feat: 设置页 MCP 管理卡片(方案B)— DB 真开关 + 即时生效 + env 仅首次种子
- app_settings 表(key/value):MCP 开关与令牌存 DB,不属于任何用户、不参与同步协议 - 配置读取/写入收敛到 core/mcp.js(readMcpConfig/saveMcpToken/setMcpEnabled) - 状态语义:未配置→404(端点视为不存在);已配置关闭→503(可重新开启);令牌错→401 - env MCP_AUTH_TOKEN 仅首次种子(库里从没有过令牌时迁入 DB),不设也行;后台改动即时生效无需重启 - 管理 API(登录态):GET /api/mcp/status(掩码)、POST /api/mcp/token(生成/重置,明文仅一次)、GET /api/mcp/token(复制用)、POST /api/mcp/enabled - 设置页新增「AI 接入 (MCP)」卡片:开关 toggle、掩码展示、一键生成并复制(writeClipboard 兜底)、重置令牌;未登录徽章「本设备请先登录」 - 入口接线:node-server.js 与 worker.js 均挂 /mcp(seedToken 透传);sw.js 放行裸 /mcp(v2→v3) - 两个适配器补 getSetting/setSetting(node:sqlite 与 D1 同语义) - 测试:server/test/mcp.test.js 39 项(未配置关闭/鉴权/JSON-RPC 全链路/开关热切换/令牌轮换);npm test 全绿(107+72+39) - 线上 E2E:生产令牌 initialize/tools 7 个/2026-09 大盘(出勤11天/82h/挣得1599);公网 HTTPS 链路通;UI puppeteer 17/17(含关→503→开→200 热切换,全程无重启) - 文档:README + server/README(环境变量表/接口表/数据模型/MCP 章节)
This commit is contained in:
+118
-1
@@ -809,6 +809,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 接入 (MCP):让对话式 AI 只读查询打卡与薪酬;开关与令牌存服务器 DB,改动即时生效 -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="px-5 py-3 bg-slate-50 border-b border-slate-100 flex items-center justify-between gap-2">
|
||||
<p class="text-xs font-bold text-slate-700 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-lightbulb" /></svg>AI 接入 (MCP)</p>
|
||||
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="mcpBadge.cls">{{ mcpBadge.text }}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-xs text-slate-600">接入开关</span>
|
||||
<button @click="toggleMcp" :disabled="mcp.busy || !mcp.configured" class="relative w-11 h-6 rounded-full transition shrink-0 disabled:opacity-40" :class="mcp.enabled ? 'bg-emerald-500' : 'bg-slate-300'">
|
||||
<span class="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" :class="mcp.enabled ? 'left-[22px]' : 'left-0.5'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="mcp.configured" class="flex items-center justify-between gap-2 px-5 py-3">
|
||||
<span class="text-xs text-slate-600 shrink-0">访问令牌</span>
|
||||
<span class="text-[11px] font-bold text-slate-700 font-mono-num">{{ mcp.masked }}</span>
|
||||
</div>
|
||||
<button v-if="mcp.configured" @click="copyMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use :href="mcpCopied ? '#i-check' : '#i-copy'" /></svg>{{ mcpCopied ? '已复制,去 AI 客户端粘贴' : '复制访问令牌(明文)' }}</button>
|
||||
<button @click="newMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]" :class="{ 'animate-spin': mcp.busy }"><use href="#i-refresh-cw" /></svg>{{ mcp.configured ? '重置令牌(旧令牌立即失效)' : '生成访问令牌并开启' }}</button>
|
||||
</div>
|
||||
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
|
||||
端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-database" /></svg>数据</p>
|
||||
<div class="divide-y divide-slate-100">
|
||||
@@ -1431,7 +1456,7 @@ createApp({
|
||||
isDrawerOpen.value = true;
|
||||
};
|
||||
|
||||
const openSettings = () => { settingsOpen.value = true; };
|
||||
const openSettings = () => { settingsOpen.value = true; loadMcpStatus(); };
|
||||
const closeSettings = () => { settingsOpen.value = false; };
|
||||
|
||||
// ===== 奖惩终稿修正入口 =====
|
||||
@@ -1933,6 +1958,97 @@ createApp({
|
||||
window.WHTSync.health().catch(() => {});
|
||||
};
|
||||
|
||||
// ===== AI 接入 (MCP):状态/开关/令牌管理(走 /api/mcp/*,登录态鉴权)=====
|
||||
// 开关与令牌存服务器 DB,改动即时生效;令牌明文只在生成/复制响应里出现,页面不留存。
|
||||
const mcp = reactive({ configured: false, enabled: false, masked: '', busy: false, loaded: false, authNeeded: false });
|
||||
const mcpCopied = ref(false);
|
||||
const mcpBadge = computed(() => {
|
||||
if (!mcp.loaded) return { text: '…', cls: 'bg-slate-100 text-slate-400' };
|
||||
if (mcp.authNeeded) return { text: '本设备请先登录', cls: 'bg-amber-50 text-amber-600' };
|
||||
if (!mcp.configured) return { text: '未配置', cls: 'bg-slate-100 text-slate-400' };
|
||||
return mcp.enabled
|
||||
? { text: '已开启', cls: 'bg-emerald-50 text-emerald-600' }
|
||||
: { text: '已关闭', cls: 'bg-amber-50 text-amber-600' };
|
||||
});
|
||||
|
||||
// 复用同步引擎的 apiBase 与登录 token:页面能同步就能管 MCP,不另做一套鉴权
|
||||
const mcpHeaders = () => {
|
||||
const h = { 'content-type': 'application/json' };
|
||||
const token = window.WHTSync && window.WHTSync.state.token;
|
||||
if (token) h.authorization = 'Bearer ' + token;
|
||||
return h;
|
||||
};
|
||||
|
||||
const loadMcpStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/status', { headers: mcpHeaders() });
|
||||
const d = await res.json();
|
||||
mcp.authNeeded = res.status === 401 || res.status === 403;
|
||||
if (d && d.ok) {
|
||||
mcp.configured = !!d.configured;
|
||||
mcp.enabled = !!d.enabled;
|
||||
mcp.masked = d.masked || '';
|
||||
if (d.configured) mcp.authNeeded = false;
|
||||
}
|
||||
} catch (e) { /* 静态模式/离线:保持未知态,不干扰记账主功能 */ }
|
||||
mcp.loaded = true;
|
||||
};
|
||||
|
||||
const toggleMcp = async () => {
|
||||
if (mcp.busy) return;
|
||||
mcp.busy = true;
|
||||
try {
|
||||
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/enabled', {
|
||||
method: 'POST', headers: mcpHeaders(), body: JSON.stringify({ enabled: !mcp.enabled })
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok || !d.ok) throw new Error(d.error || 'HTTP ' + res.status);
|
||||
mcp.enabled = d.enabled;
|
||||
showToast(d.enabled ? 'MCP 已开启,客户端即可接入' : 'MCP 已关闭,客户端接入立即失效');
|
||||
} catch (e) {
|
||||
showToast('操作失败:' + (e.message || '未知错误'));
|
||||
} finally { mcp.busy = false; }
|
||||
};
|
||||
|
||||
// 生成/重置令牌:生成后自动复制明文(仅此一次,服务端不留明文出口以外的副本)
|
||||
const newMcpToken = async () => {
|
||||
if (mcp.busy) return;
|
||||
if (mcp.configured && !confirm('重置令牌?正在使用的旧令牌会立即失效。')) return;
|
||||
mcp.busy = true;
|
||||
try {
|
||||
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/token', { method: 'POST', headers: mcpHeaders() });
|
||||
const d = await res.json();
|
||||
if (!res.ok || !d.ok) throw new Error(res.status === 401 ? '本设备请先登录' : (d.error || 'HTTP ' + res.status));
|
||||
mcp.configured = true;
|
||||
mcp.enabled = true;
|
||||
mcp.authNeeded = false;
|
||||
writeClipboard(d.token, () => {
|
||||
mcpCopied.value = true;
|
||||
setTimeout(() => { mcpCopied.value = false; }, 2500);
|
||||
});
|
||||
showToast('令牌已生成并复制(明文仅此一次)');
|
||||
await loadMcpStatus();
|
||||
} catch (e) {
|
||||
showToast('生成失败:' + (e.message || '未知错误'));
|
||||
} finally { mcp.busy = false; }
|
||||
};
|
||||
|
||||
const copyMcpToken = async () => {
|
||||
if (mcp.busy || !mcp.configured) return;
|
||||
mcp.busy = true;
|
||||
try {
|
||||
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/token', { headers: mcpHeaders() });
|
||||
const d = await res.json();
|
||||
if (!res.ok || !d.ok) throw new Error(d.error || 'HTTP ' + res.status);
|
||||
writeClipboard(d.token, () => {
|
||||
mcpCopied.value = true;
|
||||
setTimeout(() => { mcpCopied.value = false; }, 2000);
|
||||
});
|
||||
} catch (e) {
|
||||
showToast('取回失败:' + (e.message || '未知错误'));
|
||||
} finally { mcp.busy = false; }
|
||||
};
|
||||
|
||||
// ===== 载入与持久化 =====
|
||||
onMounted(() => {
|
||||
const savedRecords = localStorage.getItem('work_records');
|
||||
@@ -2002,6 +2118,7 @@ createApp({
|
||||
sync, syncPanelOpen, loginForm, syncStatusText, syncStatusClass, syncTimeText,
|
||||
mergeOpen, mergeInfo, mergeRows, resolveMerge, deferMerge, reopenMerge,
|
||||
doLogin, doRegister, doLogout, doSyncNow, doPullAll, doPushAll, saveApiBase,
|
||||
mcp, mcpCopied, mcpBadge, toggleMcp, copyMcpToken, newMcpToken,
|
||||
saveRecord, deleteRecord, copyReport,
|
||||
exportData, copyData, importData, resetSettings, clearMonth, clearAll
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user