agent-Specialization/static/src/admin/PolicyApp.vue

1602 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="policy-page">
<div class="secondary-overlay" v-if="!secondaryVerified">
<div class="secondary-card">
<h3>二级密码校验</h3>
<p class="muted">请输入管理员二级密码后继续配置策略。</p>
<input
class="secondary-input"
type="password"
v-model="secondaryPassword"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/>
<div class="secondary-actions">
<button
type="button"
class="primary"
:disabled="secondaryLoading"
@click="handleVerifySecondary"
>
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<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>MCP 服务配置(统一工具扩展)</h2>
<div class="header-actions">
<button type="button" class="ghost-btn" :disabled="mcpLoading" @click="fetchMcpServers">
{{ mcpLoading ? '刷新中...' : '刷新' }}
</button>
<button type="button" class="ghost-btn" :disabled="mcpSyncing" @click="syncAllMcpServers">
{{ mcpSyncing ? '同步中...' : '同步全部工具' }}
</button>
<button type="button" @click="addMcpServer">新增 MCP 服务</button>
</div>
</div>
<div v-if="!mcpServers.length" class="muted">暂无 MCP 服务,点击“新增 MCP 服务”开始配置。</div>
<div class="mcp-list" v-else>
<div class="mcp-item" v-for="(server, idx) in mcpServers" :key="server.id || `new-${idx}`">
<div class="mcp-grid">
<label>
<span>ID</span>
<input v-model="server.id" placeholder="如 demo_stdio" />
</label>
<label>
<span>名称</span>
<input v-model="server.name" placeholder="显示名称(可选)" />
</label>
<label>
<span>Transport</span>
<select v-model="server.transport">
<option value="stdio">stdio</option>
<option value="streamable_http">streamable_http</option>
</select>
</label>
<label>
<span>启用</span>
<select v-model="server.enabledText">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label v-if="server.transport === 'stdio'" class="wide">
<span>command</span>
<input v-model="server.command" placeholder="如 python3" />
</label>
<label v-if="server.transport === 'stdio'" class="wide">
<span>args每行一个留空=无)</span>
<textarea
v-model="server.argsText"
rows="3"
placeholder="如\n/abs/path/mcp_server.py"
></textarea>
</label>
<label v-if="server.transport === 'stdio'" class="wide">
<span>cwd可选</span>
<input v-model="server.cwd" placeholder="工作目录" />
</label>
<label v-if="server.transport === 'streamable_http'" class="wide">
<span>url</span>
<input v-model="server.url" placeholder="如 http://127.0.0.1:8000/mcp" />
</label>
<label class="wide">
<span>headersJSON可选</span>
<textarea
v-model="server.headersText"
rows="2"
placeholder='如 {"Authorization":"Bearer xxx"}'
></textarea>
</label>
<label class="wide">
<span>envJSON可选stdio 有效)</span>
<textarea v-model="server.envText" rows="2" placeholder='如 {"PYTHONUNBUFFERED":"1"}'></textarea>
</label>
<label>
<span>timeout(s)</span>
<input v-model="server.timeoutText" placeholder="25" />
</label>
<label class="wide">
<span>include_tools逗号分隔可选</span>
<input v-model="server.includeToolsText" placeholder="如 echo,read_db" />
</label>
<label class="wide">
<span>exclude_tools逗号分隔可选</span>
<input v-model="server.excludeToolsText" placeholder="如 dangerous_tool" />
</label>
<label class="wide">
<span>说明(可选)</span>
<input v-model="server.description" placeholder="备注说明" />
</label>
</div>
<div class="mcp-meta">
<span>最近同步:{{ server.tools_cache_updated_at || '未同步' }}</span>
<span v-if="server.last_error" class="error-text">错误:{{ server.last_error }}</span>
<span>缓存工具数:{{ server.tools_cache_count }}</span>
</div>
<div class="mcp-actions">
<button type="button" class="primary" @click="saveMcpServer(server)">保存</button>
<button type="button" class="ghost-btn" @click="syncMcpServer(server.id)">同步工具</button>
<button type="button" class="link danger" @click="deleteMcpServer(server.id, idx)">删除</button>
</div>
</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>优先级:用户 &gt; 邀请码 &gt; 角色 &gt; 全局。</li>
<li>分类“强制状态”会覆盖用户侧的工具开关,并在前端提示“被管理员强制”。</li>
<li>UI 禁用项会在用户操作时弹出右上角提示。</li>
</ul>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass';
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;
}
interface MCPServerFormItem {
id: string;
name: string;
description: string;
transport: 'stdio' | 'streamable_http';
enabledText: 'true' | 'false';
command: string;
argsText: string;
cwd: string;
url: string;
headersText: string;
envText: string;
timeoutText: string;
includeToolsText: string;
excludeToolsText: string;
tools_cache_updated_at: string;
last_error: string;
tools_cache_count: number;
}
const defaults = reactive({
categories: {} as Record<string, any>,
models: [] as string[],
ui_block_keys: [] as string[]
});
const {
verified: secondaryVerified,
loading: secondaryLoading,
error: secondaryError,
check: checkSecondary,
verify: verifySecondary
} = useSecondaryPass();
const secondaryPassword = ref('');
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 mcpServers = ref<MCPServerFormItem[]>([]);
const mcpLoading = ref(false);
const mcpSyncing = ref(false);
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;
}
function splitCsv(value: string): string[] {
return (value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function linesToList(value: string): string[] {
return (value || '')
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
}
function parseJsonDict(label: string, raw: string): Record<string, string> {
const text = (raw || '').trim();
if (!text) return {};
let parsed: any;
try {
parsed = JSON.parse(text);
} catch (error: any) {
throw new Error(`${label} 不是合法 JSON${error?.message || error}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
const output: Record<string, string> = {};
Object.entries(parsed).forEach(([k, v]) => {
const key = String(k || '').trim();
if (!key) return;
output[key] = String(v ?? '');
});
return output;
}
function mapMcpServer(raw: any): MCPServerFormItem {
const transport = raw?.transport === 'streamable_http' ? 'streamable_http' : 'stdio';
const args = Array.isArray(raw?.args) ? raw.args.filter((x: any) => typeof x === 'string') : [];
const toolsCache = Array.isArray(raw?.tools_cache) ? raw.tools_cache : [];
return {
id: String(raw?.id || ''),
name: String(raw?.name || raw?.id || ''),
description: String(raw?.description || ''),
transport,
enabledText: raw?.enabled === false ? 'false' : 'true',
command: String(raw?.command || ''),
argsText: args.join('\n'),
cwd: String(raw?.cwd || ''),
url: String(raw?.url || ''),
headersText: raw?.headers && typeof raw.headers === 'object' ? JSON.stringify(raw.headers, null, 2) : '',
envText: raw?.env && typeof raw.env === 'object' ? JSON.stringify(raw.env, null, 2) : '',
timeoutText: String(raw?.timeout_seconds ?? 25),
includeToolsText: Array.isArray(raw?.include_tools) ? raw.include_tools.join(',') : '',
excludeToolsText: Array.isArray(raw?.exclude_tools) ? raw.exclude_tools.join(',') : '',
tools_cache_updated_at: String(raw?.tools_cache_updated_at || ''),
last_error: String(raw?.last_error || ''),
tools_cache_count: toolsCache.length
};
}
function normalizeMcpPayload(server: MCPServerFormItem) {
const id = server.id.trim();
if (!id) throw new Error('MCP 服务 ID 不能为空');
return {
id,
name: server.name.trim() || id,
description: server.description.trim(),
transport: server.transport,
enabled: server.enabledText === 'true',
command: server.command.trim(),
args: linesToList(server.argsText),
cwd: server.cwd.trim(),
url: server.url.trim(),
headers: parseJsonDict('headers', server.headersText),
env: parseJsonDict('env', server.envText),
timeout_seconds: Number(server.timeoutText || 25),
include_tools: splitCsv(server.includeToolsText),
exclude_tools: splitCsv(server.excludeToolsText)
};
}
async function fetchMcpServers() {
if (!secondaryVerified.value) return;
mcpLoading.value = true;
try {
const resp = await fetch('/api/admin/mcp-servers', { credentials: 'same-origin' });
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '加载 MCP 服务失败');
}
mcpServers.value = (data.data || []).map((item: any) => mapMcpServer(item));
} catch (error: any) {
banner.message = error?.message || '加载 MCP 服务失败';
banner.type = 'error';
} finally {
mcpLoading.value = false;
}
}
function addMcpServer() {
mcpServers.value.unshift(
mapMcpServer({
id: '',
name: '',
transport: 'stdio',
enabled: true,
timeout_seconds: 25
})
);
}
async function saveMcpServer(server: MCPServerFormItem) {
if (!secondaryVerified.value) return;
try {
const payload = normalizeMcpPayload(server);
const resp = await fetch('/api/admin/mcp-servers', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '保存 MCP 服务失败');
}
banner.message = `MCP 服务 ${payload.id} 已保存`;
banner.type = 'success';
await syncMcpServer(payload.id, true);
} catch (error: any) {
banner.message = error?.message || '保存 MCP 服务失败';
banner.type = 'error';
}
}
async function deleteMcpServer(id: string, idx: number) {
if (!secondaryVerified.value) return;
const target = String(id || '').trim();
if (!target) {
mcpServers.value.splice(idx, 1);
return;
}
try {
const resp = await fetch(`/api/admin/mcp-servers?id=${encodeURIComponent(target)}`, {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '删除 MCP 服务失败');
}
banner.message = `已删除 MCP 服务 ${target}`;
banner.type = 'success';
await fetchMcpServers();
await fetchDefaults();
} catch (error: any) {
banner.message = error?.message || '删除 MCP 服务失败';
banner.type = 'error';
}
}
async function syncMcpServer(id?: string, silent = false) {
if (!secondaryVerified.value) return;
const target = String(id || '').trim();
if (typeof id !== 'undefined' && !target) {
banner.message = '请先保存服务后再同步工具';
banner.type = 'error';
return;
}
if (!silent) mcpSyncing.value = true;
try {
const resp = await fetch('/api/admin/mcp-servers/sync', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(target ? { id: target } : {})
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '同步 MCP 工具失败');
}
if (!silent) {
banner.message = target ? `已同步 ${target}` : '已同步全部 MCP 服务';
banner.type = 'success';
}
await fetchMcpServers();
await fetchDefaults();
} catch (error: any) {
banner.message = error?.message || '同步 MCP 工具失败';
banner.type = 'error';
} finally {
if (!silent) mcpSyncing.value = false;
}
}
async function syncAllMcpServers() {
await syncMcpServer();
}
async function fetchDefaults() {
if (!secondaryVerified.value) return;
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();
}
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 (!secondaryVerified.value) return;
if (!policyCache.value) {
await fetchDefaults();
return;
}
applyScopeConfig();
}
async function savePolicy() {
if (!secondaryVerified.value) return;
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;
}
}
const handleVerifySecondary = async () => {
await verifySecondary(secondaryPassword.value);
if (secondaryVerified.value) {
secondaryPassword.value = '';
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
});
}
};
onMounted(async () => {
document.addEventListener('click', handleDocClick);
await checkSecondary();
if (secondaryVerified.value) {
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
});
}
});
watch(secondaryVerified, (val) => {
if (val) {
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
});
}
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocClick);
});
</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);
}
.mcp-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-item {
border: 1px solid rgba(118, 103, 84, 0.2);
border-radius: 14px;
padding: 12px;
background: rgba(255, 255, 255, 0.92);
}
.mcp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.mcp-grid label {
display: flex;
flex-direction: column;
gap: 6px;
color: #5b4d3b;
font-size: 13px;
}
.mcp-grid label.wide {
grid-column: 1 / -1;
}
.mcp-grid textarea,
.mcp-grid input,
.mcp-grid select {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff;
}
.mcp-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
color: #8a7a62;
font-size: 12px;
}
.mcp-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.error-text {
color: #b05b3c;
}
.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;
}
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
</style>