1075 lines
28 KiB
Vue
1075 lines
28 KiB
Vue
<template>
|
||
<div class="policy-page">
|
||
<header class="policy-header">
|
||
<div>
|
||
<h1>管理员策略配置</h1>
|
||
<p>控制工具分类、模型与前端功能禁用。最新保存:{{ lastUpdated || '—' }}</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<div class="dropdown" :class="{ open: targetMenuOpen }">
|
||
<button type="button" class="ghost-btn" @click="toggleTargetMenu">
|
||
<span>{{ targetTypeLabel }}</span>
|
||
<span class="caret">▾</span>
|
||
</button>
|
||
<div class="dropdown-menu" v-if="targetMenuOpen">
|
||
<button v-for="opt in targetOptions" :key="opt.value" type="button" @click="pickTargetType(opt.value)">
|
||
{{ opt.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="form.target_type !== 'global'" class="text-field">
|
||
<input
|
||
v-model="form.target_value"
|
||
:placeholder="targetPlaceholder"
|
||
class="target-input"
|
||
type="text"
|
||
/>
|
||
</div>
|
||
<button type="button" class="ghost-btn" @click="loadScope">载入</button>
|
||
<button type="button" class="primary" :disabled="saving" @click="savePolicy">
|
||
{{ saving ? '保存中...' : '保存' }}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<transition name="fade">
|
||
<div v-if="banner.message" class="banner" :class="banner.type">
|
||
<span>{{ banner.message }}</span>
|
||
<button type="button" class="banner-close" @click="banner.message = ''">×</button>
|
||
</div>
|
||
</transition>
|
||
|
||
<section class="panel">
|
||
<div class="panel-title">
|
||
<h2>工具分类</h2>
|
||
<button type="button" @click="addCategory">新增分类</button>
|
||
</div>
|
||
<div class="category-table">
|
||
<div class="category-row category-head">
|
||
<span>分类 ID</span>
|
||
<span>名称</span>
|
||
<span>工具列表</span>
|
||
<span>默认启用</span>
|
||
<span>强制状态</span>
|
||
<span>操作</span>
|
||
</div>
|
||
<div class="category-row" v-for="cat in categoryList" :key="cat.id">
|
||
<input v-model="cat.id" class="id-input" />
|
||
<input v-model="cat.label" />
|
||
<div class="tool-select" :class="{ open: openToolMenu === cat.id }">
|
||
<button type="button" class="tool-select-trigger" @click.stop="toggleToolMenu(cat.id)">
|
||
<span v-if="cat.tools.length" class="tool-badges">
|
||
<span class="tool-badge" v-for="tool in cat.tools" :key="tool">{{ tool }}</span>
|
||
</span>
|
||
<span v-else class="muted">选择工具</span>
|
||
<span class="caret">▾</span>
|
||
</button>
|
||
<div class="tool-select-menu" v-if="openToolMenu === cat.id" @click.stop>
|
||
<div class="tool-select-search">
|
||
<input v-model="toolSearch" type="text" placeholder="搜索或添加工具 ID" />
|
||
</div>
|
||
<div class="tool-select-options">
|
||
<label v-for="tool in filteredToolOptions(cat)" :key="tool">
|
||
<input
|
||
type="checkbox"
|
||
:checked="cat.tools.includes(tool)"
|
||
@change="toggleToolInCategory(cat, tool)"
|
||
/>
|
||
<span>{{ tool }}</span>
|
||
</label>
|
||
<p v-if="!filteredToolOptions.length" class="muted tiny">未找到匹配的工具</p>
|
||
</div>
|
||
<button
|
||
v-if="toolSearch.trim() && !toolOptionsSet.has(toolSearch.trim())"
|
||
type="button"
|
||
class="link small"
|
||
@click="addCustomTool(cat)"
|
||
>
|
||
添加自定义工具 “{{ toolSearch.trim() }}”
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<label class="toggle-row compact">
|
||
<input type="checkbox" :checked="getCategoryDefault(cat.id)" @change="setCategoryDefault(cat.id, $event.target.checked)" />
|
||
<span class="fancy-check" aria-hidden="true">
|
||
<svg viewBox="0 0 64 64">
|
||
<path
|
||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||
pathLength="575.0541381835938"
|
||
class="fancy-path"
|
||
></path>
|
||
</svg>
|
||
</span>
|
||
<span>{{ cat.default_enabled ? '开' : '关' }}</span>
|
||
</label>
|
||
<div class="dropdown" :class="{ open: openForceMenu === cat.id }">
|
||
<button type="button" class="ghost-btn" @click="toggleForceMenu(cat.id)">
|
||
<span>{{ forcedLabel(cat.forced) }}</span>
|
||
<span class="caret">▾</span>
|
||
</button>
|
||
<div class="dropdown-menu" v-if="openForceMenu === cat.id">
|
||
<button type="button" @click="setForced(cat.id, null)">不强制</button>
|
||
<button type="button" @click="setForced(cat.id, true)">强制启用</button>
|
||
<button type="button" @click="setForced(cat.id, false)">强制禁用</button>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="link danger" @click="removeCategory(cat.id)">删除</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel grid-2">
|
||
<div>
|
||
<div class="panel-title">
|
||
<h2>模型禁用</h2>
|
||
</div>
|
||
<div class="toggle-grid">
|
||
<label
|
||
v-for="model in defaults.models"
|
||
:key="model"
|
||
class="toggle-row"
|
||
>
|
||
<input type="checkbox" :checked="isModelDisabled(model)" @change="toggleModel(model)" />
|
||
<span class="fancy-check" aria-hidden="true">
|
||
<svg viewBox="0 0 64 64">
|
||
<path
|
||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||
pathLength="575.0541381835938"
|
||
class="fancy-path"
|
||
></path>
|
||
</svg>
|
||
</span>
|
||
<span>{{ model }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="panel-title">
|
||
<h2>前端禁用项</h2>
|
||
</div>
|
||
<div class="toggle-grid">
|
||
<label
|
||
v-for="key in defaults.ui_block_keys"
|
||
:key="key"
|
||
class="toggle-row"
|
||
>
|
||
<input type="checkbox" :checked="!!form.config.ui_blocks[key]" @change="toggleUiBlock(key, $event)" />
|
||
<span class="fancy-check" aria-hidden="true">
|
||
<svg viewBox="0 0 64 64">
|
||
<path
|
||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||
pathLength="575.0541381835938"
|
||
class="fancy-path"
|
||
></path>
|
||
</svg>
|
||
</span>
|
||
<span>{{ uiBlockLabel(key) }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-title">
|
||
<h2>已删除分类</h2>
|
||
</div>
|
||
<div class="chips">
|
||
<span v-if="!form.config.remove_categories.length" class="muted">无</span>
|
||
<span v-for="cid in form.config.remove_categories" :key="cid" class="chip">
|
||
{{ cid }}
|
||
<button type="button" @click="undoRemove(cid)">×</button>
|
||
</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel muted-info">
|
||
<p>说明:</p>
|
||
<ul>
|
||
<li>优先级:用户 > 邀请码 > 角色 > 全局。</li>
|
||
<li>分类“强制状态”会覆盖用户侧的工具开关,并在前端提示“被管理员强制”。</li>
|
||
<li>UI 禁用项会在用户操作时弹出右上角提示。</li>
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
|
||
|
||
type TargetType = 'global' | 'role' | 'user' | 'invite';
|
||
|
||
interface RawPolicy {
|
||
updated_at?: string;
|
||
global?: any;
|
||
roles?: Record<string, any>;
|
||
users?: Record<string, any>;
|
||
invites?: Record<string, any>;
|
||
}
|
||
|
||
interface CategoryFormItem {
|
||
id: string;
|
||
label: string;
|
||
tools: string[];
|
||
default_enabled: boolean;
|
||
forced: boolean | null;
|
||
}
|
||
|
||
const defaults = reactive({
|
||
categories: {} as Record<string, any>,
|
||
models: [] as string[],
|
||
ui_block_keys: [] as string[]
|
||
});
|
||
|
||
const form = reactive({
|
||
target_type: 'global' as TargetType,
|
||
target_value: '',
|
||
config: {
|
||
category_overrides: {} as Record<string, any>,
|
||
remove_categories: [] as string[],
|
||
forced_category_states: {} as Record<string, boolean>,
|
||
disabled_models: [] as string[],
|
||
ui_blocks: {} as Record<string, boolean>
|
||
}
|
||
});
|
||
|
||
const lastUpdated = ref<string | null>(null);
|
||
const saving = ref(false);
|
||
const policyCache = ref<RawPolicy | null>(null);
|
||
|
||
const targetPlaceholder = computed(() => {
|
||
if (form.target_type === 'role') return '如:admin / user';
|
||
if (form.target_type === 'invite') return '邀请码';
|
||
if (form.target_type === 'user') return '用户名(小写)';
|
||
return '';
|
||
});
|
||
|
||
const targetOptions = [
|
||
{ value: 'global', label: '全局' },
|
||
{ value: 'role', label: '按角色' },
|
||
{ value: 'user', label: '指定用户' },
|
||
{ value: 'invite', label: '邀请码' }
|
||
] as const;
|
||
|
||
const targetTypeLabel = computed(() => targetOptions.find(o => o.value === form.target_type)?.label || '全局');
|
||
const targetMenuOpen = ref(false);
|
||
const openForceMenu = ref<string | null>(null);
|
||
const openToolMenu = ref<string | null>(null);
|
||
const toolSearch = ref('');
|
||
|
||
const banner = reactive({ message: '', type: 'info' as 'info' | 'success' | 'error' });
|
||
|
||
const toolOptionsSet = computed<Set<string>>(() => {
|
||
const set = new Set<string>();
|
||
const addList = (list: any) => {
|
||
if (!Array.isArray(list)) return;
|
||
list.forEach((t) => {
|
||
if (typeof t === 'string' && t.trim()) set.add(t.trim());
|
||
});
|
||
};
|
||
Object.values(defaults.categories || {}).forEach((cat: any) => addList(cat?.tools));
|
||
categoryList.value.forEach((cat) => addList(cat.tools));
|
||
return set;
|
||
});
|
||
|
||
const toolOptions = computed(() => Array.from(toolOptionsSet.value).sort());
|
||
|
||
const toolAssignments = computed<Record<string, string[]>>(() => {
|
||
const map: Record<string, string[]> = {};
|
||
categoryList.value.forEach((cat) => {
|
||
(cat.tools || []).forEach((tool) => {
|
||
if (!map[tool]) map[tool] = [];
|
||
map[tool].push(cat.id);
|
||
});
|
||
});
|
||
return map;
|
||
});
|
||
|
||
const optionsForCategory = (cat: CategoryFormItem) => {
|
||
return toolOptions.value.filter((tool) => {
|
||
const owners = toolAssignments.value[tool] || [];
|
||
if (!owners.length) return true;
|
||
return owners.length === 1 && owners[0] === cat.id;
|
||
});
|
||
};
|
||
|
||
const filteredToolOptions = (cat: CategoryFormItem) => {
|
||
const q = toolSearch.value.trim().toLowerCase();
|
||
const base = optionsForCategory(cat);
|
||
if (!q) return base;
|
||
return base.filter((item) => item.toLowerCase().includes(q));
|
||
};
|
||
|
||
const toggleTargetMenu = () => {
|
||
targetMenuOpen.value = !targetMenuOpen.value;
|
||
};
|
||
|
||
const pickTargetType = (value: TargetType) => {
|
||
form.target_type = value;
|
||
if (value === 'global') form.target_value = '';
|
||
targetMenuOpen.value = false;
|
||
openForceMenu.value = null;
|
||
openToolMenu.value = null;
|
||
applyScopeConfig();
|
||
};
|
||
|
||
const toggleForceMenu = (id: string) => {
|
||
openForceMenu.value = openForceMenu.value === id ? null : id;
|
||
};
|
||
|
||
const forcedLabel = (value: boolean | null) => {
|
||
if (value === true) return '强制启用';
|
||
if (value === false) return '强制禁用';
|
||
return '不强制';
|
||
};
|
||
|
||
const setForced = (id: string, value: boolean | null) => {
|
||
const map = { ...form.config.forced_category_states };
|
||
if (value === null) {
|
||
delete map[id];
|
||
} else {
|
||
map[id] = value;
|
||
}
|
||
form.config.forced_category_states = map;
|
||
openForceMenu.value = null;
|
||
};
|
||
|
||
const toggleToolMenu = (id: string) => {
|
||
openToolMenu.value = openToolMenu.value === id ? null : id;
|
||
toolSearch.value = '';
|
||
};
|
||
|
||
const toggleToolInCategory = (cat: CategoryFormItem, tool: string) => {
|
||
const owners = toolAssignments.value[tool] || [];
|
||
const conflict = owners.find((id) => id !== cat.id);
|
||
if (conflict) {
|
||
banner.message = `工具 ${tool} 已在分类 ${conflict} 中,请先移除后再分配`;
|
||
banner.type = 'error';
|
||
return;
|
||
}
|
||
const set = new Set(cat.tools || []);
|
||
if (set.has(tool)) {
|
||
set.delete(tool);
|
||
} else {
|
||
set.add(tool);
|
||
}
|
||
cat.tools = Array.from(set);
|
||
};
|
||
|
||
const addCustomTool = (cat: CategoryFormItem) => {
|
||
const val = toolSearch.value.trim();
|
||
if (!val) return;
|
||
const owners = toolAssignments.value[val] || [];
|
||
const conflict = owners.find((id) => id !== cat.id);
|
||
if (conflict) {
|
||
banner.message = `工具 ${val} 已在分类 ${conflict} 中,请先移除后再分配`;
|
||
banner.type = 'error';
|
||
return;
|
||
}
|
||
toggleToolInCategory(cat, val);
|
||
toolSearch.value = '';
|
||
};
|
||
|
||
const handleDocClick = (event: MouseEvent) => {
|
||
const target = event.target as HTMLElement | null;
|
||
if (!target) return;
|
||
if (!target.closest('.tool-select')) {
|
||
openToolMenu.value = null;
|
||
}
|
||
};
|
||
|
||
const categoryList = computed<CategoryFormItem[]>(() => {
|
||
const map = form.config.category_overrides || {};
|
||
return Object.keys(map).map((id) => ({
|
||
id,
|
||
label: map[id]?.label || id,
|
||
tools: Array.isArray(map[id]?.tools) ? [...map[id].tools] : [],
|
||
default_enabled: map[id]?.default_enabled !== false,
|
||
forced: form.config.forced_category_states[id] ?? null
|
||
}));
|
||
});
|
||
|
||
const getCategoryDefault = (id: string): boolean => {
|
||
const map = form.config.category_overrides || {};
|
||
if (map[id] && typeof map[id].default_enabled !== 'undefined') {
|
||
return !!map[id].default_enabled;
|
||
}
|
||
const base = defaults.categories?.[id];
|
||
return base ? !!base.default_enabled : true;
|
||
};
|
||
|
||
const setCategoryDefault = (id: string, enabled: boolean) => {
|
||
const current = form.config.category_overrides || {};
|
||
const base = current[id] || defaults.categories?.[id] || { label: id, tools: [] };
|
||
form.config.category_overrides = {
|
||
...current,
|
||
[id]: { ...base, default_enabled: enabled }
|
||
};
|
||
};
|
||
|
||
function uiBlockLabel(key: string) {
|
||
const map: Record<string, string> = {
|
||
collapse_workspace: '折叠工作区',
|
||
block_file_manager: '禁止文件管理器',
|
||
block_personal_space: '禁止个人空间',
|
||
block_upload: '禁止上传',
|
||
block_conversation_review: '禁止对话引用',
|
||
block_tool_toggle: '禁止工具禁用',
|
||
block_realtime_terminal: '禁止实时终端',
|
||
block_focus_panel: '禁止聚焦面板',
|
||
block_token_panel: '禁止用量统计',
|
||
block_compress_conversation: '禁止压缩对话',
|
||
block_virtual_monitor: '禁止虚拟显示器'
|
||
};
|
||
return map[key] || key;
|
||
}
|
||
|
||
function toggleUiBlock(key: string, event: Event) {
|
||
event?.stopPropagation?.();
|
||
form.config.ui_blocks = {
|
||
...form.config.ui_blocks,
|
||
[key]: !form.config.ui_blocks[key]
|
||
};
|
||
}
|
||
|
||
function isModelDisabled(key: string) {
|
||
return (form.config.disabled_models || []).includes(key);
|
||
}
|
||
|
||
function toggleModel(key: string) {
|
||
const set = new Set(form.config.disabled_models || []);
|
||
if (set.has(key)) {
|
||
set.delete(key);
|
||
} else {
|
||
set.add(key);
|
||
}
|
||
form.config.disabled_models = Array.from(set);
|
||
}
|
||
|
||
function addCategory() {
|
||
const id = `custom_${Date.now().toString(36)}`;
|
||
form.config.category_overrides[id] = {
|
||
label: id,
|
||
tools: [],
|
||
default_enabled: true
|
||
};
|
||
}
|
||
|
||
function removeCategory(id: string) {
|
||
delete form.config.category_overrides[id];
|
||
form.config.forced_category_states[id] && delete form.config.forced_category_states[id];
|
||
if (!form.config.remove_categories.includes(id)) {
|
||
form.config.remove_categories.push(id);
|
||
}
|
||
}
|
||
|
||
function undoRemove(id: string) {
|
||
form.config.remove_categories = form.config.remove_categories.filter((item) => item !== id);
|
||
}
|
||
|
||
function rebuildCategoryOverrides() {
|
||
const map: Record<string, any> = {};
|
||
categoryList.value.forEach((item) => {
|
||
if (!item.id) return;
|
||
map[item.id] = {
|
||
label: item.label || item.id,
|
||
tools: (item.tools || [])
|
||
.map((s) => (typeof s === 'string' ? s.trim() : ''))
|
||
.filter(Boolean),
|
||
default_enabled: !!item.default_enabled
|
||
};
|
||
if (item.forced === true || item.forced === false) {
|
||
form.config.forced_category_states[item.id] = item.forced;
|
||
} else {
|
||
delete form.config.forced_category_states[item.id];
|
||
}
|
||
});
|
||
form.config.category_overrides = map;
|
||
}
|
||
|
||
async function fetchDefaults() {
|
||
const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' });
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.success) {
|
||
throw new Error(data.error || '加载默认配置失败');
|
||
}
|
||
defaults.categories = data.defaults?.categories || {};
|
||
defaults.models = data.defaults?.models || [];
|
||
defaults.ui_block_keys = data.defaults?.ui_block_keys || [];
|
||
policyCache.value = data.data;
|
||
lastUpdated.value = data.data?.updated_at || null;
|
||
applyScopeConfig();
|
||
banner.message = '';
|
||
}
|
||
|
||
function scopeConfig(): any {
|
||
const p = policyCache.value;
|
||
if (!p) return null;
|
||
if (form.target_type === 'global') return p.global || {};
|
||
if (form.target_type === 'role') return (p.roles || {})[form.target_value] || {};
|
||
if (form.target_type === 'user') return (p.users || {})[form.target_value] || {};
|
||
if (form.target_type === 'invite') return (p.invites || {})[form.target_value] || {};
|
||
return null;
|
||
}
|
||
|
||
function applyScopeConfig() {
|
||
const cfg = scopeConfig() || {};
|
||
const base = JSON.parse(JSON.stringify(defaults.categories || {}));
|
||
const overrides = cfg.category_overrides || {};
|
||
const removed = new Set(cfg.remove_categories || []);
|
||
|
||
Object.keys(overrides).forEach((key) => {
|
||
base[key] = overrides[key];
|
||
});
|
||
removed.forEach((id: string) => {
|
||
delete base[id];
|
||
});
|
||
|
||
form.config.category_overrides = base;
|
||
form.config.remove_categories = [...removed];
|
||
form.config.forced_category_states = { ...(cfg.forced_category_states || {}) };
|
||
form.config.disabled_models = [...(cfg.disabled_models || [])];
|
||
form.config.ui_blocks = { ...(cfg.ui_blocks || {}) };
|
||
openForceMenu.value = null;
|
||
openToolMenu.value = null;
|
||
}
|
||
|
||
async function loadScope() {
|
||
if (!policyCache.value) {
|
||
await fetchDefaults();
|
||
return;
|
||
}
|
||
applyScopeConfig();
|
||
}
|
||
|
||
async function savePolicy() {
|
||
rebuildCategoryOverrides();
|
||
saving.value = true;
|
||
try {
|
||
const resp = await fetch('/api/admin/policy', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
target_type: form.target_type,
|
||
target_value: form.target_type === 'global' ? 'global' : form.target_value,
|
||
config: form.config
|
||
})
|
||
});
|
||
const result = await resp.json();
|
||
if (!resp.ok || !result.success) {
|
||
throw new Error(result.error || '保存失败');
|
||
}
|
||
policyCache.value = result.data;
|
||
lastUpdated.value = result.data?.updated_at || null;
|
||
banner.message = '保存成功';
|
||
banner.type = 'success';
|
||
} catch (error: any) {
|
||
banner.message = error?.message || '保存失败';
|
||
banner.type = 'error';
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('click', handleDocClick);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('click', handleDocClick);
|
||
});
|
||
|
||
fetchDefaults().catch((err) => {
|
||
console.error(err);
|
||
banner.message = err?.message || '加载策略失败';
|
||
banner.type = 'error';
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
:global(body) {
|
||
margin: 0;
|
||
background: #f7f3ea;
|
||
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
||
color: #2a2013;
|
||
}
|
||
|
||
:global(#admin-policy-app) {
|
||
min-height: 100vh;
|
||
padding: 24px 32px 48px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.policy-page {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.policy-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.dropdown {
|
||
position: relative;
|
||
}
|
||
|
||
.ghost-btn {
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
background: rgba(255, 255, 255, 0.9);
|
||
color: #2a2013;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ghost-btn .caret {
|
||
font-size: 12px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.dropdown-menu {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
left: 0;
|
||
min-width: 160px;
|
||
background: #fff;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
border-radius: 12px;
|
||
box-shadow: 0 12px 30px rgba(0,0,0,0.12);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
z-index: 10;
|
||
}
|
||
|
||
.dropdown-menu button {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 10px 12px;
|
||
text-align: left;
|
||
width: 100%;
|
||
color: #2a2013;
|
||
}
|
||
|
||
.dropdown-menu button:hover {
|
||
background: rgba(0,0,0,0.04);
|
||
}
|
||
|
||
.text-field input {
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
background: #fff;
|
||
}
|
||
|
||
.banner {
|
||
margin-top: 12px;
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.banner.success {
|
||
background: rgba(73, 160, 120, 0.12);
|
||
border-color: rgba(73, 160, 120, 0.35);
|
||
}
|
||
|
||
.banner.error {
|
||
background: rgba(189, 93, 58, 0.12);
|
||
border-color: rgba(189, 93, 58, 0.35);
|
||
}
|
||
|
||
.banner-close {
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.panel {
|
||
background: rgba(255, 255, 255, 0.9);
|
||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
.panel-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.category-table {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.category-row {
|
||
display: grid;
|
||
grid-template-columns: 1.2fr 1.2fr 2fr 0.8fr 1fr 0.8fr;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.category-head {
|
||
font-weight: 600;
|
||
color: #5b4d3b;
|
||
}
|
||
|
||
.category-row input,
|
||
.category-row select {
|
||
padding: 8px 10px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
background: #fff;
|
||
}
|
||
|
||
.id-input {
|
||
font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
}
|
||
|
||
.checkbox-cell {
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.grid-2 {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.checkbox-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.toggle-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.check-pill {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
background: rgba(255,255,255,0.92);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.check-pill.compact {
|
||
padding: 8px 10px;
|
||
}
|
||
|
||
.check-pill .pill-knob {
|
||
width: 34px;
|
||
height: 18px;
|
||
border-radius: 999px;
|
||
background: rgba(0,0,0,0.12);
|
||
position: relative;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.check-pill .pill-knob::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.check-pill.on {
|
||
border-color: rgba(73, 160, 120, 0.45);
|
||
box-shadow: 0 6px 18px rgba(73, 160, 120, 0.18);
|
||
}
|
||
|
||
.check-pill.on .pill-knob {
|
||
background: linear-gradient(90deg, #49a078, #4fb28a);
|
||
}
|
||
|
||
.check-pill.on .pill-knob::after {
|
||
transform: translateX(16px);
|
||
}
|
||
|
||
.check-pill .pill-label {
|
||
font-weight: 600;
|
||
color: #2a2013;
|
||
}
|
||
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chip {
|
||
background: rgba(118, 103, 84, 0.12);
|
||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||
border-radius: 999px;
|
||
padding: 6px 10px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.muted {
|
||
color: #8a7a62;
|
||
}
|
||
|
||
.muted-info {
|
||
color: #6a5d4c;
|
||
background: rgba(247, 243, 234, 0.9);
|
||
}
|
||
|
||
.link {
|
||
background: transparent;
|
||
border: 1px solid rgba(176, 91, 60, 0.28);
|
||
color: #5b4d3b;
|
||
cursor: pointer;
|
||
padding: 6px 10px;
|
||
border-radius: 10px;
|
||
transition: background 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
|
||
.link.danger {
|
||
color: #b05b3c;
|
||
border-color: rgba(176, 91, 60, 0.4);
|
||
background: rgba(176, 91, 60, 0.06);
|
||
}
|
||
|
||
.link.danger:hover {
|
||
background: rgba(176, 91, 60, 0.12);
|
||
border-color: rgba(176, 91, 60, 0.55);
|
||
}
|
||
|
||
button {
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
background: #fff;
|
||
cursor: pointer;
|
||
}
|
||
|
||
button.primary {
|
||
background: linear-gradient(90deg, #4b2e14, #8b5d3b);
|
||
color: #fff;
|
||
border: none;
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* 复用个人空间的勾选样式 */
|
||
.toggle-row {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--theme-control-border, rgba(118, 103, 84, 0.25));
|
||
background: var(--theme-surface-muted, rgba(255, 255, 255, 0.85));
|
||
cursor: pointer;
|
||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||
}
|
||
|
||
.toggle-row.compact {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.toggle-row:hover {
|
||
border-color: var(--theme-control-border-strong, rgba(118, 103, 84, 0.35));
|
||
background: var(--theme-surface-soft, rgba(255, 255, 255, 0.92));
|
||
}
|
||
|
||
.toggle-row input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.toggle-row .fancy-check {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 6px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.toggle-row .fancy-check svg {
|
||
width: 22px;
|
||
height: 22px;
|
||
overflow: visible;
|
||
}
|
||
|
||
.fancy-path {
|
||
fill: none;
|
||
stroke: var(--claude-text-secondary, #7f7766);
|
||
stroke-width: 5;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
transition: stroke-dasharray 0.5s ease, stroke-dashoffset 0.5s ease, stroke 0.2s ease;
|
||
stroke-dasharray: 241 9999999;
|
||
stroke-dashoffset: 0;
|
||
}
|
||
|
||
.toggle-row input:checked + .fancy-check .fancy-path {
|
||
stroke: var(--claude-accent, #da7756);
|
||
stroke-dasharray: 70.5096664428711 9999999;
|
||
stroke-dashoffset: -262.2723388671875;
|
||
}
|
||
|
||
.toggle-row span {
|
||
color: var(--claude-text, #3d3929);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tool-select {
|
||
position: relative;
|
||
}
|
||
|
||
.tool-select-trigger {
|
||
width: 100%;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 9px 10px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
background: #fff;
|
||
cursor: pointer;
|
||
min-height: 40px;
|
||
}
|
||
|
||
.tool-select.open .tool-select-trigger {
|
||
border-color: rgba(118, 103, 84, 0.4);
|
||
box-shadow: 0 10px 24px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.tool-badges {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
|
||
.tool-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 8px;
|
||
border-radius: 999px;
|
||
background: rgba(118, 103, 84, 0.12);
|
||
color: #4b3d2f;
|
||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.tool-select-menu {
|
||
position: absolute;
|
||
z-index: 20;
|
||
top: calc(100% + 6px);
|
||
left: 0;
|
||
min-width: 260px;
|
||
max-height: 240px;
|
||
background: #fffaf4;
|
||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||
border-radius: 12px;
|
||
box-shadow: 0 16px 36px rgba(0,0,0,0.12);
|
||
padding: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tool-select-search input {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0,0,0,0.12);
|
||
background: #fff;
|
||
}
|
||
|
||
.tool-select-options {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 6px;
|
||
max-height: 140px;
|
||
overflow: auto;
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
}
|
||
|
||
.tool-select-options::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.tool-select-options label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 8px;
|
||
border-radius: 8px;
|
||
background: rgba(255,255,255,0.85);
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.tool-select-options input {
|
||
position: static;
|
||
opacity: 1;
|
||
}
|
||
|
||
.link.small {
|
||
font-size: 13px;
|
||
align-self: flex-start;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.tiny {
|
||
font-size: 12px;
|
||
}
|
||
</style>
|