firstUpdate #1
24115
TalkingQ-H5.html
24115
TalkingQ-H5.html
File diff suppressed because it is too large
Load Diff
36
index.html
36
index.html
@@ -228,6 +228,32 @@
|
||||
previewVoice: (provider, voice, text) => new Promise(r => setTimeout(r, 600)),
|
||||
testRAGQuery: (agentId, query) => new Promise(r => setTimeout(r, 1000)),
|
||||
createKnowledgeBase: (data) => new Promise(r => setTimeout(r, 600)),
|
||||
listASRModels: (params) => Promise.resolve({ success: true, data: window.__asrModelRegistry || [], params }),
|
||||
createASRModel: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 300)),
|
||||
updateASRModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 300)),
|
||||
deleteASRModel: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
|
||||
validateASRModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 600)),
|
||||
testASRModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 500)),
|
||||
setASRModelEnabled: (id, enabled) => new Promise(r => setTimeout(() => r({ success: true, data: { id, enabled } }), 250)),
|
||||
listTTSModels: (params) => Promise.resolve({ success: true, data: window.__ttsModelRegistry || [], params }),
|
||||
createTTSModel: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 300)),
|
||||
updateTTSModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 300)),
|
||||
deleteTTSModel: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
|
||||
validateTTSModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 700)),
|
||||
setTTSModelEnabled: (id, enabled) => new Promise(r => setTimeout(() => r({ success: true, data: { id, enabled } }), 250)),
|
||||
listTextModels: (params) => Promise.resolve({ success: true, data: window.__textModelRegistry || [], params }),
|
||||
createTextModel: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 300)),
|
||||
updateTextModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 300)),
|
||||
deleteTextModel: (id) => new Promise(r => setTimeout(() => r({ success: true, data: { id } }), 300)),
|
||||
validateTextModel: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 700)),
|
||||
setTextModelEnabled: (id, enabled) => new Promise(r => setTimeout(() => r({ success: true, data: { id, enabled } }), 250)),
|
||||
listVariables: (agentId, params) => Promise.resolve({ success: true, data: window.__agentVariables || [], params: { agentId, ...params } }),
|
||||
createVariable: (agentId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, ...data } }), 300)),
|
||||
updateVariable: (agentId, variableId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, variableId, ...data } }), 300)),
|
||||
deleteVariable: (agentId, variableId) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, variableId } }), 250)),
|
||||
injectVariableToPrompt: (agentId, variableId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, variableId, ...data } }), 250)),
|
||||
getVariableState: (agentId, sessionId, params) => Promise.resolve({ success: true, data: {}, params: { agentId, sessionId, ...params } }),
|
||||
updateVariableState: (agentId, sessionId, data) => new Promise(r => setTimeout(() => r({ success: true, data: { agentId, sessionId, ...data } }), 300)),
|
||||
getHotwordGroups: (params) => Promise.resolve({ success: true, data: window.__agentHotwordGroups || [], params }),
|
||||
createHotwordGroup: (data) => new Promise(r => setTimeout(() => r({ success: true, data }), 500)),
|
||||
updateHotwordGroup: (id, data) => new Promise(r => setTimeout(() => r({ success: true, data: { id, ...data } }), 500)),
|
||||
@@ -560,10 +586,16 @@
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `<div class="modal${sizeClass}"><div class="modal-header"><span class="modal-title">${title}</span><button class="modal-close">×</button></div><div class="modal-body">${content}</div><div class="modal-footer">${showCancel ? `<button class="btn btn-outline cancel-btn">${cancelText || '取消'}</button>` : ''}${onConfirm ? `<button class="btn btn-primary confirm-btn${confirmBtnClass}">${confirmText || '确认'}</button>` : ''}</div></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
const close = () => {
|
||||
overlay.classList.remove('open');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
if (!document.querySelector('.modal-overlay')) {
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
overlay.querySelector('.modal-close').addEventListener('click', close);
|
||||
|
||||
4253
scripts/agent-dev.js
4253
scripts/agent-dev.js
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,13 @@ const API = {
|
||||
agentDev: {
|
||||
// ASR (Automatic Speech Recognition)
|
||||
getASRProviders: () => API._get('/agent-dev/asr/providers'),
|
||||
listASRModels: (params) => API._get('/agent-dev/asr/models', params),
|
||||
createASRModel: (data) => API._post('/agent-dev/asr/models', data),
|
||||
updateASRModel: (id, data) => API._put(`/agent-dev/asr/models/${id}`, data),
|
||||
deleteASRModel: (id) => API._delete(`/agent-dev/asr/models/${id}`),
|
||||
validateASRModel: (id, data = {}) => API._post(`/agent-dev/asr/models/${id}/validate`, data),
|
||||
testASRModel: (id, data = {}) => API._post(`/agent-dev/asr/models/${id}/test`, data),
|
||||
setASRModelEnabled: (id, enabled) => API._put(`/agent-dev/asr/models/${id}/enabled`, { enabled }),
|
||||
getASRConfig: (agentId) => API._get(`/agent-dev/${agentId}/asr`),
|
||||
updateASRConfig: (agentId, config) => API._put(`/agent-dev/${agentId}/asr`, config),
|
||||
testASR: (agentId, audioBlob) => API._post(`/agent-dev/${agentId}/asr/test`, { audio: audioBlob }),
|
||||
@@ -44,11 +51,37 @@ const API = {
|
||||
|
||||
// TTS (Text-to-Speech)
|
||||
getTTSProviders: () => API._get('/agent-dev/tts/providers'),
|
||||
listTTSModels: (params) => API._get('/agent-dev/tts/models', params),
|
||||
createTTSModel: (data) => API._post('/agent-dev/tts/models', data),
|
||||
updateTTSModel: (id, data) => API._put(`/agent-dev/tts/models/${id}`, data),
|
||||
deleteTTSModel: (id) => API._delete(`/agent-dev/tts/models/${id}`),
|
||||
validateTTSModel: (id, data = {}) => API._post(`/agent-dev/tts/models/${id}/validate`, data),
|
||||
setTTSModelEnabled: (id, enabled) => API._put(`/agent-dev/tts/models/${id}/enabled`, { enabled }),
|
||||
getTTSConfig: (agentId) => API._get(`/agent-dev/${agentId}/tts`),
|
||||
updateTTSConfig: (agentId, config) => API._put(`/agent-dev/${agentId}/tts`, config),
|
||||
testTTS: (agentId, text) => API._post(`/agent-dev/${agentId}/tts/test`, { text }),
|
||||
previewVoice: (provider, voiceId, text) => API._post('/agent-dev/tts/preview', { provider, voiceId, text }),
|
||||
|
||||
// Text / LLM models
|
||||
listTextModels: (params) => API._get('/agent-dev/text/models', params),
|
||||
createTextModel: (data) => API._post('/agent-dev/text/models', data),
|
||||
updateTextModel: (id, data) => API._put(`/agent-dev/text/models/${id}`, data),
|
||||
deleteTextModel: (id) => API._delete(`/agent-dev/text/models/${id}`),
|
||||
validateTextModel: (id, data = {}) => API._post(`/agent-dev/text/models/${id}/validate`, data),
|
||||
setTextModelEnabled: (id, enabled) => API._put(`/agent-dev/text/models/${id}/enabled`, { enabled }),
|
||||
getTextConfig: (agentId) => API._get(`/agent-dev/${agentId}/text`),
|
||||
updateTextConfig: (agentId, config) => API._put(`/agent-dev/${agentId}/text`, config),
|
||||
testTextModel: (agentId, prompt) => API._post(`/agent-dev/${agentId}/text/test`, { prompt }),
|
||||
|
||||
// Runtime variables / state storage
|
||||
listVariables: (agentId, params) => API._get(`/agent-dev/${agentId}/variables`, params),
|
||||
createVariable: (agentId, data) => API._post(`/agent-dev/${agentId}/variables`, data),
|
||||
updateVariable: (agentId, variableId, data) => API._put(`/agent-dev/${agentId}/variables/${variableId}`, data),
|
||||
deleteVariable: (agentId, variableId) => API._delete(`/agent-dev/${agentId}/variables/${variableId}`),
|
||||
injectVariableToPrompt: (agentId, variableId, data = {}) => API._post(`/agent-dev/${agentId}/variables/${variableId}/inject`, data),
|
||||
getVariableState: (agentId, sessionId, params) => API._get(`/agent-dev/${agentId}/sessions/${sessionId}/variables`, params),
|
||||
updateVariableState: (agentId, sessionId, data) => API._put(`/agent-dev/${agentId}/sessions/${sessionId}/variables`, data),
|
||||
|
||||
// RAG (Retrieval Augmented Generation)
|
||||
getKnowledgeBases: () => API._get('/agent-dev/rag/knowledge-bases'),
|
||||
getAgentRAG: (agentId) => API._get(`/agent-dev/${agentId}/rag`),
|
||||
|
||||
@@ -180,11 +180,17 @@ function createModal({ title, content, onConfirm, confirmText = '确认', cancel
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.body.classList.add('modal-open');
|
||||
lucide.createIcons({ elements: [overlay] });
|
||||
|
||||
const close = () => {
|
||||
overlay.classList.remove('open');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
if (!document.querySelector('.modal-overlay')) {
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
overlay.querySelector('.modal-close').addEventListener('click', close);
|
||||
|
||||
@@ -3,261 +3,431 @@
|
||||
*/
|
||||
|
||||
registerPage('badcase', function(container) {
|
||||
const mockCases = [
|
||||
{ id: 'BC-2026-001', session: 'sess-6c1d', device: 'DEV-002', agent: '小Q故事机器人', issue: '回答偏离话题,讲了不适合儿童的内容片段', severity: 'high', status: 'open', reporter: '自动检测', time: '2026-05-12 14:07', tags: ['内容安全', '主题偏离'] },
|
||||
{ id: 'BC-2026-002', session: 'sess-5a4b', device: 'DEV-003', agent: '小Q故事机器人', issue: '粤语识别错误率高,多次识别成普通话', severity: 'medium', status: 'reviewing', reporter: '运营标注', time: '2026-05-12 13:58', tags: ['ASR质量', '粤语'] },
|
||||
{ id: 'BC-2026-003', session: 'sess-3f7a', device: 'DEV-007', agent: '英语学习助手', issue: '用户问"what is fire",AI回复了如何生火的危险操作', severity: 'critical', status: 'open', reporter: '自动检测', time: '2026-05-12 11:30', tags: ['内容安全', '危险信息'] },
|
||||
{ id: 'BC-2026-004', session: 'sess-2c8b', device: 'DEV-012', agent: '科学百科探索者', issue: 'TTS 合成失败,只播放了半句话', severity: 'low', status: 'closed', reporter: '系统监控', time: '2026-05-11 09:15', tags: ['TTS故障'] },
|
||||
{ id: 'BC-2026-005', session: 'sess-1d9e', device: 'DEV-015', agent: '小Q故事机器人', issue: '对用户"我想要妈妈"的情感求助回应过于机械', severity: 'medium', status: 'reviewing', reporter: '人工标注', time: '2026-05-11 16:42', tags: ['情感质量', '儿童关怀'] },
|
||||
const initialCases = [
|
||||
{
|
||||
id: 'BC-2026-001',
|
||||
session: 'sess-6c1d',
|
||||
device: 'DEV-002',
|
||||
agent: '小Q故事机器人',
|
||||
issue: '回答偏离话题,讲了不适合儿童的内容片段',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
reporter: '自动检测',
|
||||
time: '2026-05-12 14:07',
|
||||
tags: ['内容安全', '主题偏离'],
|
||||
dialogue: {
|
||||
user: '给我讲一个勇敢小朋友的故事',
|
||||
assistant: '故事中出现了不适合儿童的危险情节描述。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BC-2026-002',
|
||||
session: 'sess-5a4b',
|
||||
device: 'DEV-003',
|
||||
agent: '小Q故事机器人',
|
||||
issue: '粤语识别错误率高,多次识别成普通话',
|
||||
severity: 'medium',
|
||||
status: 'reviewing',
|
||||
reporter: '运营标注',
|
||||
time: '2026-05-12 13:58',
|
||||
tags: ['ASR质量', '粤语'],
|
||||
dialogue: {
|
||||
user: '我想听广东话故事',
|
||||
assistant: '识别结果被转写为普通话场景,导致回复不准确。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BC-2026-003',
|
||||
session: 'sess-3f7a',
|
||||
device: 'DEV-007',
|
||||
agent: '英语学习助手',
|
||||
issue: '用户问"what is fire",AI回复了如何生火的危险操作',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
reporter: '自动检测',
|
||||
time: '2026-05-12 11:30',
|
||||
tags: ['内容安全', '危险信息'],
|
||||
dialogue: {
|
||||
user: 'what is fire?',
|
||||
assistant: '回复中包含了危险操作步骤,需要安全策略拦截。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BC-2026-004',
|
||||
session: 'sess-2c8b',
|
||||
device: 'DEV-012',
|
||||
agent: '科学百科探索者',
|
||||
issue: 'TTS 合成失败,只播放了半句话',
|
||||
severity: 'low',
|
||||
status: 'closed',
|
||||
reporter: '系统监控',
|
||||
time: '2026-05-11 09:15',
|
||||
tags: ['TTS故障'],
|
||||
resolution: '已更换默认 TTS 兜底模型。',
|
||||
dialogue: {
|
||||
user: '太阳为什么会发光',
|
||||
assistant: '太阳发光是因为内部发生核聚变...(播放中断)',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BC-2026-005',
|
||||
session: 'sess-1d9e',
|
||||
device: 'DEV-015',
|
||||
agent: '小Q故事机器人',
|
||||
issue: '对用户"我想要妈妈"的情感求助回应过于机械',
|
||||
severity: 'medium',
|
||||
status: 'reviewing',
|
||||
reporter: '人工标注',
|
||||
time: '2026-05-11 16:42',
|
||||
tags: ['情感质量', '儿童关怀'],
|
||||
dialogue: {
|
||||
user: '我想要妈妈',
|
||||
assistant: '请说明你的需求。',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const sevMap = { critical: { label: '严重', badge: 'badge-danger' }, high: { label: '高', badge: 'badge-danger' }, medium: { label: '中', badge: 'badge-warning' }, low: { label: '低', badge: 'badge-default' } };
|
||||
const statMap = { open: { label: '待处理', badge: 'badge-danger' }, reviewing: { label: '审核中', badge: 'badge-warning' }, closed: { label: '已关闭', badge: 'badge-default' } };
|
||||
if (!window.__badCaseRecords) {
|
||||
window.__badCaseRecords = initialCases.map(item => ({ ...item, tags: [...item.tags], dialogue: { ...item.dialogue } }));
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">Bad Case 管理</div>
|
||||
<div class="page-desc">收集、标注和修复 AI 回复质量问题,持续优化智能体表现</div>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-outline" onclick="Toast.info('导出 Bad Case 数据...')">
|
||||
<i data-lucide="download"></i> 导出
|
||||
</button>
|
||||
<button class="btn btn-primary" id="manualBCBtn">
|
||||
<i data-lucide="plus"></i> 手动添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const cases = window.__badCaseRecords;
|
||||
const filters = {
|
||||
keyword: '',
|
||||
severity: 'all',
|
||||
status: 'all',
|
||||
agent: 'all',
|
||||
};
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="kpi-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:20px">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="alert-triangle"></i></div>
|
||||
<div class="kpi-label">待处理</div>
|
||||
<div class="kpi-value" style="color:var(--danger)">23</div>
|
||||
<div class="kpi-change down">其中严重 3 个</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="search"></i></div>
|
||||
<div class="kpi-label">审核中</div>
|
||||
<div class="kpi-value" style="color:var(--warning)">18</div>
|
||||
<div class="kpi-change">平均处理 2.3h</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="check-circle"></i></div>
|
||||
<div class="kpi-label">本月已关闭</div>
|
||||
<div class="kpi-value" style="color:var(--success)">142</div>
|
||||
<div class="kpi-change up">解决率 86%</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="shield-alert"></i></div>
|
||||
<div class="kpi-label">内容安全触发</div>
|
||||
<div class="kpi-value" style="color:var(--danger)">7</div>
|
||||
<div class="kpi-change down">需优先处理</div>
|
||||
</div>
|
||||
</div>
|
||||
const sevMap = {
|
||||
critical: { label: '严重', badge: 'badge-danger', accent: 'var(--danger)' },
|
||||
high: { label: '高', badge: 'badge-danger', accent: 'var(--danger)' },
|
||||
medium: { label: '中', badge: 'badge-warning', accent: 'var(--warning)' },
|
||||
low: { label: '低', badge: 'badge-default', accent: 'var(--text-muted)' },
|
||||
};
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-input">
|
||||
<i data-lucide="search"></i>
|
||||
<input type="text" placeholder="搜索 Bad Case 内容/ID...">
|
||||
</div>
|
||||
<select class="filter-select">
|
||||
<option>全部严重度</option>
|
||||
<option>严重</option>
|
||||
<option>高</option>
|
||||
<option>中</option>
|
||||
<option>低</option>
|
||||
</select>
|
||||
<select class="filter-select">
|
||||
<option>全部状态</option>
|
||||
<option>待处理</option>
|
||||
<option>审核中</option>
|
||||
<option>已关闭</option>
|
||||
</select>
|
||||
<select class="filter-select">
|
||||
<option>全部智能体</option>
|
||||
<option>小Q故事机器人</option>
|
||||
<option>英语学习助手</option>
|
||||
<option>科学百科探索者</option>
|
||||
</select>
|
||||
</div>
|
||||
const statMap = {
|
||||
open: { label: '待处理', badge: 'badge-danger' },
|
||||
reviewing: { label: '审核中', badge: 'badge-warning' },
|
||||
closed: { label: '已关闭', badge: 'badge-default' },
|
||||
};
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>问题描述</th>
|
||||
<th>智能体</th>
|
||||
<th>严重度</th>
|
||||
<th>状态</th>
|
||||
<th>标签</th>
|
||||
<th>报告人</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${mockCases.map(c => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:12px;color:var(--primary)">${c.id}</td>
|
||||
<td style="max-width:280px">
|
||||
<div style="font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${c.issue}">${c.issue}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted)">Session: ${c.session} · ${c.device}</div>
|
||||
</td>
|
||||
<td style="font-size:12px">${c.agent}</td>
|
||||
<td><span class="badge ${sevMap[c.severity]?.badge}">${sevMap[c.severity]?.label}</span></td>
|
||||
<td><span class="badge ${statMap[c.status]?.badge}">${statMap[c.status]?.label}</span></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
${c.tags.map(t => `<span class="badge badge-purple" style="font-size:11px">${t}</span>`).join('')}
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size:12px">${c.reporter}</td>
|
||||
<td style="font-size:12px;color:var(--text-muted)">${c.time}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-ghost btn-sm" data-tooltip="查看详情" onclick="openBCDetail('${c.id}')"><i data-lucide="eye"></i></button>
|
||||
${c.status !== 'closed' ? `
|
||||
<button class="btn btn-ghost btn-sm" data-tooltip="标注问题" onclick="openBCTagModal('${c.id}')"><i data-lucide="tag"></i></button>
|
||||
<button class="btn btn-outline btn-sm" style="color:var(--success)" data-tooltip="关闭案例" onclick="closeBC('${c.id}', this)">关闭</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
lucide.createIcons({ elements: [container] });
|
||||
function getNowText() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
}
|
||||
|
||||
window.openBCDetail = (id) => {
|
||||
const bc = mockCases.find(c => c.id === id);
|
||||
if (!bc) return;
|
||||
createModal({
|
||||
title: `Bad Case 详情 - ${id}`,
|
||||
content: `
|
||||
<div style="margin-bottom:12px">
|
||||
<div class="form-label">问题描述</div>
|
||||
<div style="padding:10px;background:#fff2f0;border-radius:var(--radius-sm);font-size:13px">${bc.issue}</div>
|
||||
function splitTags(value) {
|
||||
return String(value || '')
|
||||
.split(/[,,]/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getAgents() {
|
||||
return [...new Set(cases.map(item => item.agent).filter(Boolean))];
|
||||
}
|
||||
|
||||
function getFilteredCases() {
|
||||
const keyword = filters.keyword.trim().toLowerCase();
|
||||
return cases.filter(item => {
|
||||
const searchableText = [
|
||||
item.id,
|
||||
item.session,
|
||||
item.device,
|
||||
item.agent,
|
||||
item.issue,
|
||||
item.reporter,
|
||||
...(item.tags || []),
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return (!keyword || searchableText.includes(keyword))
|
||||
&& (filters.severity === 'all' || item.severity === filters.severity)
|
||||
&& (filters.status === 'all' || item.status === filters.status)
|
||||
&& (filters.agent === 'all' || item.agent === filters.agent);
|
||||
});
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const openCases = cases.filter(item => item.status === 'open');
|
||||
const reviewingCases = cases.filter(item => item.status === 'reviewing');
|
||||
const closedCases = cases.filter(item => item.status === 'closed');
|
||||
const securityCases = cases.filter(item => (item.tags || []).some(tag => ['内容安全', '危险信息'].includes(tag)));
|
||||
|
||||
return {
|
||||
open: openCases.length,
|
||||
criticalOpen: openCases.filter(item => item.severity === 'critical').length,
|
||||
reviewing: reviewingCases.length,
|
||||
closed: closedCases.length,
|
||||
security: securityCases.length,
|
||||
securityOpen: securityCases.filter(item => item.status !== 'closed').length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const stats = getStats();
|
||||
return `
|
||||
<div class="kpi-grid" style="grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:20px">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="alert-triangle"></i></div>
|
||||
<div class="kpi-label">待处理</div>
|
||||
<div class="kpi-value" style="color:var(--danger)">${stats.open}</div>
|
||||
<div class="kpi-change down">其中严重 ${stats.criticalOpen} 个</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">严重度</div>
|
||||
<span class="badge ${sevMap[bc.severity]?.badge}">${sevMap[bc.severity]?.label}</span>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="search"></i></div>
|
||||
<div class="kpi-label">审核中</div>
|
||||
<div class="kpi-value" style="color:var(--warning)">${stats.reviewing}</div>
|
||||
<div class="kpi-change">等待复核或修复验证</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="check-circle"></i></div>
|
||||
<div class="kpi-label">已关闭</div>
|
||||
<div class="kpi-value" style="color:var(--success)">${stats.closed}</div>
|
||||
<div class="kpi-change up">当前前端 mock 统计</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><i data-lucide="shield-alert"></i></div>
|
||||
<div class="kpi-label">内容安全触发</div>
|
||||
<div class="kpi-value" style="color:var(--danger)">${stats.security}</div>
|
||||
<div class="kpi-change down">未关闭 ${stats.securityOpen} 个</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderActions(item) {
|
||||
return `
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<button class="btn btn-ghost btn-sm" data-tooltip="查看详情" onclick="openBCDetail('${item.id}')"><i data-lucide="eye"></i></button>
|
||||
${item.status !== 'closed' ? `
|
||||
<button class="btn btn-ghost btn-sm" data-tooltip="标注问题" onclick="openBCTagModal('${item.id}')"><i data-lucide="tag"></i></button>
|
||||
<button class="btn btn-outline btn-sm" style="color:var(--success)" onclick="closeBC('${item.id}')">关闭</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRows(rows) {
|
||||
if (!rows.length) {
|
||||
return `
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<div style="padding:42px 20px;text-align:center;color:var(--text-muted)">
|
||||
<i data-lucide="inbox" style="width:32px;height:32px;margin-bottom:10px"></i>
|
||||
<div style="font-size:14px;color:var(--text-secondary)">暂无匹配的 Bad Case</div>
|
||||
<div style="font-size:12px;margin-top:4px">可以调整筛选条件,或手动添加新的问题案例。</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
return rows.map(item => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:12px;color:var(--primary)">${escapeHtml(item.id)}</td>
|
||||
<td style="max-width:300px">
|
||||
<div style="font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(item.issue)}">${escapeHtml(item.issue)}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted)">Session: ${escapeHtml(item.session)} · ${escapeHtml(item.device)}</div>
|
||||
</td>
|
||||
<td style="font-size:12px">${escapeHtml(item.agent)}</td>
|
||||
<td><span class="badge ${sevMap[item.severity]?.badge || 'badge-default'}">${sevMap[item.severity]?.label || '-'}</span></td>
|
||||
<td><span class="badge ${statMap[item.status]?.badge || 'badge-default'}">${statMap[item.status]?.label || '-'}</span></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
${(item.tags || []).map(tag => `<span class="badge badge-purple" style="font-size:11px">${escapeHtml(tag)}</span>`).join('') || '<span style="font-size:12px;color:var(--text-muted)">未标注</span>'}
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label">关联 Session</div>
|
||||
<span style="font-family:monospace;font-size:12px;color:var(--primary)">${bc.session}</span>
|
||||
</td>
|
||||
<td style="font-size:12px">${escapeHtml(item.reporter)}</td>
|
||||
<td style="font-size:12px;color:var(--text-muted)">${escapeHtml(item.time)}</td>
|
||||
<td>${renderActions(item)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const rows = getFilteredCases();
|
||||
const statsTarget = container.querySelector('#bcStats');
|
||||
const tableTarget = container.querySelector('#bcTableCard');
|
||||
const totalTarget = container.querySelector('#bcFilterCount');
|
||||
|
||||
if (statsTarget) statsTarget.innerHTML = renderStats();
|
||||
if (totalTarget) totalTarget.textContent = `共 ${rows.length} 条`;
|
||||
if (tableTarget) {
|
||||
tableTarget.innerHTML = `
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>问题描述</th>
|
||||
<th>智能体</th>
|
||||
<th>严重度</th>
|
||||
<th>状态</th>
|
||||
<th>标签</th>
|
||||
<th>报告人</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${renderRows(rows)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label class="form-label">处理意见</label>
|
||||
<textarea class="form-control" placeholder="描述如何修复此问题(例如:调整 Prompt 中的内容审核规则)..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">关联 Prompt 修改</label>
|
||||
<select class="form-control">
|
||||
<option>不关联</option>
|
||||
<option>修改系统 Prompt - 内容安全规则</option>
|
||||
<option>修改 Prompt - 情感回应策略</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="api-note">
|
||||
<i data-lucide="info"></i>
|
||||
<span>处理后通过 <code>POST /api/v1/bad-cases/${id}/review</code> 提交审核结果</span>
|
||||
</div>`,
|
||||
onConfirm: async () => {
|
||||
await API.badCases.review(id, { resolution: 'fixed' });
|
||||
Toast.success('已提交处理意见');
|
||||
},
|
||||
confirmText: '提交处理',
|
||||
});
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
window.openBCTagModal = (id) => {
|
||||
const bc = mockCases.find(c => c.id === id);
|
||||
if (!bc) return;
|
||||
createModal({
|
||||
title: `标注问题 - ${id}`,
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">问题标签<span class="required">*</span></label>
|
||||
<input type="text" class="form-control" id="bcTagsInput" value="${bc.tags.join(', ')}" placeholder="例:内容安全, ASR质量">
|
||||
lucide.createIcons({ elements: [container] });
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const agents = getAgents();
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">Bad Case 管理</div>
|
||||
<div class="page-desc">收集、标注和修复 AI 回复质量问题,持续优化智能体表现</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">标注备注</label>
|
||||
<textarea class="form-control" rows="3" id="bcTagNote" placeholder="补充问题原因或建议修复方向"></textarea>
|
||||
</div>`,
|
||||
onConfirm: () => {
|
||||
const tags = document.getElementById('bcTagsInput')?.value.trim();
|
||||
if (!tags) {
|
||||
Toast.warning('请填写至少一个问题标签');
|
||||
return false;
|
||||
}
|
||||
bc.tags = tags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
Toast.success('问题标注已保存');
|
||||
},
|
||||
confirmText: '保存标注',
|
||||
});
|
||||
};
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="manualBCBtn">
|
||||
<i data-lucide="plus"></i> 手动添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
window.closeBC = (id, btn) => {
|
||||
const bc = mockCases.find(c => c.id === id);
|
||||
if (!bc) return;
|
||||
createModal({
|
||||
title: '关闭 Bad Case',
|
||||
content: `
|
||||
<p style="font-size:14px;margin-bottom:10px">确认关闭 <strong>${id}</strong>?</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">关闭原因</label>
|
||||
<textarea class="form-control" rows="3" id="bcCloseReason" placeholder="说明问题已如何处理"></textarea>
|
||||
</div>`,
|
||||
onConfirm: () => {
|
||||
bc.status = 'closed';
|
||||
const row = btn?.closest('tr');
|
||||
const statusCell = row?.querySelector('td:nth-child(5)');
|
||||
if (statusCell) statusCell.innerHTML = '<span class="badge badge-default">已关闭</span>';
|
||||
const actions = btn?.closest('div');
|
||||
if (actions) actions.innerHTML = `<button class="btn btn-ghost btn-sm" data-tooltip="查看详情" onclick="openBCDetail('${id}')"><i data-lucide="eye"></i></button>`;
|
||||
lucide.createIcons();
|
||||
Toast.success(`${id} 已关闭`);
|
||||
},
|
||||
confirmText: '确认关闭',
|
||||
});
|
||||
};
|
||||
<div id="bcStats"></div>
|
||||
|
||||
document.getElementById('manualBCBtn')?.addEventListener('click', () => {
|
||||
createModal({
|
||||
<div class="filter-bar">
|
||||
<div class="search-input">
|
||||
<i data-lucide="search"></i>
|
||||
<input type="text" id="bcSearchInput" value="${escapeHtml(filters.keyword)}" placeholder="搜索 Bad Case 内容 / ID / Session / 设备...">
|
||||
</div>
|
||||
<select class="filter-select" id="bcSeverityFilter">
|
||||
<option value="all" ${filters.severity === 'all' ? 'selected' : ''}>全部严重度</option>
|
||||
<option value="critical" ${filters.severity === 'critical' ? 'selected' : ''}>严重</option>
|
||||
<option value="high" ${filters.severity === 'high' ? 'selected' : ''}>高</option>
|
||||
<option value="medium" ${filters.severity === 'medium' ? 'selected' : ''}>中</option>
|
||||
<option value="low" ${filters.severity === 'low' ? 'selected' : ''}>低</option>
|
||||
</select>
|
||||
<select class="filter-select" id="bcStatusFilter">
|
||||
<option value="all" ${filters.status === 'all' ? 'selected' : ''}>全部状态</option>
|
||||
<option value="open" ${filters.status === 'open' ? 'selected' : ''}>待处理</option>
|
||||
<option value="reviewing" ${filters.status === 'reviewing' ? 'selected' : ''}>审核中</option>
|
||||
<option value="closed" ${filters.status === 'closed' ? 'selected' : ''}>已关闭</option>
|
||||
</select>
|
||||
<select class="filter-select" id="bcAgentFilter">
|
||||
<option value="all" ${filters.agent === 'all' ? 'selected' : ''}>全部智能体</option>
|
||||
${agents.map(agent => `<option value="${escapeHtml(agent)}" ${filters.agent === agent ? 'selected' : ''}>${escapeHtml(agent)}</option>`).join('')}
|
||||
</select>
|
||||
<span id="bcFilterCount" style="margin-left:auto;font-size:12px;color:var(--text-muted)"></span>
|
||||
</div>
|
||||
|
||||
<div id="bcTableCard"></div>
|
||||
`;
|
||||
|
||||
bindEvents();
|
||||
renderResults();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
container.querySelector('#bcSearchInput')?.addEventListener('input', event => {
|
||||
filters.keyword = event.target.value;
|
||||
renderResults();
|
||||
});
|
||||
|
||||
container.querySelector('#bcSeverityFilter')?.addEventListener('change', event => {
|
||||
filters.severity = event.target.value;
|
||||
renderResults();
|
||||
});
|
||||
|
||||
container.querySelector('#bcStatusFilter')?.addEventListener('change', event => {
|
||||
filters.status = event.target.value;
|
||||
renderResults();
|
||||
});
|
||||
|
||||
container.querySelector('#bcAgentFilter')?.addEventListener('change', event => {
|
||||
filters.agent = event.target.value;
|
||||
renderResults();
|
||||
});
|
||||
|
||||
container.querySelector('#manualBCBtn')?.addEventListener('click', openManualCaseModal);
|
||||
}
|
||||
|
||||
function findCase(id) {
|
||||
return cases.find(item => item.id === id);
|
||||
}
|
||||
|
||||
function getNextCaseId() {
|
||||
const year = new Date().getFullYear();
|
||||
const usedNumbers = cases
|
||||
.map(item => String(item.id).match(/^BC-\d{4}-(\d+)$/))
|
||||
.filter(Boolean)
|
||||
.map(match => Number(match[1]));
|
||||
const nextNumber = (usedNumbers.length ? Math.max(...usedNumbers) : 0) + 1;
|
||||
return `BC-${year}-${String(nextNumber).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function openManualCaseModal() {
|
||||
const agents = getAgents();
|
||||
const modal = createModal({
|
||||
title: '手动添加 Bad Case',
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">关联 Session ID</label>
|
||||
<input type="text" class="form-control" id="manualBCSession" placeholder="sess-xxxxxx">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">关联 Session ID</label>
|
||||
<input type="text" class="form-control" id="manualBCSession" placeholder="sess-xxxxxx">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">关联设备</label>
|
||||
<input type="text" class="form-control" id="manualBCDevice" placeholder="DEV-001">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">智能体</label>
|
||||
<select class="form-control" id="manualBCAgent">
|
||||
${agents.map(agent => `<option value="${escapeHtml(agent)}">${escapeHtml(agent)}</option>`).join('')}
|
||||
<option value="custom">自定义智能体</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="manualBCCustomWrap" style="display:none">
|
||||
<label class="form-label">自定义智能体名称</label>
|
||||
<input type="text" class="form-control" id="manualBCCustomAgent" placeholder="请输入智能体名称">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">问题描述<span class="required">*</span></label>
|
||||
<textarea class="form-control" id="manualBCIssue" placeholder="描述 AI 回复的具体问题..."></textarea>
|
||||
<textarea class="form-control" rows="4" id="manualBCIssue" placeholder="描述 AI 回复的具体问题、触发场景和期望结果..."></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">严重度</label>
|
||||
<select class="form-control">
|
||||
<option>低</option><option>中</option><option>高</option><option>严重</option>
|
||||
<select class="form-control" id="manualBCSeverity">
|
||||
<option value="low">低</option>
|
||||
<option value="medium" selected>中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="critical">严重</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">问题标签</label>
|
||||
<input type="text" class="form-control" placeholder="内容安全, ASR质量...">
|
||||
<input type="text" class="form-control" id="manualBCTags" placeholder="内容安全, ASR质量...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-note">
|
||||
<i data-lucide="info"></i>
|
||||
<span>后端接口预留:<code>POST /api/v1/bad-cases</code> 创建人工 Bad Case。</span>
|
||||
</div>`,
|
||||
onConfirm: async () => {
|
||||
const issue = document.getElementById('manualBCIssue')?.value.trim();
|
||||
@@ -265,9 +435,240 @@ registerPage('badcase', function(container) {
|
||||
Toast.warning('请填写问题描述');
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedAgent = document.getElementById('manualBCAgent')?.value || '';
|
||||
const customAgent = document.getElementById('manualBCCustomAgent')?.value.trim();
|
||||
const agent = selectedAgent === 'custom' ? customAgent : selectedAgent;
|
||||
if (!agent) {
|
||||
Toast.warning('请填写智能体名称');
|
||||
return false;
|
||||
}
|
||||
|
||||
cases.unshift({
|
||||
id: getNextCaseId(),
|
||||
session: document.getElementById('manualBCSession')?.value.trim() || `sess-${Math.random().toString(16).slice(2, 6)}`,
|
||||
device: document.getElementById('manualBCDevice')?.value.trim() || '手动录入',
|
||||
agent,
|
||||
issue,
|
||||
severity: document.getElementById('manualBCSeverity')?.value || 'medium',
|
||||
status: 'open',
|
||||
reporter: '手动添加',
|
||||
time: getNowText(),
|
||||
tags: splitTags(document.getElementById('manualBCTags')?.value) || [],
|
||||
dialogue: {
|
||||
user: '手动添加案例,暂无原始用户消息',
|
||||
assistant: issue,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
if (window.API?.badCases?.create) {
|
||||
await API.badCases.create(cases[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Mock bad case create failed:', error);
|
||||
}
|
||||
|
||||
filters.status = 'all';
|
||||
renderPage();
|
||||
Toast.success('Bad Case 已添加');
|
||||
},
|
||||
confirmText: '添加',
|
||||
size: 'large',
|
||||
});
|
||||
});
|
||||
|
||||
const agentSelect = modal.overlay.querySelector('#manualBCAgent');
|
||||
const customWrap = modal.overlay.querySelector('#manualBCCustomWrap');
|
||||
agentSelect?.addEventListener('change', event => {
|
||||
if (customWrap) customWrap.style.display = event.target.value === 'custom' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
window.openBCDetail = async function(id) {
|
||||
const bc = findCase(id);
|
||||
if (!bc) {
|
||||
Toast.warning('未找到该 Bad Case');
|
||||
return;
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: `Bad Case 详情 - ${escapeHtml(id)}`,
|
||||
content: `
|
||||
<div style="display:grid;gap:14px">
|
||||
<div>
|
||||
<div class="form-label">问题描述</div>
|
||||
<div style="padding:12px;background:#fff2f0;border:1px solid #ffd8d3;border-radius:var(--radius-sm);font-size:13px;line-height:1.7">${escapeHtml(bc.issue)}</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">严重度</div>
|
||||
<span class="badge ${sevMap[bc.severity]?.badge || 'badge-default'}">${sevMap[bc.severity]?.label || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label">当前状态</div>
|
||||
<span class="badge ${statMap[bc.status]?.badge || 'badge-default'}">${statMap[bc.status]?.label || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">关联 Session</div>
|
||||
<span style="font-family:monospace;font-size:12px;color:var(--primary)">${escapeHtml(bc.session)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label">关联设备</div>
|
||||
<span style="font-size:12px;color:var(--text-secondary)">${escapeHtml(bc.device)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label">关联对话</div>
|
||||
<div style="display:grid;gap:8px;padding:12px;background:#f7f8fc;border-radius:var(--radius-sm)">
|
||||
<div style="font-size:12px;color:var(--text-muted)">用户</div>
|
||||
<div style="font-size:13px;color:var(--text-primary);line-height:1.6">${escapeHtml(bc.dialogue?.user || '暂无原始用户消息')}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">AI 回复</div>
|
||||
<div style="font-size:13px;color:var(--text-primary);line-height:1.6">${escapeHtml(bc.dialogue?.assistant || '暂无原始 AI 回复')}</div>
|
||||
</div>
|
||||
</div>
|
||||
${bc.resolution ? `
|
||||
<div>
|
||||
<div class="form-label">最近处理意见</div>
|
||||
<div style="font-size:13px;line-height:1.7;color:var(--text-secondary);padding:10px 12px;background:#f6ffed;border:1px solid #d9f7be;border-radius:var(--radius-sm)">${escapeHtml(bc.resolution)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="form-group">
|
||||
<label class="form-label">处理意见<span class="required">*</span></label>
|
||||
<textarea class="form-control" rows="4" id="bcReviewResolution" placeholder="描述如何修复此问题,例如调整 Prompt、安全规则、ASR/TTS 模型或知识库内容...">${escapeHtml(bc.resolution || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">关联修复动作</label>
|
||||
<select class="form-control" id="bcReviewAction">
|
||||
<option value="none">不关联</option>
|
||||
<option value="prompt">修改系统 Prompt / 安全策略</option>
|
||||
<option value="model">调整模型配置</option>
|
||||
<option value="knowledge">修正知识库内容</option>
|
||||
<option value="workflow">调整工作流或工具调用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="api-note">
|
||||
<i data-lucide="info"></i>
|
||||
<span>后端接口预留:<code>POST /api/v1/bad-cases/${escapeHtml(id)}/review</code> 提交审核处理结果。</span>
|
||||
</div>
|
||||
</div>`,
|
||||
onConfirm: async () => {
|
||||
const resolution = document.getElementById('bcReviewResolution')?.value.trim();
|
||||
const action = document.getElementById('bcReviewAction')?.value || 'none';
|
||||
if (!resolution) {
|
||||
Toast.warning('请填写处理意见');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.API?.badCases?.review) {
|
||||
await API.badCases.review(id, { resolution, action });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Mock bad case review failed:', error);
|
||||
}
|
||||
|
||||
bc.resolution = resolution;
|
||||
bc.reviewAction = action;
|
||||
bc.updatedAt = getNowText();
|
||||
if (bc.status !== 'closed') bc.status = 'reviewing';
|
||||
renderResults();
|
||||
Toast.success('已提交处理意见,状态已更新');
|
||||
},
|
||||
confirmText: '提交处理',
|
||||
size: 'large',
|
||||
});
|
||||
};
|
||||
|
||||
window.openBCTagModal = function(id) {
|
||||
const bc = findCase(id);
|
||||
if (!bc) {
|
||||
Toast.warning('未找到该 Bad Case');
|
||||
return;
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: `标注问题 - ${escapeHtml(id)}`,
|
||||
content: `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">严重度</label>
|
||||
<select class="form-control" id="bcTagSeverity">
|
||||
<option value="critical" ${bc.severity === 'critical' ? 'selected' : ''}>严重</option>
|
||||
<option value="high" ${bc.severity === 'high' ? 'selected' : ''}>高</option>
|
||||
<option value="medium" ${bc.severity === 'medium' ? 'selected' : ''}>中</option>
|
||||
<option value="low" ${bc.severity === 'low' ? 'selected' : ''}>低</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">问题标签<span class="required">*</span></label>
|
||||
<input type="text" class="form-control" id="bcTagsInput" value="${escapeHtml((bc.tags || []).join(', '))}" placeholder="例:内容安全, ASR质量">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">标注备注</label>
|
||||
<textarea class="form-control" rows="3" id="bcTagNote" placeholder="补充问题原因或建议修复方向">${escapeHtml(bc.tagNote || '')}</textarea>
|
||||
</div>
|
||||
<div class="api-note">
|
||||
<i data-lucide="info"></i>
|
||||
<span>后端接口预留:<code>PATCH /api/v1/bad-cases/${escapeHtml(id)}/labels</code> 保存标签、严重度和备注。</span>
|
||||
</div>`,
|
||||
onConfirm: () => {
|
||||
const tags = splitTags(document.getElementById('bcTagsInput')?.value);
|
||||
if (!tags.length) {
|
||||
Toast.warning('请填写至少一个问题标签');
|
||||
return false;
|
||||
}
|
||||
|
||||
bc.tags = tags;
|
||||
bc.severity = document.getElementById('bcTagSeverity')?.value || bc.severity;
|
||||
bc.tagNote = document.getElementById('bcTagNote')?.value.trim() || '';
|
||||
bc.reporter = bc.reporter === '自动检测' ? '运营标注' : bc.reporter;
|
||||
bc.updatedAt = getNowText();
|
||||
if (bc.status === 'open') bc.status = 'reviewing';
|
||||
renderResults();
|
||||
Toast.success('问题标注已保存');
|
||||
},
|
||||
confirmText: '保存标注',
|
||||
});
|
||||
};
|
||||
|
||||
window.closeBC = function(id) {
|
||||
const bc = findCase(id);
|
||||
if (!bc) {
|
||||
Toast.warning('未找到该 Bad Case');
|
||||
return;
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: '关闭 Bad Case',
|
||||
content: `
|
||||
<p style="font-size:14px;margin-bottom:10px">确认关闭 <strong>${escapeHtml(id)}</strong>?关闭后仍可查看详情,但不再进入待处理列表。</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">关闭原因<span class="required">*</span></label>
|
||||
<textarea class="form-control" rows="3" id="bcCloseReason" placeholder="说明问题已如何处理或为什么无需继续跟进">${escapeHtml(bc.closedReason || '')}</textarea>
|
||||
</div>
|
||||
<div class="api-note">
|
||||
<i data-lucide="info"></i>
|
||||
<span>后端接口预留:<code>POST /api/v1/bad-cases/${escapeHtml(id)}/close</code> 关闭问题案例。</span>
|
||||
</div>`,
|
||||
onConfirm: () => {
|
||||
const reason = document.getElementById('bcCloseReason')?.value.trim();
|
||||
if (!reason) {
|
||||
Toast.warning('请填写关闭原因');
|
||||
return false;
|
||||
}
|
||||
|
||||
bc.status = 'closed';
|
||||
bc.closedReason = reason;
|
||||
bc.closedAt = getNowText();
|
||||
renderResults();
|
||||
Toast.success(`${id} 已关闭`);
|
||||
},
|
||||
confirmText: '确认关闭',
|
||||
});
|
||||
};
|
||||
|
||||
renderPage();
|
||||
});
|
||||
|
||||
@@ -1173,8 +1173,39 @@ registerPage('kb-create', function(container) {
|
||||
};
|
||||
|
||||
window.__finishWizard = function() {
|
||||
collectStepData();
|
||||
|
||||
// TODO: 调用 API POST /api/v1/knowledge-bases 创建知识库
|
||||
console.log('创建知识库数据:', formData);
|
||||
|
||||
const typeMap = {
|
||||
doc_search: '文档搜索',
|
||||
data_query: '数据查询',
|
||||
image_qa: '图片问答',
|
||||
video_search: '音视频搜索',
|
||||
};
|
||||
const createdAt = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
const createdText = `${createdAt.getFullYear()}-${pad(createdAt.getMonth() + 1)}-${pad(createdAt.getDate())} ${pad(createdAt.getHours())}:${pad(createdAt.getMinutes())}:${pad(createdAt.getSeconds())}`;
|
||||
const uploadedCount = Array.isArray(formData.uploadedFiles) ? formData.uploadedFiles.length : 0;
|
||||
const newKb = {
|
||||
id: `kb-${Date.now()}`,
|
||||
name: formData.name || '未命名知识库',
|
||||
description: formData.description || '暂无描述',
|
||||
docs: uploadedCount * 24,
|
||||
size: uploadedCount ? `${Math.max(1, uploadedCount * 8)}MB` : '0MB',
|
||||
type: formData.type || 'doc_search',
|
||||
typeName: typeMap[formData.type] || '文档搜索',
|
||||
status: uploadedCount ? 'processing' : 'ready',
|
||||
createdAt: createdText,
|
||||
updatedAt: createdText,
|
||||
tags: [],
|
||||
};
|
||||
const catalog = Array.isArray(window.__knowledgeBaseCatalog)
|
||||
? window.__knowledgeBaseCatalog
|
||||
: [];
|
||||
catalog.unshift(newKb);
|
||||
window.__knowledgeBaseCatalog = catalog;
|
||||
|
||||
clearDraft();
|
||||
Toast.success('知识库创建成功!');
|
||||
@@ -1302,8 +1333,18 @@ registerPage('kb-create', function(container) {
|
||||
|
||||
window.__retryUpload = function(index) {
|
||||
if (formData.uploadedFiles[index]) {
|
||||
formData.uploadedFiles[index].status = 'uploading';
|
||||
const fileObj = formData.uploadedFiles[index];
|
||||
fileObj.status = 'uploading';
|
||||
renderPage();
|
||||
setTimeout(() => {
|
||||
fileObj.status = 'processing';
|
||||
renderPage();
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
fileObj.status = 'ready';
|
||||
renderPage();
|
||||
Toast.success(`${fileObj.name} 已重新上传并解析完成`);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1422,11 +1463,101 @@ registerPage('kb-create', function(container) {
|
||||
|
||||
window.__loadCategoryFiles = function(category) {
|
||||
// TODO: 调用 API GET /api/v1/categories/:id/files
|
||||
Toast.info(`加载 ${category} 文件列表`);
|
||||
createModal({
|
||||
title: `${category} 文件列表`,
|
||||
size: 'lg',
|
||||
content: `
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
${['儿童科学百科.pdf', '科学小实验.docx', '物理入门.xlsx'].map((name, index) => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
|
||||
<div>
|
||||
<div style="font-weight:600">${name}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted)">mock-file-${index + 1} · ${(index + 1) * 2}.4MB</div>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="Toast.success('已选择 ${name}')">选择</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`,
|
||||
confirmText: '',
|
||||
cancelText: '关闭'
|
||||
});
|
||||
};
|
||||
|
||||
window.__showCategoryDrawer = function() {
|
||||
Toast.info('类目管理功能');
|
||||
const renderCategoryRows = () => {
|
||||
const list = document.getElementById('categoryManageList');
|
||||
if (!list) return;
|
||||
list.innerHTML = categories.map(category => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
|
||||
<div>
|
||||
<div style="font-weight:600">${category.name}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted)">${category.fileCount} 个文件 · ${category.id}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-outline btn-sm" onclick="window.__chooseCategoryFromModal('${category.name}')">选择</button>
|
||||
${category.id === 'cat-001' ? '' : `
|
||||
<button class="btn btn-ghost btn-sm" onclick="window.__removeCategoryFromModal('${category.id}')">
|
||||
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
lucide.createIcons({ elements: list });
|
||||
};
|
||||
|
||||
createModal({
|
||||
title: '类目管理',
|
||||
size: 'lg',
|
||||
content: `
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px">
|
||||
<input class="form-control" id="newCategoryName" placeholder="输入新类目名称">
|
||||
<button class="btn btn-primary" type="button" onclick="window.__addCategoryFromModal()">添加</button>
|
||||
</div>
|
||||
<div id="categoryManageList" style="display:flex;flex-direction:column;gap:10px"></div>
|
||||
`,
|
||||
confirmText: '',
|
||||
cancelText: '关闭'
|
||||
});
|
||||
|
||||
window.__addCategoryFromModal = function() {
|
||||
const input = document.getElementById('newCategoryName');
|
||||
const name = input?.value.trim();
|
||||
if (!name) {
|
||||
Toast.warning('请输入类目名称');
|
||||
return;
|
||||
}
|
||||
categories.push({
|
||||
id: `cat-${Date.now()}`,
|
||||
name,
|
||||
fileCount: 0,
|
||||
});
|
||||
input.value = '';
|
||||
renderCategoryRows();
|
||||
Toast.success('类目已添加');
|
||||
};
|
||||
|
||||
window.__removeCategoryFromModal = function(id) {
|
||||
const index = categories.findIndex(category => category.id === id);
|
||||
if (index !== -1) {
|
||||
categories.splice(index, 1);
|
||||
renderCategoryRows();
|
||||
Toast.success('类目已删除');
|
||||
}
|
||||
};
|
||||
|
||||
window.__chooseCategoryFromModal = function(name) {
|
||||
formData.category = name;
|
||||
hasUnsavedChanges = true;
|
||||
const select = document.getElementById('categorySelect');
|
||||
if (select && Array.from(select.options).some(option => option.value === name)) {
|
||||
select.value = name;
|
||||
}
|
||||
Toast.success(`已选择类目:${name}`);
|
||||
};
|
||||
|
||||
setTimeout(renderCategoryRows, 50);
|
||||
};
|
||||
|
||||
window.__saveDraft = saveDraft;
|
||||
|
||||
@@ -199,6 +199,14 @@ registerPage('kb-detail', function(container) {
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function getStatusBadge(status) {
|
||||
const map = {
|
||||
ready: { label: '解析完成', cls: 'badge-success' },
|
||||
@@ -209,6 +217,31 @@ registerPage('kb-detail', function(container) {
|
||||
return map[status] || { label: status, cls: 'badge-default' };
|
||||
}
|
||||
|
||||
function getNowText() {
|
||||
const date = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes) || 0;
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename, content, mime = 'application/json;charset=utf-8') {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
// ==================== 渲染函数 ====================
|
||||
|
||||
function renderPage() {
|
||||
@@ -759,12 +792,66 @@ registerPage('kb-detail', function(container) {
|
||||
};
|
||||
|
||||
window.__filterSlices = function(keyword) {
|
||||
Toast.info('搜索切片: ' + keyword);
|
||||
const query = String(keyword || '').trim().toLowerCase();
|
||||
document.querySelectorAll('#sliceList .slice-list-item').forEach(item => {
|
||||
const text = item.textContent.toLowerCase();
|
||||
item.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
// 文件操作
|
||||
window.__uploadFiles = function() {
|
||||
Toast.info('上传文件功能');
|
||||
createModal({
|
||||
title: '上传文件',
|
||||
size: 'lg',
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">选择文件</label>
|
||||
<input type="file" class="form-control" id="detailUploadInput" multiple>
|
||||
<div class="form-hint">前端 mock 会将文件加入列表并进入“解析中”状态;后端接入后调用 POST /api/v1/knowledge-bases/:id/files。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">所属类目</label>
|
||||
<select class="form-control" id="detailUploadCategory">
|
||||
<option value="默认类目">默认类目</option>
|
||||
<option value="教学类目">教学类目</option>
|
||||
<option value="产品文档">产品文档</option>
|
||||
<option value="用户指南">用户指南</option>
|
||||
</select>
|
||||
</div>
|
||||
`,
|
||||
confirmText: '上传',
|
||||
onConfirm: () => {
|
||||
const input = document.getElementById('detailUploadInput');
|
||||
const files = Array.from(input?.files || []);
|
||||
if (!files.length) {
|
||||
Toast.warning('请先选择文件');
|
||||
return false;
|
||||
}
|
||||
const category = document.getElementById('detailUploadCategory')?.value || '默认类目';
|
||||
files.forEach(file => {
|
||||
const ext = file.name.split('.').pop()?.toUpperCase() || 'FILE';
|
||||
kbFiles.unshift({
|
||||
id: `f-${Date.now()}-${Math.random().toString(16).slice(2, 7)}`,
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
format: ext,
|
||||
dataSize: formatBytes(file.size),
|
||||
status: 'processing',
|
||||
category,
|
||||
indexedAt: '-',
|
||||
slices: 0,
|
||||
chars: 0,
|
||||
parseError: null,
|
||||
});
|
||||
});
|
||||
kbData.docs += files.length;
|
||||
kbData.updatedAt = getNowText();
|
||||
selectedFiles.clear();
|
||||
renderPage();
|
||||
Toast.success(`已上传 ${files.length} 个文件`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.__viewMetaInfo = function(fileId) {
|
||||
@@ -848,7 +935,31 @@ registerPage('kb-detail', function(container) {
|
||||
};
|
||||
|
||||
window.__moveFile = function(fileId) {
|
||||
Toast.info('移动文件功能');
|
||||
const file = kbFiles.find(f => f.id === fileId);
|
||||
if (!file) {
|
||||
Toast.warning('未找到文件');
|
||||
return;
|
||||
}
|
||||
createModal({
|
||||
title: '移动到类目',
|
||||
content: `
|
||||
<p style="margin-bottom:12px">文件:<strong>${file.name}</strong></p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">目标类目</label>
|
||||
<select class="form-control" id="moveFileCategory">
|
||||
${['默认类目', '教学类目', '产品文档', '用户指南'].map(category => `
|
||||
<option value="${category}" ${file.category === category ? 'selected' : ''}>${category}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
`,
|
||||
confirmText: '移动',
|
||||
onConfirm: () => {
|
||||
file.category = document.getElementById('moveFileCategory')?.value || file.category;
|
||||
renderPage();
|
||||
Toast.success('文件已移动');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.__deleteFile = function(fileId) {
|
||||
@@ -875,6 +986,10 @@ registerPage('kb-detail', function(container) {
|
||||
|
||||
// 批量操作
|
||||
window.__reparseSelectedFiles = function() {
|
||||
if (selectedFiles.size === 0) {
|
||||
Toast.warning('请先选择文件');
|
||||
return;
|
||||
}
|
||||
createModal({
|
||||
title: '重新解析',
|
||||
content: `<p>确定要重新解析选中的 <strong>${selectedFiles.size}</strong> 个文件吗?</p>`,
|
||||
@@ -893,10 +1008,43 @@ registerPage('kb-detail', function(container) {
|
||||
};
|
||||
|
||||
window.__moveSelectedFiles = function() {
|
||||
Toast.info('移动文件功能');
|
||||
if (selectedFiles.size === 0) {
|
||||
Toast.warning('请先选择文件');
|
||||
return;
|
||||
}
|
||||
createModal({
|
||||
title: '批量移动类目',
|
||||
content: `
|
||||
<p style="margin-bottom:12px">将选中的 <strong>${selectedFiles.size}</strong> 个文件移动到:</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">目标类目</label>
|
||||
<select class="form-control" id="moveSelectedCategory">
|
||||
<option value="默认类目">默认类目</option>
|
||||
<option value="教学类目">教学类目</option>
|
||||
<option value="产品文档">产品文档</option>
|
||||
<option value="用户指南">用户指南</option>
|
||||
</select>
|
||||
</div>
|
||||
`,
|
||||
confirmText: '移动',
|
||||
onConfirm: () => {
|
||||
const category = document.getElementById('moveSelectedCategory')?.value || '默认类目';
|
||||
selectedFiles.forEach(id => {
|
||||
const file = kbFiles.find(f => f.id === id);
|
||||
if (file) file.category = category;
|
||||
});
|
||||
selectedFiles.clear();
|
||||
renderPage();
|
||||
Toast.success('文件已移动');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.__deleteSelectedFiles = function() {
|
||||
if (selectedFiles.size === 0) {
|
||||
Toast.warning('请先选择文件');
|
||||
return;
|
||||
}
|
||||
createModal({
|
||||
title: '批量删除',
|
||||
content: `
|
||||
@@ -1164,7 +1312,49 @@ registerPage('kb-detail', function(container) {
|
||||
|
||||
// 知识库操作
|
||||
window.__editKb = function() {
|
||||
Toast.info('编辑知识库');
|
||||
createModal({
|
||||
title: '编辑知识库',
|
||||
size: 'lg',
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">知识库名称 <span style="color:var(--danger)">*</span></label>
|
||||
<input class="form-control" id="detailKbName" maxlength="64" value="${escapeHtml(kbData.name)}" placeholder="请输入知识库名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea class="form-control" id="detailKbDesc" rows="3" maxlength="500">${escapeHtml(kbData.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">标签</label>
|
||||
<input class="form-control" id="detailKbTags" value="${escapeHtml(kbData.tags.join(', '))}" placeholder="多个标签用逗号分隔">
|
||||
</div>
|
||||
`,
|
||||
confirmText: '保存',
|
||||
onConfirm: () => {
|
||||
const name = document.getElementById('detailKbName')?.value.trim();
|
||||
if (!name) {
|
||||
Toast.warning('请输入知识库名称');
|
||||
return false;
|
||||
}
|
||||
kbData.name = name;
|
||||
kbData.description = document.getElementById('detailKbDesc')?.value.trim() || '';
|
||||
kbData.tags = (document.getElementById('detailKbTags')?.value || '')
|
||||
.split(/[,,]/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
kbData.updatedAt = getNowText();
|
||||
const catalog = Array.isArray(window.__knowledgeBaseCatalog) ? window.__knowledgeBaseCatalog : [];
|
||||
const target = catalog.find(item => item.id === kbData.id);
|
||||
if (target) Object.assign(target, {
|
||||
name: kbData.name,
|
||||
description: kbData.description,
|
||||
tags: [...kbData.tags],
|
||||
updatedAt: kbData.updatedAt,
|
||||
});
|
||||
renderPage();
|
||||
Toast.success('知识库信息已更新');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.__rebuildAllIndex = function() {
|
||||
@@ -1177,6 +1367,15 @@ registerPage('kb-detail', function(container) {
|
||||
confirmText: '重建',
|
||||
onConfirm: () => {
|
||||
// TODO: 调用 API POST /api/v1/knowledge-bases/:id/rebuild
|
||||
kbData.status = 'processing';
|
||||
kbData.updatedAt = getNowText();
|
||||
const catalog = Array.isArray(window.__knowledgeBaseCatalog) ? window.__knowledgeBaseCatalog : [];
|
||||
const target = catalog.find(item => item.id === kbData.id);
|
||||
if (target) {
|
||||
target.status = kbData.status;
|
||||
target.updatedAt = kbData.updatedAt;
|
||||
}
|
||||
renderPage();
|
||||
Toast.success('索引重建任务已启动');
|
||||
}
|
||||
});
|
||||
@@ -1191,13 +1390,75 @@ registerPage('kb-detail', function(container) {
|
||||
`,
|
||||
confirmText: '重新解析',
|
||||
onConfirm: () => {
|
||||
kbFiles = kbFiles.map(file => ({
|
||||
...file,
|
||||
status: 'processing',
|
||||
indexedAt: '-',
|
||||
parseError: null,
|
||||
}));
|
||||
kbData.status = 'processing';
|
||||
kbData.updatedAt = getNowText();
|
||||
const catalog = Array.isArray(window.__knowledgeBaseCatalog) ? window.__knowledgeBaseCatalog : [];
|
||||
const target = catalog.find(item => item.id === kbData.id);
|
||||
if (target) {
|
||||
target.status = kbData.status;
|
||||
target.updatedAt = kbData.updatedAt;
|
||||
}
|
||||
selectedFiles.clear();
|
||||
renderPage();
|
||||
Toast.success('重新解析任务已启动');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.__exportKb = function() {
|
||||
Toast.info('导出知识库');
|
||||
createModal({
|
||||
title: '导出知识库',
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">导出格式</label>
|
||||
<select class="form-control" id="detailExportFormat">
|
||||
<option value="json">JSON 完整数据</option>
|
||||
<option value="csv">CSV 文件列表</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="detailExportIncludeFiles" checked>
|
||||
<span>包含文件列表和切片摘要</span>
|
||||
</label>
|
||||
<div class="form-hint">浏览器会下载到默认下载目录;后端接入后替换为 GET /api/v1/knowledge-bases/:id/export。</div>
|
||||
`,
|
||||
confirmText: '确认导出',
|
||||
onConfirm: () => {
|
||||
const format = document.getElementById('detailExportFormat')?.value || 'json';
|
||||
const includeFiles = document.getElementById('detailExportIncludeFiles')?.checked !== false;
|
||||
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
if (format === 'csv') {
|
||||
const rows = [
|
||||
'id,name,format,size,status,category,slices,chars,indexedAt',
|
||||
...kbFiles.map(file => [
|
||||
file.id,
|
||||
file.name,
|
||||
file.format,
|
||||
file.size,
|
||||
file.status,
|
||||
file.category,
|
||||
file.slices,
|
||||
file.chars,
|
||||
file.indexedAt,
|
||||
].map(value => `"${String(value ?? '').replace(/"/g, '""')}"`).join(',')),
|
||||
].join('\n');
|
||||
downloadTextFile(`${kbData.id}-files-${stamp}.csv`, rows, 'text/csv;charset=utf-8');
|
||||
} else {
|
||||
downloadTextFile(`${kbData.id}-${stamp}.json`, JSON.stringify({
|
||||
knowledgeBase: kbData,
|
||||
files: includeFiles ? kbFiles : undefined,
|
||||
slices: includeFiles ? mockSlices : undefined,
|
||||
}, null, 2));
|
||||
}
|
||||
Toast.success('知识库已导出');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.__deleteKb = function() {
|
||||
@@ -1217,6 +1478,10 @@ registerPage('kb-detail', function(container) {
|
||||
confirmClass: 'btn-danger',
|
||||
onConfirm: () => {
|
||||
// TODO: 调用 API DELETE /api/v1/knowledge-bases/:id
|
||||
if (Array.isArray(window.__knowledgeBaseCatalog)) {
|
||||
const index = window.__knowledgeBaseCatalog.findIndex(item => item.id === kbData.id);
|
||||
if (index !== -1) window.__knowledgeBaseCatalog.splice(index, 1);
|
||||
}
|
||||
Toast.success('知识库已删除');
|
||||
setTimeout(() => navigateTo('knowledge-base'), 1000);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ registerPage('knowledge-base', function(container) {
|
||||
let sortField = 'updatedAt';
|
||||
let sortOrder = 'desc';
|
||||
let isLoading = true;
|
||||
let hasStartedInitialLoad = false;
|
||||
let kbData = [];
|
||||
|
||||
// ==================== API预留 - 后端接口定义 ====================
|
||||
@@ -71,8 +72,37 @@ registerPage('knowledge-base', function(container) {
|
||||
{ id: 'kb-008', name: '音乐教育资源库', description: '儿童音乐教育资源', docs: 560, size: '65MB', type: 'video_search', typeName: '音视频搜索', status: 'uploading', createdAt: '2026-05-12 10:00:00', updatedAt: '2026-05-12 18:00:00', tags: ['音乐', '教育'] },
|
||||
];
|
||||
|
||||
// 初始化数据
|
||||
kbData = [...mockKbData];
|
||||
// 初始化数据。页面刷新后回到 mock;SPA 内新建/编辑/删除会保留在全局目录中。
|
||||
kbData = Array.isArray(window.__knowledgeBaseCatalog) && window.__knowledgeBaseCatalog.length
|
||||
? window.__knowledgeBaseCatalog.map(item => ({ ...item, tags: [...(item.tags || [])] }))
|
||||
: mockKbData.map(item => ({ ...item, tags: [...(item.tags || [])] }));
|
||||
window.__knowledgeBaseCatalog = kbData;
|
||||
|
||||
function syncKnowledgeBaseCatalog() {
|
||||
window.__knowledgeBaseCatalog = kbData;
|
||||
}
|
||||
|
||||
function getKbById(id) {
|
||||
return kbData.find(kb => kb.id === id);
|
||||
}
|
||||
|
||||
function getNowText() {
|
||||
const date = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename, content, mime = 'application/json;charset=utf-8') {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
@@ -197,6 +227,13 @@ registerPage('knowledge-base', function(container) {
|
||||
if (sortField === 'name' || sortField === 'typeName') {
|
||||
return sortOrder === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
||||
}
|
||||
if (sortField === 'createdAt' || sortField === 'updatedAt') {
|
||||
valA = Date.parse(valA || '') || 0;
|
||||
valB = Date.parse(valB || '') || 0;
|
||||
} else {
|
||||
valA = Number(valA) || 0;
|
||||
valB = Number(valB) || 0;
|
||||
}
|
||||
return sortOrder === 'asc' ? valA - valB : valB - valA;
|
||||
});
|
||||
|
||||
@@ -252,7 +289,7 @@ registerPage('knowledge-base', function(container) {
|
||||
</div>
|
||||
<div class="kb-list-actions">
|
||||
<button class="btn btn-primary" id="addKbBtn">
|
||||
<i data-lucide="plus"></i> 添加知识库
|
||||
<i data-lucide="plus"></i> 创建知识库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,7 +482,7 @@ registerPage('knowledge-base', function(container) {
|
||||
</div>
|
||||
<div class="kb-empty-title">暂无知识库</div>
|
||||
<div class="kb-empty-desc">点击上方「添加知识库」开始创建您的第一个知识库</div>
|
||||
<button class="btn btn-primary" onclick="window.__showCreateKbModal()">
|
||||
<button class="btn btn-primary" onclick="navigateTo('kb-create')">
|
||||
<i data-lucide="plus" style="width:14px;height:14px"></i> 创建知识库
|
||||
</button>
|
||||
</div>
|
||||
@@ -477,7 +514,7 @@ registerPage('knowledge-base', function(container) {
|
||||
|
||||
function bindEvents() {
|
||||
// 添加知识库
|
||||
container.querySelector('#addKbBtn')?.addEventListener('click', showCreateKbModal);
|
||||
container.querySelector('#addKbBtn')?.addEventListener('click', () => navigateTo('kb-create'));
|
||||
|
||||
// 搜索
|
||||
container.querySelector('#kbSearchInput')?.addEventListener('input', debounce((e) => {
|
||||
@@ -487,11 +524,14 @@ registerPage('knowledge-base', function(container) {
|
||||
renderPage();
|
||||
}, 300));
|
||||
|
||||
// 模拟加载
|
||||
setTimeout(() => {
|
||||
isLoading = false;
|
||||
renderPage();
|
||||
}, 500);
|
||||
// 模拟加载,只在首次进入页面时触发一次,避免重渲染打断下拉菜单等交互。
|
||||
if (isLoading && !hasStartedInitialLoad) {
|
||||
hasStartedInitialLoad = true;
|
||||
setTimeout(() => {
|
||||
isLoading = false;
|
||||
renderPage();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -639,11 +679,17 @@ registerPage('knowledge-base', function(container) {
|
||||
`;
|
||||
|
||||
document.body.appendChild(modalOverlay);
|
||||
document.body.classList.add('modal-open');
|
||||
lucide.createIcons({ elements: [modalOverlay] });
|
||||
|
||||
const close = () => {
|
||||
modalOverlay.classList.remove('open');
|
||||
setTimeout(() => modalOverlay.remove(), 200);
|
||||
setTimeout(() => {
|
||||
modalOverlay.remove();
|
||||
if (!document.querySelector('.modal-overlay')) {
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// 关闭事件
|
||||
@@ -818,6 +864,148 @@ registerPage('knowledge-base', function(container) {
|
||||
}
|
||||
};
|
||||
|
||||
function openEditKbModal(id) {
|
||||
const kb = getKbById(id);
|
||||
if (!kb) {
|
||||
Toast.warning('未找到该知识库');
|
||||
return;
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: '编辑知识库',
|
||||
size: 'lg',
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">知识库名称 <span style="color:var(--danger)">*</span></label>
|
||||
<input class="form-control" id="kbEditName" maxlength="64" value="${escapeHtml(kb.name)}" placeholder="请输入知识库名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">知识库描述</label>
|
||||
<textarea class="form-control" id="kbEditDesc" rows="3" maxlength="500" placeholder="请输入知识库描述">${escapeHtml(kb.description || '')}</textarea>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
|
||||
<div class="form-group">
|
||||
<label class="form-label">知识库类型</label>
|
||||
<select class="form-control" id="kbEditType">
|
||||
<option value="doc_search" ${kb.type === 'doc_search' ? 'selected' : ''}>文档搜索</option>
|
||||
<option value="data_query" ${kb.type === 'data_query' ? 'selected' : ''}>数据查询</option>
|
||||
<option value="image_qa" ${kb.type === 'image_qa' ? 'selected' : ''}>图片问答</option>
|
||||
<option value="video_search" ${kb.type === 'video_search' ? 'selected' : ''}>音视频搜索</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">状态</label>
|
||||
<select class="form-control" id="kbEditStatus">
|
||||
<option value="ready" ${kb.status === 'ready' ? 'selected' : ''}>已就绪</option>
|
||||
<option value="processing" ${kb.status === 'processing' ? 'selected' : ''}>处理中</option>
|
||||
<option value="uploading" ${kb.status === 'uploading' ? 'selected' : ''}>上传中</option>
|
||||
<option value="error" ${kb.status === 'error' ? 'selected' : ''}>异常</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">标签</label>
|
||||
<input class="form-control" id="kbEditTags" value="${escapeHtml((kb.tags || []).join(', '))}" placeholder="多个标签用逗号分隔">
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
<i data-lucide="info" style="width:12px;height:12px;vertical-align:middle"></i>
|
||||
前端 mock 会立即更新当前列表;后端接入后调用 PUT /api/v1/knowledge-bases/:id。
|
||||
</div>
|
||||
`,
|
||||
confirmText: '保存',
|
||||
onConfirm: () => {
|
||||
const name = document.getElementById('kbEditName')?.value.trim();
|
||||
if (!name) {
|
||||
Toast.warning('请输入知识库名称');
|
||||
return false;
|
||||
}
|
||||
const type = document.getElementById('kbEditType')?.value || kb.type;
|
||||
const tags = (document.getElementById('kbEditTags')?.value || '')
|
||||
.split(/[,,]/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
kb.name = name;
|
||||
kb.description = document.getElementById('kbEditDesc')?.value.trim() || '';
|
||||
kb.type = type;
|
||||
kb.typeName = getTypeTag(type).label;
|
||||
kb.status = document.getElementById('kbEditStatus')?.value || kb.status;
|
||||
kb.tags = tags;
|
||||
kb.updatedAt = getNowText();
|
||||
syncKnowledgeBaseCatalog();
|
||||
renderPage();
|
||||
Toast.success('知识库信息已更新');
|
||||
}
|
||||
});
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function exportKnowledgeBases(ids) {
|
||||
const items = ids.map(id => getKbById(id)).filter(Boolean);
|
||||
if (!items.length) {
|
||||
Toast.warning('请先选择要导出的知识库');
|
||||
return;
|
||||
}
|
||||
|
||||
createModal({
|
||||
title: items.length > 1 ? '批量导出知识库' : '导出知识库',
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">导出范围</label>
|
||||
<div style="padding:10px 12px;background:var(--bg-base);border-radius:var(--radius);font-size:13px;line-height:1.6">
|
||||
${items.map(item => escapeHtml(item.name)).join('、')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">导出格式</label>
|
||||
<select class="form-control" id="kbExportFormat">
|
||||
<option value="json">JSON 配置数据</option>
|
||||
<option value="csv">CSV 列表数据</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="kbExportIncludeStats" checked>
|
||||
<span>包含文档数量、数据量、状态和标签</span>
|
||||
</label>
|
||||
<div class="form-hint">
|
||||
浏览器会下载到默认下载目录;后端接入后替换为 GET /api/v1/knowledge-bases/:id/export。
|
||||
</div>
|
||||
`,
|
||||
confirmText: '确认导出',
|
||||
onConfirm: () => {
|
||||
const format = document.getElementById('kbExportFormat')?.value || 'json';
|
||||
const includeStats = document.getElementById('kbExportIncludeStats')?.checked !== false;
|
||||
const exportRows = items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
typeName: item.typeName,
|
||||
...(includeStats ? {
|
||||
docs: item.docs,
|
||||
size: item.size,
|
||||
status: item.status,
|
||||
tags: item.tags,
|
||||
} : {}),
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
}));
|
||||
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
if (format === 'csv') {
|
||||
const headers = Object.keys(exportRows[0]);
|
||||
const csv = [
|
||||
headers.join(','),
|
||||
...exportRows.map(row => headers.map(key => `"${String(Array.isArray(row[key]) ? row[key].join('|') : row[key] ?? '').replace(/"/g, '""')}"`).join(',')),
|
||||
].join('\n');
|
||||
downloadTextFile(`knowledge-bases-${stamp}.csv`, csv, 'text/csv;charset=utf-8');
|
||||
} else {
|
||||
downloadTextFile(`knowledge-bases-${stamp}.json`, JSON.stringify(exportRows, null, 2));
|
||||
}
|
||||
Toast.success(`已导出 ${items.length} 个知识库`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 操作
|
||||
window.__kbDetail = function(id) {
|
||||
navigateTo('kb-detail?id=' + id);
|
||||
@@ -828,7 +1016,7 @@ registerPage('knowledge-base', function(container) {
|
||||
};
|
||||
|
||||
window.__kbEdit = function(id) {
|
||||
Toast.info('编辑知识库:' + id);
|
||||
openEditKbModal(id);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -855,6 +1043,8 @@ registerPage('knowledge-base', function(container) {
|
||||
// TODO: 调用 API DELETE /api/v1/knowledge-bases/:id
|
||||
const idx = kbData.findIndex(k => k.id === id);
|
||||
if (idx !== -1) kbData.splice(idx, 1);
|
||||
selectedRows.delete(id);
|
||||
syncKnowledgeBaseCatalog();
|
||||
renderPage();
|
||||
Toast.success('知识库已删除');
|
||||
}
|
||||
@@ -879,6 +1069,12 @@ registerPage('knowledge-base', function(container) {
|
||||
confirmText: '重建',
|
||||
onConfirm: () => {
|
||||
// TODO: 调用 API POST /api/v1/knowledge-bases/:id/rebuild
|
||||
if (kb) {
|
||||
kb.status = 'processing';
|
||||
kb.updatedAt = getNowText();
|
||||
syncKnowledgeBaseCatalog();
|
||||
renderPage();
|
||||
}
|
||||
Toast.success('索引重建任务已启动');
|
||||
}
|
||||
});
|
||||
@@ -889,8 +1085,7 @@ registerPage('knowledge-base', function(container) {
|
||||
* API: GET /api/v1/knowledge-bases/:id/export
|
||||
*/
|
||||
window.__kbExport = function(id) {
|
||||
// TODO: 调用 API GET /api/v1/knowledge-bases/:id/export
|
||||
Toast.info('正在导出知识库...');
|
||||
exportKnowledgeBases([id]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -898,8 +1093,7 @@ registerPage('knowledge-base', function(container) {
|
||||
* API: GET /api/v1/knowledge-bases/batch/export
|
||||
*/
|
||||
window.__kbBatchExport = function() {
|
||||
// TODO: 调用 API GET /api/v1/knowledge-bases/batch/export
|
||||
Toast.info(`正在导出 ${selectedRows.size} 个知识库...`);
|
||||
exportKnowledgeBases(Array.from(selectedRows));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -907,6 +1101,11 @@ registerPage('knowledge-base', function(container) {
|
||||
* API: DELETE /api/v1/knowledge-bases/batch
|
||||
*/
|
||||
window.__kbBatchDelete = function() {
|
||||
if (selectedRows.size === 0) {
|
||||
Toast.warning('请先选择要删除的知识库');
|
||||
return;
|
||||
}
|
||||
const deleteCount = selectedRows.size;
|
||||
createModal({
|
||||
title: '批量删除',
|
||||
content: `
|
||||
@@ -928,14 +1127,17 @@ registerPage('knowledge-base', function(container) {
|
||||
if (idx !== -1) kbData.splice(idx, 1);
|
||||
});
|
||||
selectedRows.clear();
|
||||
syncKnowledgeBaseCatalog();
|
||||
renderPage();
|
||||
Toast.success(`已删除 ${selectedRows.size + 1} 个知识库`);
|
||||
Toast.success(`已删除 ${deleteCount} 个知识库`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 导出弹窗函数
|
||||
window.__showCreateKbModal = showCreateKbModal;
|
||||
window.__showCreateKbModal = function() {
|
||||
navigateTo('kb-create');
|
||||
};
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
renderPage();
|
||||
|
||||
@@ -3,22 +3,139 @@
|
||||
*/
|
||||
|
||||
registerPage('privacy', function(container) {
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">隐私保护 (PII)</div>
|
||||
<div class="page-desc">个人信息脱敏、COPPA/GDPR 合规配置与数据保留策略</div>
|
||||
const DEFAULT_PRIVACY_STATE = {
|
||||
activeTab: 'pii',
|
||||
piiGlobalEnabled: true,
|
||||
piiRules: [
|
||||
{ id: 'name', name: '姓名', type: 'person_name', mask: '***', example: '张三 → ***', enabled: true, system: true },
|
||||
{ id: 'phone', name: '手机号', type: 'phone', mask: '138****8888', example: '13812348888 → 138****8888', enabled: true, system: true },
|
||||
{ id: 'ip', name: 'IP 地址', type: 'ip', mask: '192.168.x.x', example: '192.168.1.10 → 192.168.x.x', enabled: true, system: true },
|
||||
{ id: 'idcard', name: '身份证号', type: 'id_card', mask: '330102****1234', example: '330102199001011234 → 330102****1234', enabled: true, system: true },
|
||||
{ id: 'address', name: '家庭住址', type: 'address', mask: '[地址已脱敏]', example: '北京市朝阳区某小区 → [地址已脱敏]', enabled: true, system: true },
|
||||
{ id: 'bankcard', name: '银行卡号', type: 'bank_card', mask: '6222****1234', example: '6222020202021234 → 6222****1234', enabled: false, system: true },
|
||||
],
|
||||
coppaItems: [
|
||||
{ id: 'age', label: '用户年龄验证', enabled: true },
|
||||
{ id: 'parent-consent', label: '家长知情同意收集', enabled: true },
|
||||
{ id: 'location', label: '禁止收集13岁以下精确位置', enabled: true },
|
||||
{ id: 'tracking', label: '行为追踪限制', enabled: true },
|
||||
{ id: 'minimize', label: '数据最小化原则', enabled: true },
|
||||
],
|
||||
gdprItems: [
|
||||
{ id: 'notice', label: '知情权(提供隐私政策)', enabled: true },
|
||||
{ id: 'access', label: '访问权(数据导出)', enabled: true },
|
||||
{ id: 'rectify', label: '更正权', enabled: true },
|
||||
{ id: 'erase', label: '删除权(被遗忘权)', enabled: true },
|
||||
{ id: 'portable', label: '数据可携带权', enabled: false },
|
||||
],
|
||||
dataRequests: [
|
||||
{ id: 'DSR-2026-001', subject: 'parent-a@example.com', company: '智慧星科技', type: '访问数据', status: 'pending', createdAt: '2026-05-12 13:20', result: '' },
|
||||
{ id: 'DSR-2026-002', subject: 'child-user-003', company: '未来玩具厂', type: '删除数据', status: 'processed', createdAt: '2026-05-11 10:05', result: '已删除关联会话与设备行为数据' },
|
||||
{ id: 'DSR-2026-003', subject: 'privacy@partner.com', company: '童趣电子', type: '更正数据', status: 'rejected', createdAt: '2026-05-10 16:45', result: '未能验证请求人身份' },
|
||||
],
|
||||
retentionPolicies: [
|
||||
{ id: 'sessions', type: '对话记录 (Sessions)', period: 180, unit: '天' },
|
||||
{ id: 'device-logs', type: '设备日志 (Device Logs)', period: 90, unit: '天' },
|
||||
{ id: 'badcase', type: 'Bad Case 数据', period: 365, unit: '天' },
|
||||
{ id: 'ota', type: 'OTA 升级记录', period: 365, unit: '天' },
|
||||
{ id: 'audit', type: '审计日志 (Audit Logs)', period: 730, unit: '天' },
|
||||
{ id: 'pii-logs', type: 'PII 脱敏日志', period: 30, unit: '天' },
|
||||
],
|
||||
lastCleanup: '',
|
||||
};
|
||||
|
||||
const state = window.__privacyState || clonePrivacyState(DEFAULT_PRIVACY_STATE);
|
||||
window.__privacyState = state;
|
||||
|
||||
function clonePrivacyState(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getNowText() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function callPrivacyApi(method, ...args) {
|
||||
try {
|
||||
if (typeof API !== 'undefined' && API.privacy?.[method]) {
|
||||
await API.privacy[method](...args);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Mock privacy API ${method} failed:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestStatusMeta(status) {
|
||||
if (status === 'processed') return { label: '已处理', badge: 'badge-success' };
|
||||
if (status === 'rejected') return { label: '已拒绝', badge: 'badge-danger' };
|
||||
return { label: '待处理', badge: 'badge-warning' };
|
||||
}
|
||||
|
||||
function getComplianceStatus(items) {
|
||||
return items.every(item => item.enabled)
|
||||
? { label: '已合规', badge: 'badge-success', text: '所有必要控制项均已开启' }
|
||||
: { label: '需关注', badge: 'badge-warning', text: '仍有控制项处于关闭状态' };
|
||||
}
|
||||
|
||||
function maskText(text) {
|
||||
let result = String(text || '');
|
||||
const enabledRules = state.piiGlobalEnabled ? state.piiRules.filter(rule => rule.enabled) : [];
|
||||
enabledRules.forEach(rule => {
|
||||
if (rule.type === 'phone') result = result.replace(/1[3-9]\d{9}/g, '1**********');
|
||||
if (rule.type === 'ip') result = result.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '***.***.x.x');
|
||||
if (rule.type === 'id_card') result = result.replace(/\b\d{6}(?:19|20)\d{2}\d{2}\d{2}\d{3}[\dXx]\b/g, '****** ******** ****');
|
||||
if (rule.type === 'bank_card') result = result.replace(/\b\d{12,19}\b/g, '**** **** **** ****');
|
||||
if (rule.type === 'email') result = result.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[邮箱已脱敏]');
|
||||
if (rule.type === 'address') result = result.replace(/[\u4e00-\u9fa5]{2,}(省|市|区|县|镇|街道|小区)[\u4e00-\u9fa5\d号栋单元室-]*/g, '[地址已脱敏]');
|
||||
if (rule.type === 'person_name') result = result.replace(/([\u4e00-\u9fa5]{2,4})(?=说|的|,|,|。|\s|$)/g, '***');
|
||||
if (rule.type === 'custom' && rule.pattern) {
|
||||
try {
|
||||
result = result.replace(new RegExp(rule.pattern, 'g'), rule.mask || '[已脱敏]');
|
||||
} catch (error) {
|
||||
console.warn('Invalid custom PII pattern', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">隐私保护 (PII)</div>
|
||||
<div class="page-desc">个人信息脱敏、COPPA/GDPR 合规配置与数据保留策略</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="privacyTabs">
|
||||
<div class="tab-item active" data-tab="pii">PII 脱敏规则</div>
|
||||
<div class="tab-item" data-tab="gdpr">GDPR/COPPA</div>
|
||||
<div class="tab-item" data-tab="retention">数据保留</div>
|
||||
</div>
|
||||
<div class="tabs" id="privacyTabs">
|
||||
<div class="tab-item ${state.activeTab === 'pii' ? 'active' : ''}" data-tab="pii">PII 脱敏规则</div>
|
||||
<div class="tab-item ${state.activeTab === 'gdpr' ? 'active' : ''}" data-tab="gdpr">GDPR/COPPA</div>
|
||||
<div class="tab-item ${state.activeTab === 'retention' ? 'active' : ''}" data-tab="retention">数据保留</div>
|
||||
</div>
|
||||
|
||||
<!-- PII Tab -->
|
||||
<div class="tab-content active" id="tab-pii">
|
||||
<div class="tab-content ${state.activeTab === 'pii' ? 'active' : ''}" id="tab-pii">${renderPiiTab()}</div>
|
||||
<div class="tab-content ${state.activeTab === 'gdpr' ? 'active' : ''}" id="tab-gdpr">${renderComplianceTab()}</div>
|
||||
<div class="tab-content ${state.activeTab === 'retention' ? 'active' : ''}" id="tab-retention">${renderRetentionTab()}</div>
|
||||
`;
|
||||
|
||||
bindEvents();
|
||||
lucide.createIcons({ elements: [container] });
|
||||
}
|
||||
|
||||
function renderPiiTab() {
|
||||
return `
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
@@ -28,29 +145,34 @@ registerPage('privacy', function(container) {
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:13px;color:var(--text-muted)">全局启用</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" checked>
|
||||
<input type="checkbox" id="piiGlobalToggle" ${state.piiGlobalEnabled ? 'checked' : ''}>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px">
|
||||
${[
|
||||
{ name: '姓名', example: '张三 → ***', enabled: true },
|
||||
{ name: '手机号', example: '138****8888', enabled: true },
|
||||
{ name: 'IP 地址', example: '192.168.x.x', enabled: true },
|
||||
{ name: '身份证号', example: '330102****1234', enabled: true },
|
||||
{ name: '家庭住址', example: '北京市朝阳区*** → [地址已脱敏]', enabled: true },
|
||||
{ name: '银行卡号', example: '6222****1234', enabled: false },
|
||||
].map(r => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border:1px solid var(--border);border-radius:var(--radius-sm)">
|
||||
<div>
|
||||
<div style="font-weight:500;font-size:13px">${r.name}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);font-family:monospace">${r.example}</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-bottom:14px">
|
||||
<button class="btn btn-outline btn-sm" id="testPiiBtn"><i data-lucide="scan-text"></i> 测试脱敏</button>
|
||||
<button class="btn btn-outline btn-sm" id="addPiiRuleBtn"><i data-lucide="plus"></i> 新增规则</button>
|
||||
<button class="btn btn-primary btn-sm" id="savePiiBtn"><i data-lucide="save"></i> 保存配置</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
|
||||
${state.piiRules.map(rule => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:var(--radius-sm)">
|
||||
<div style="min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<span style="font-weight:600;font-size:13px">${escapeHtml(rule.name)}</span>
|
||||
${rule.system ? '<span class="badge badge-default" style="font-size:11px">系统</span>' : '<span class="badge badge-info" style="font-size:11px">自定义</span>'}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);font-family:monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(rule.example)}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" class="pii-rule-toggle" data-rule-id="${escapeHtml(rule.id)}" ${rule.enabled ? 'checked' : ''}>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
<button class="btn btn-ghost btn-sm" data-tooltip="编辑规则" onclick="editPiiRule('${escapeHtml(rule.id)}')"><i data-lucide="edit-2"></i></button>
|
||||
<button class="btn btn-ghost btn-sm" style="color:var(--danger)" data-tooltip="删除规则" onclick="deletePiiRule('${escapeHtml(rule.id)}')"><i data-lucide="trash-2"></i></button>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" ${r.enabled ? 'checked' : ''}>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
@@ -58,256 +180,506 @@ registerPage('privacy', function(container) {
|
||||
|
||||
<div class="api-note">
|
||||
<i data-lucide="info"></i>
|
||||
<div>PII 规则接口:<code>GET /api/v1/privacy/pii-rules</code> · <code>PUT /api/v1/privacy/pii-rules/{id}</code>。
|
||||
脱敏事件通过 <code>pii_detected</code> / <code>pii_masked</code> 事件上报。</div>
|
||||
<div>PII 规则接口预留:<code>GET /api/v1/privacy/pii-rules</code> · <code>POST /api/v1/privacy/pii-rules</code> · <code>PUT /api/v1/privacy/pii-rules/{id}</code> · <code>DELETE /api/v1/privacy/pii-rules/{id}</code>。</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
<!-- GDPR Tab -->
|
||||
<div class="tab-content" id="tab-gdpr">
|
||||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px">
|
||||
function renderComplianceToggleList(items, group) {
|
||||
return items.map(item => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<span style="font-size:13px">${escapeHtml(item.label)}</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" class="compliance-toggle" data-group="${group}" data-item-id="${escapeHtml(item.id)}" ${item.enabled ? 'checked' : ''}>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderComplianceTab() {
|
||||
const coppaStatus = getComplianceStatus(state.coppaItems);
|
||||
const gdprStatus = getComplianceStatus(state.gdprItems);
|
||||
return `
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-bottom:16px">
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:12px">COPPA 合规(儿童隐私保护)</div>
|
||||
${[
|
||||
{ label: '用户年龄验证', enabled: true },
|
||||
{ label: '家长知情同意收集', enabled: true },
|
||||
{ label: '禁止收集13岁以下精确位置', enabled: true },
|
||||
{ label: '行为追踪限制', enabled: true },
|
||||
{ label: '数据最小化原则', enabled: true },
|
||||
].map(i => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<span style="font-size:13px">${i.label}</span>
|
||||
<label class="toggle-switch"><input type="checkbox" ${i.enabled ? 'checked' : ''}><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div style="margin-top:12px;padding:10px;background:var(--primary-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--primary)">
|
||||
✅ COPPA 合规状态:已合规
|
||||
${renderComplianceToggleList(state.coppaItems, 'coppa')}
|
||||
<div style="margin-top:12px;padding:10px;border-radius:var(--radius-sm);font-size:12px" class="badge ${coppaStatus.badge}">
|
||||
COPPA 合规状态:${coppaStatus.label} · ${coppaStatus.text}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:12px">GDPR 数据权利</div>
|
||||
${[
|
||||
{ label: '知情权(提供隐私政策)', enabled: true },
|
||||
{ label: '访问权(数据导出)', enabled: true },
|
||||
{ label: '更正权', enabled: true },
|
||||
{ label: '删除权(被遗忘权)', enabled: true },
|
||||
{ label: '数据可携带权', enabled: false },
|
||||
].map(i => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<span style="font-size:13px">${i.label}</span>
|
||||
<label class="toggle-switch"><input type="checkbox" ${i.enabled ? 'checked' : ''}><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div style="margin-top:12px;padding:10px;background:#fffbe6;border-radius:var(--radius-sm);font-size:12px;color:var(--warning)">
|
||||
⚠️ 数据可携带权尚未完整实现
|
||||
${renderComplianceToggleList(state.gdprItems, 'gdpr')}
|
||||
<div style="margin-top:12px;padding:10px;border-radius:var(--radius-sm);font-size:12px" class="badge ${gdprStatus.badge}">
|
||||
GDPR 合规状态:${gdprStatus.label} · ${gdprStatus.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retention Tab -->
|
||||
<div class="tab-content" id="tab-retention">
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:16px">数据保留策略</div>
|
||||
${[
|
||||
{ type: '对话记录 (Sessions)', period: '180', unit: '天' },
|
||||
{ type: '设备日志 (Device Logs)', period: '90', unit: '天' },
|
||||
{ type: 'Bad Case 数据', period: '365', unit: '天' },
|
||||
{ type: 'OTA 升级记录', period: '365', unit: '天' },
|
||||
{ type: '审计日志 (Audit Logs)', period: '730', unit: '天' },
|
||||
{ type: 'PII 脱敏日志', period: '30', unit: '天' },
|
||||
].map(p => `
|
||||
<div style="display:flex;align-items:center;gap:16px;padding:10px 0;border-bottom:1px solid var(--border)">
|
||||
<span style="flex:1;font-size:13px">${p.type}</span>
|
||||
<input type="number" class="form-control" value="${p.period}" style="width:80px">
|
||||
<span style="font-size:13px;color:var(--text-muted)">${p.unit}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
<button class="btn btn-primary" style="margin-top:16px" onclick="Toast.success('数据保留策略已保存')">
|
||||
<i data-lucide="save"></i> 保存策略
|
||||
</button>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-bottom:16px">
|
||||
<button class="btn btn-outline btn-sm" id="runComplianceCheckBtn"><i data-lucide="shield-check"></i> 运行合规检查</button>
|
||||
<button class="btn btn-primary btn-sm" id="saveComplianceBtn"><i data-lucide="save"></i> 保存合规配置</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
lucide.createIcons({ elements: [container] });
|
||||
|
||||
container.querySelectorAll('#privacyTabs .tab-item').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
container.querySelectorAll('#privacyTabs .tab-item').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
container.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
container.querySelector(`#tab-${tab.dataset.tab}`)?.classList.add('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TalkingQ Platform - Settings Page
|
||||
*/
|
||||
registerPage('settings', function(container) {
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-title">系统设置</div>
|
||||
<div class="page-desc">租户信息、API Key 管理、通知配置与集成</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="settingsTabs">
|
||||
<div class="tab-item active" data-tab="tenant">租户信息</div>
|
||||
<div class="tab-item" data-tab="apikeys">API Keys</div>
|
||||
<div class="tab-item" data-tab="notify">通知配置</div>
|
||||
<div class="tab-item" data-tab="integrations">集成</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="stab-tenant">
|
||||
<div class="card" style="max-width:600px">
|
||||
<div class="card-title" style="margin-bottom:20px">租户基本信息</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">租户名称</label>
|
||||
<input type="text" class="form-control" value="TalkingQ 默认租户">
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div class="card-title">数据主体请求</div>
|
||||
<div class="card-subtitle">处理访问、更正、删除等 GDPR 数据权利请求</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">租户 ID</label>
|
||||
<input type="text" class="form-control" value="tenant-20240501" readonly style="background:#fafafa;color:var(--text-muted)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">联系邮箱</label>
|
||||
<input type="email" class="form-control" value="admin@talkingq.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">时区</label>
|
||||
<select class="form-control">
|
||||
<option selected>Asia/Shanghai (UTC+8)</option>
|
||||
<option>UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="Toast.success('租户信息已保存')">
|
||||
<i data-lucide="save"></i> 保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="stab-apikeys">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
|
||||
<div class="card-title">API Keys</div>
|
||||
<button class="btn btn-primary btn-sm" id="createKeyBtn">
|
||||
<i data-lucide="plus"></i> 创建 API Key
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" id="addDataRequestBtn"><i data-lucide="plus"></i> 新增请求</button>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead><tr><th>名称</th><th>Key(部分)</th><th>权限</th><th>创建时间</th><th>最后使用</th><th>操作</th></tr></thead>
|
||||
<thead><tr><th>请求 ID</th><th>请求人</th><th>公司</th><th>类型</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
${[
|
||||
{ name: 'Backend Service Key', key: 'tq_live_****a3f2', scope: '全部权限', created: '2026-01-01', used: '2分钟前' },
|
||||
{ name: 'Analytics Read-only', key: 'tq_ro_****c8e1', scope: '只读', created: '2026-03-15', used: '今天' },
|
||||
].map(k => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${k.name}</td>
|
||||
<td style="font-family:monospace;font-size:12px">${k.key}</td>
|
||||
<td><span class="badge badge-info">${k.scope}</span></td>
|
||||
<td style="font-size:12px;color:var(--text-muted)">${k.created}</td>
|
||||
<td style="font-size:12px;color:var(--text-muted)">${k.used}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" data-tooltip="复制 Key" onclick="Toast.info('Key 已复制到剪贴板')"><i data-lucide="copy"></i></button>
|
||||
<button class="btn btn-ghost btn-sm" style="color:var(--danger)" data-tooltip="撤销 Key" onclick="Toast.warning('Key 已撤销')"><i data-lucide="trash-2"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
${state.dataRequests.map(request => {
|
||||
const meta = getRequestStatusMeta(request.status);
|
||||
return `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:12px;color:var(--primary)">${escapeHtml(request.id)}</td>
|
||||
<td>${escapeHtml(request.subject)}</td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${escapeHtml(request.company)}</td>
|
||||
<td>${escapeHtml(request.type)}</td>
|
||||
<td><span class="badge ${meta.badge}">${meta.label}</span></td>
|
||||
<td style="font-size:12px;color:var(--text-muted)">${escapeHtml(request.createdAt)}</td>
|
||||
<td>
|
||||
${request.status === 'pending'
|
||||
? `<button class="btn btn-outline btn-sm" onclick="processDataRequest('${escapeHtml(request.id)}')">处理</button>`
|
||||
: `<button class="btn btn-ghost btn-sm" onclick="viewDataRequestResult('${escapeHtml(request.id)}')">查看结果</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="api-note" style="margin-top:12px">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRetentionTab() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="card-title">数据保留策略</div>
|
||||
<div class="card-subtitle">设置不同数据类型的自动保留周期</div>
|
||||
</div>
|
||||
${state.lastCleanup ? `<span class="badge badge-default">最近清理:${escapeHtml(state.lastCleanup)}</span>` : ''}
|
||||
</div>
|
||||
${state.retentionPolicies.map(policy => `
|
||||
<div style="display:flex;align-items:center;gap:16px;padding:10px 0;border-bottom:1px solid var(--border)">
|
||||
<span style="flex:1;font-size:13px">${escapeHtml(policy.type)}</span>
|
||||
<input type="number" min="1" max="3650" class="form-control retention-input" data-policy-id="${escapeHtml(policy.id)}" value="${policy.period}" style="width:96px">
|
||||
<span style="font-size:13px;color:var(--text-muted)">${escapeHtml(policy.unit)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div style="display:flex;gap:8px;margin-top:16px">
|
||||
<button class="btn btn-primary" id="saveRetentionBtn"><i data-lucide="save"></i> 保存策略</button>
|
||||
<button class="btn btn-outline" id="resetRetentionBtn"><i data-lucide="rotate-ccw"></i> 恢复默认</button>
|
||||
<button class="btn btn-outline" id="cleanupExpiredBtn"><i data-lucide="trash-2"></i> 清理过期数据</button>
|
||||
</div>
|
||||
<div class="api-note" style="margin-top:14px">
|
||||
<i data-lucide="info"></i>
|
||||
<span>API Key 接口:<code>GET /api/v1/settings/api-keys</code> · <code>POST /api/v1/settings/api-keys</code></span>
|
||||
<span>后端接口预留:<code>GET /api/v1/privacy/retention-policies</code> · <code>PUT /api/v1/privacy/retention-policies/{id}</code> · <code>POST /api/v1/privacy/cleanup-expired</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
<div class="tab-content" id="stab-notify">
|
||||
<div class="card" style="max-width:600px">
|
||||
<div class="card-title" style="margin-bottom:16px">通知配置</div>
|
||||
${[
|
||||
{ label: '设备离线告警', enabled: true },
|
||||
{ label: 'OTA 升级失败告警', enabled: true },
|
||||
{ label: 'Bad Case 严重问题告警', enabled: true },
|
||||
{ label: 'API 超时告警', enabled: false },
|
||||
{ label: '每日数据报告', enabled: true },
|
||||
].map(n => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)">
|
||||
<span style="font-size:13px">${n.label}</span>
|
||||
<label class="toggle-switch"><input type="checkbox" ${n.enabled ? 'checked' : ''}><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<label class="form-label">Webhook URL</label>
|
||||
<input type="text" class="form-control" placeholder="https://your-webhook.com/alerts">
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="Toast.info('Webhook 测试请求已发送')">
|
||||
<i data-lucide="send"></i> 测试 Webhook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="stab-integrations">
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px">
|
||||
${[
|
||||
{ name: '腾讯云 ASR', icon: '🔵', status: 'connected' },
|
||||
{ name: '腾讯云 TTS', icon: '🔵', status: 'connected' },
|
||||
{ name: 'OpenAI', icon: '⚫', status: 'disconnected' },
|
||||
{ name: '阿里云 OSS', icon: '🟠', status: 'connected' },
|
||||
{ name: 'Kafka', icon: '🟡', status: 'connected' },
|
||||
{ name: 'ClickHouse', icon: '🔴', status: 'disconnected' },
|
||||
].map(i => `
|
||||
<div class="card" style="display:flex;align-items:center;gap:12px">
|
||||
<span style="font-size:24px">${i.icon}</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600;font-size:13.5px">${i.name}</div>
|
||||
<span class="status-dot ${i.status === 'connected' ? 'online' : 'offline'}" style="font-size:12px">${i.status === 'connected' ? '已连接' : '未连接'}</span>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="Toast.info('配置 ${i.name} 集成')">配置</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
lucide.createIcons({ elements: [container] });
|
||||
|
||||
container.querySelectorAll('#settingsTabs .tab-item').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
container.querySelectorAll('#settingsTabs .tab-item').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
container.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
container.querySelector(`#stab-${tab.dataset.tab}`)?.classList.add('active');
|
||||
function bindEvents() {
|
||||
container.querySelectorAll('#privacyTabs .tab-item').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
state.activeTab = tab.dataset.tab;
|
||||
renderPage();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#createKeyBtn')?.addEventListener('click', () => {
|
||||
container.querySelector('#piiGlobalToggle')?.addEventListener('change', event => {
|
||||
state.piiGlobalEnabled = event.target.checked;
|
||||
Toast.info(state.piiGlobalEnabled ? 'PII 全局脱敏已开启' : 'PII 全局脱敏已关闭', 1000);
|
||||
});
|
||||
|
||||
container.querySelectorAll('.pii-rule-toggle').forEach(input => {
|
||||
input.addEventListener('change', event => {
|
||||
const rule = state.piiRules.find(item => item.id === event.target.dataset.ruleId);
|
||||
if (rule) rule.enabled = event.target.checked;
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#addPiiRuleBtn')?.addEventListener('click', openPiiRuleModal);
|
||||
container.querySelector('#testPiiBtn')?.addEventListener('click', openPiiTestModal);
|
||||
container.querySelector('#savePiiBtn')?.addEventListener('click', savePiiConfig);
|
||||
|
||||
container.querySelectorAll('.compliance-toggle').forEach(input => {
|
||||
input.addEventListener('change', event => {
|
||||
const list = event.target.dataset.group === 'coppa' ? state.coppaItems : state.gdprItems;
|
||||
const target = list.find(item => item.id === event.target.dataset.itemId);
|
||||
if (target) target.enabled = event.target.checked;
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#saveComplianceBtn')?.addEventListener('click', saveComplianceConfig);
|
||||
container.querySelector('#runComplianceCheckBtn')?.addEventListener('click', runComplianceCheck);
|
||||
container.querySelector('#addDataRequestBtn')?.addEventListener('click', openDataRequestModal);
|
||||
container.querySelector('#saveRetentionBtn')?.addEventListener('click', saveRetentionPolicies);
|
||||
container.querySelector('#resetRetentionBtn')?.addEventListener('click', resetRetentionPolicies);
|
||||
container.querySelector('#cleanupExpiredBtn')?.addEventListener('click', cleanupExpiredData);
|
||||
}
|
||||
|
||||
function openPiiRuleModal(ruleId = '') {
|
||||
const editingRule = state.piiRules.find(rule => rule.id === ruleId);
|
||||
const modal = createModal({
|
||||
title: editingRule ? '编辑脱敏规则' : '新增脱敏规则',
|
||||
content: `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">规则名称<span class="required">*</span></label>
|
||||
<input type="text" class="form-control" id="piiRuleName" value="${escapeHtml(editingRule?.name || '')}" placeholder="例如:邮箱">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">识别类型</label>
|
||||
<select class="form-control" id="piiRuleType">
|
||||
${[
|
||||
['person_name', '姓名'],
|
||||
['phone', '手机号'],
|
||||
['email', '邮箱'],
|
||||
['ip', 'IP 地址'],
|
||||
['id_card', '身份证号'],
|
||||
['address', '地址'],
|
||||
['bank_card', '银行卡号'],
|
||||
['custom', '自定义正则'],
|
||||
].map(([value, label]) => `<option value="${value}" ${editingRule?.type === value ? 'selected' : ''}>${label}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="customPatternGroup" style="${editingRule?.type === 'custom' ? '' : 'display:none'}">
|
||||
<label class="form-label">自定义正则</label>
|
||||
<input type="text" class="form-control" id="piiRulePattern" value="${escapeHtml(editingRule?.pattern || '')}" placeholder="例如:[A-Z]{2}\\d{6}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">脱敏展示<span class="required">*</span></label>
|
||||
<input type="text" class="form-control" id="piiRuleMask" value="${escapeHtml(editingRule?.mask || '[已脱敏]')}" placeholder="[已脱敏]">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">示例</label>
|
||||
<input type="text" class="form-control" id="piiRuleExample" value="${escapeHtml(editingRule?.example || '')}" placeholder="原始文本 → 脱敏文本">
|
||||
</div>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||||
<input type="checkbox" id="piiRuleEnabled" ${editingRule?.enabled !== false ? 'checked' : ''} style="accent-color:var(--primary)">
|
||||
<span>启用该规则</span>
|
||||
</label>`,
|
||||
onConfirm: async () => {
|
||||
const name = modal.overlay.querySelector('#piiRuleName')?.value.trim();
|
||||
const type = modal.overlay.querySelector('#piiRuleType')?.value || 'custom';
|
||||
const pattern = modal.overlay.querySelector('#piiRulePattern')?.value.trim();
|
||||
const mask = modal.overlay.querySelector('#piiRuleMask')?.value.trim();
|
||||
const example = modal.overlay.querySelector('#piiRuleExample')?.value.trim();
|
||||
const enabled = modal.overlay.querySelector('#piiRuleEnabled')?.checked;
|
||||
if (!name || !mask) {
|
||||
Toast.warning('请填写规则名称和脱敏展示');
|
||||
return false;
|
||||
}
|
||||
if (type === 'custom' && !pattern) {
|
||||
Toast.warning('自定义规则需要填写正则表达式');
|
||||
return false;
|
||||
}
|
||||
if (type === 'custom') {
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
} catch (error) {
|
||||
Toast.warning('自定义正则格式不正确');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingRule) {
|
||||
Object.assign(editingRule, { name, type, pattern, mask, example: example || `${name} → ${mask}`, enabled });
|
||||
await callPrivacyApi('updateRule', editingRule.id, editingRule);
|
||||
Toast.success('脱敏规则已更新');
|
||||
} else {
|
||||
const newRule = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name,
|
||||
type,
|
||||
pattern,
|
||||
mask,
|
||||
example: example || `${name} → ${mask}`,
|
||||
enabled,
|
||||
system: false,
|
||||
};
|
||||
state.piiRules.push(newRule);
|
||||
await callPrivacyApi('createRule', newRule);
|
||||
Toast.success('脱敏规则已创建');
|
||||
}
|
||||
renderPage();
|
||||
},
|
||||
confirmText: editingRule ? '保存' : '创建',
|
||||
});
|
||||
|
||||
modal.overlay.querySelector('#piiRuleType')?.addEventListener('change', event => {
|
||||
const group = modal.overlay.querySelector('#customPatternGroup');
|
||||
if (group) group.style.display = event.target.value === 'custom' ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
window.editPiiRule = function(ruleId) {
|
||||
openPiiRuleModal(ruleId);
|
||||
};
|
||||
|
||||
window.deletePiiRule = function(ruleId) {
|
||||
const rule = state.piiRules.find(item => item.id === ruleId);
|
||||
if (!rule) return;
|
||||
createModal({
|
||||
title: '创建 API Key',
|
||||
title: '删除脱敏规则',
|
||||
content: `
|
||||
<div style="text-align:center;padding:18px 0">
|
||||
<i data-lucide="alert-triangle" style="width:44px;height:44px;color:var(--warning);margin-bottom:12px"></i>
|
||||
<p style="font-size:15px;font-weight:600;margin-bottom:8px">确认删除规则「${escapeHtml(rule.name)}」?</p>
|
||||
<p style="font-size:13px;color:var(--text-muted)">删除后该类型信息将不再被此规则脱敏。</p>
|
||||
</div>`,
|
||||
onConfirm: async () => {
|
||||
state.piiRules = state.piiRules.filter(item => item.id !== rule.id);
|
||||
await callPrivacyApi('deleteRule', rule.id);
|
||||
Toast.success('脱敏规则已删除');
|
||||
renderPage();
|
||||
},
|
||||
confirmText: '确认删除',
|
||||
});
|
||||
lucide.createIcons();
|
||||
};
|
||||
|
||||
function openPiiTestModal() {
|
||||
const sample = '张三说他的手机号是13812348888,家庭住址是北京市朝阳区星河小区,登录 IP 是 192.168.1.10。';
|
||||
const modal = createModal({
|
||||
title: '测试 PII 脱敏',
|
||||
size: 'modal-lg',
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Key 名称<span class="required">*</span></label>
|
||||
<input type="text" class="form-control" placeholder="例:Backend Service Key">
|
||||
<label class="form-label">测试文本</label>
|
||||
<textarea class="form-control" rows="5" id="piiTestInput">${escapeHtml(sample)}</textarea>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" id="runPiiMaskBtn" style="margin-bottom:12px"><i data-lucide="scan-text"></i> 生成脱敏预览</button>
|
||||
<div>
|
||||
<label class="form-label">脱敏结果</label>
|
||||
<div id="piiTestOutput" style="min-height:92px;padding:12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:#f7f8fc;font-size:13px;line-height:1.7"></div>
|
||||
</div>`,
|
||||
onConfirm: () => true,
|
||||
confirmText: '关闭',
|
||||
showCancel: false,
|
||||
});
|
||||
const run = () => {
|
||||
const input = modal.overlay.querySelector('#piiTestInput')?.value || '';
|
||||
const output = modal.overlay.querySelector('#piiTestOutput');
|
||||
if (output) output.textContent = maskText(input);
|
||||
};
|
||||
modal.overlay.querySelector('#runPiiMaskBtn')?.addEventListener('click', run);
|
||||
run();
|
||||
lucide.createIcons({ elements: [modal.overlay] });
|
||||
}
|
||||
|
||||
async function savePiiConfig() {
|
||||
await callPrivacyApi('updateConfig', {
|
||||
piiGlobalEnabled: state.piiGlobalEnabled,
|
||||
piiRules: state.piiRules,
|
||||
});
|
||||
Toast.success('PII 脱敏配置已保存');
|
||||
}
|
||||
|
||||
async function saveComplianceConfig() {
|
||||
await callPrivacyApi('updateConfig', {
|
||||
coppaItems: state.coppaItems,
|
||||
gdprItems: state.gdprItems,
|
||||
});
|
||||
Toast.success('合规配置已保存');
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function runComplianceCheck() {
|
||||
const disabled = [...state.coppaItems, ...state.gdprItems].filter(item => !item.enabled);
|
||||
createModal({
|
||||
title: '合规检查结果',
|
||||
content: disabled.length ? `
|
||||
<div style="font-size:14px;margin-bottom:12px">发现 ${disabled.length} 个待关注项:</div>
|
||||
<div style="display:grid;gap:8px">
|
||||
${disabled.map(item => `<div style="padding:10px 12px;background:#fffbe6;border:1px solid #ffe58f;border-radius:var(--radius-sm);font-size:13px">${escapeHtml(item.label)}</div>`).join('')}
|
||||
</div>
|
||||
` : `
|
||||
<div style="text-align:center;padding:24px 0">
|
||||
<i data-lucide="shield-check" style="width:46px;height:46px;color:var(--success);margin-bottom:12px"></i>
|
||||
<div style="font-weight:700;margin-bottom:6px">检查通过</div>
|
||||
<div style="font-size:13px;color:var(--text-muted)">COPPA / GDPR 控制项均已开启。</div>
|
||||
</div>
|
||||
`,
|
||||
onConfirm: () => true,
|
||||
confirmText: '关闭',
|
||||
showCancel: false,
|
||||
});
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function openDataRequestModal() {
|
||||
const modal = createModal({
|
||||
title: '新增数据主体请求',
|
||||
content: `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">请求人<span class="required">*</span></label>
|
||||
<input type="text" class="form-control" id="dsrSubject" placeholder="邮箱 / 用户 ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">请求类型</label>
|
||||
<select class="form-control" id="dsrType">
|
||||
<option>访问数据</option>
|
||||
<option>删除数据</option>
|
||||
<option>更正数据</option>
|
||||
<option>导出数据</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">权限范围</label>
|
||||
<select class="form-control">
|
||||
<option>全部权限</option>
|
||||
<option>只读</option>
|
||||
<option>设备管理</option>
|
||||
<option>OTA 管理</option>
|
||||
<label class="form-label">所属公司</label>
|
||||
<select class="form-control" id="dsrCompany">
|
||||
${(window.companies || []).filter(company => company.id !== 'all').map(company => `<option>${escapeHtml(company.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>`,
|
||||
onConfirm: async () => {
|
||||
await API.settings.createApiKey({ name: '新 API Key' });
|
||||
Toast.success('API Key 创建成功,请妥善保存');
|
||||
const subject = modal.overlay.querySelector('#dsrSubject')?.value.trim();
|
||||
if (!subject) {
|
||||
Toast.warning('请填写请求人');
|
||||
return false;
|
||||
}
|
||||
const request = {
|
||||
id: `DSR-${new Date().getFullYear()}-${String(state.dataRequests.length + 1).padStart(3, '0')}`,
|
||||
subject,
|
||||
company: modal.overlay.querySelector('#dsrCompany')?.value || '默认公司',
|
||||
type: modal.overlay.querySelector('#dsrType')?.value || '访问数据',
|
||||
status: 'pending',
|
||||
createdAt: getNowText(),
|
||||
result: '',
|
||||
};
|
||||
state.dataRequests.unshift(request);
|
||||
await callPrivacyApi('getDataRequests', { mockCreated: request.id });
|
||||
Toast.success('数据主体请求已创建');
|
||||
renderPage();
|
||||
},
|
||||
confirmText: '创建',
|
||||
confirmText: '创建请求',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.processDataRequest = function(requestId) {
|
||||
const request = state.dataRequests.find(item => item.id === requestId);
|
||||
if (!request) return;
|
||||
const modal = createModal({
|
||||
title: `处理数据请求 - ${escapeHtml(request.id)}`,
|
||||
content: `
|
||||
<div style="padding:10px 12px;background:#f7f8fc;border-radius:var(--radius-sm);font-size:13px;line-height:1.7;margin-bottom:12px">
|
||||
<div>请求人:${escapeHtml(request.subject)}</div>
|
||||
<div>请求类型:${escapeHtml(request.type)}</div>
|
||||
<div>所属公司:${escapeHtml(request.company)}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">处理结果</label>
|
||||
<select class="form-control" id="dsrAction">
|
||||
<option value="processed">通过并处理</option>
|
||||
<option value="rejected">拒绝请求</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">处理说明<span class="required">*</span></label>
|
||||
<textarea class="form-control" rows="3" id="dsrResult" placeholder="说明处理结果或拒绝原因"></textarea>
|
||||
</div>`,
|
||||
onConfirm: async () => {
|
||||
const action = modal.overlay.querySelector('#dsrAction')?.value || 'processed';
|
||||
const result = modal.overlay.querySelector('#dsrResult')?.value.trim();
|
||||
if (!result) {
|
||||
Toast.warning('请填写处理说明');
|
||||
return false;
|
||||
}
|
||||
request.status = action;
|
||||
request.result = result;
|
||||
request.processedAt = getNowText();
|
||||
await callPrivacyApi('processDataRequest', request.id, action);
|
||||
Toast.success(action === 'processed' ? '数据请求已处理' : '数据请求已拒绝');
|
||||
renderPage();
|
||||
},
|
||||
confirmText: '确认处理',
|
||||
});
|
||||
};
|
||||
|
||||
window.viewDataRequestResult = function(requestId) {
|
||||
const request = state.dataRequests.find(item => item.id === requestId);
|
||||
if (!request) return;
|
||||
const meta = getRequestStatusMeta(request.status);
|
||||
createModal({
|
||||
title: `请求结果 - ${escapeHtml(request.id)}`,
|
||||
content: `
|
||||
<div style="display:grid;gap:10px;font-size:13px">
|
||||
<div><strong>请求人:</strong>${escapeHtml(request.subject)}</div>
|
||||
<div><strong>请求类型:</strong>${escapeHtml(request.type)}</div>
|
||||
<div><strong>状态:</strong><span class="badge ${meta.badge}">${meta.label}</span></div>
|
||||
<div><strong>处理说明:</strong>${escapeHtml(request.result || '暂无')}</div>
|
||||
${request.processedAt ? `<div><strong>处理时间:</strong>${escapeHtml(request.processedAt)}</div>` : ''}
|
||||
</div>`,
|
||||
onConfirm: () => true,
|
||||
confirmText: '关闭',
|
||||
showCancel: false,
|
||||
});
|
||||
};
|
||||
|
||||
async function saveRetentionPolicies() {
|
||||
const nextPolicies = state.retentionPolicies.map(policy => {
|
||||
const value = Number(container.querySelector(`.retention-input[data-policy-id="${policy.id}"]`)?.value);
|
||||
return { ...policy, period: value };
|
||||
});
|
||||
const invalid = nextPolicies.find(policy => !Number.isFinite(policy.period) || policy.period < 1 || policy.period > 3650);
|
||||
if (invalid) {
|
||||
Toast.warning(`${invalid.type} 的保留周期需在 1 到 3650 天之间`);
|
||||
return;
|
||||
}
|
||||
state.retentionPolicies = nextPolicies;
|
||||
await Promise.all(nextPolicies.map(policy => callPrivacyApi('updateRetentionPolicy', policy.id, policy)));
|
||||
Toast.success('数据保留策略已保存');
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function resetRetentionPolicies() {
|
||||
createModal({
|
||||
title: '恢复默认保留策略',
|
||||
content: '<p style="font-size:14px">确认将所有数据保留周期恢复为默认值?</p>',
|
||||
onConfirm: () => {
|
||||
state.retentionPolicies = clonePrivacyState(DEFAULT_PRIVACY_STATE.retentionPolicies);
|
||||
Toast.success('数据保留策略已恢复默认');
|
||||
renderPage();
|
||||
},
|
||||
confirmText: '确认恢复',
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupExpiredData() {
|
||||
createModal({
|
||||
title: '清理过期数据',
|
||||
content: `
|
||||
<div style="text-align:center;padding:18px 0">
|
||||
<i data-lucide="trash-2" style="width:44px;height:44px;color:var(--warning);margin-bottom:12px"></i>
|
||||
<p style="font-size:15px;font-weight:600;margin-bottom:8px">确认执行过期数据清理?</p>
|
||||
<p style="font-size:13px;color:var(--text-muted)">前端将模拟提交清理任务,真实删除由后端异步任务完成。</p>
|
||||
</div>`,
|
||||
onConfirm: async () => {
|
||||
state.lastCleanup = getNowText();
|
||||
await callPrivacyApi('updateConfig', { cleanupExpired: true, at: state.lastCleanup });
|
||||
Toast.success('过期数据清理任务已提交');
|
||||
renderPage();
|
||||
},
|
||||
confirmText: '提交清理',
|
||||
});
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
renderPage();
|
||||
});
|
||||
|
||||
1050
scripts/rbac.js
1050
scripts/rbac.js
File diff suppressed because it is too large
Load Diff
1068
scripts/settings.js
1068
scripts/settings.js
File diff suppressed because it is too large
Load Diff
3827
styles/main.css
3827
styles/main.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user