Files
TalkingQ_Dashboard/scripts/privacy.js
“yuanmengy” 69785c214f first commit
2026-06-10 17:31:01 +08:00

686 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* TalkingQ Platform - Privacy (PII) Page
*/
registerPage('privacy', function(container) {
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 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>
<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>
<div class="card-title">PII 自动脱敏配置</div>
<div class="card-subtitle">自动识别并脱敏对话中的个人敏感信息</div>
</div>
<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" id="piiGlobalToggle" ${state.piiGlobalEnabled ? 'checked' : ''}>
<span class="toggle-track"></span>
</label>
</div>
</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>
</div>
`).join('')}
</div>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<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>
`;
}
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>
${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>
${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 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 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>
<button class="btn btn-outline btn-sm" id="addDataRequestBtn"><i data-lucide="plus"></i> 新增请求</button>
</div>
<div class="table-wrapper">
<table>
<thead><tr><th>请求 ID</th><th>请求人</th><th>公司</th><th>类型</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
${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>
`;
}
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>后端接口预留:<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>
`;
}
function bindEvents() {
container.querySelectorAll('#privacyTabs .tab-item').forEach(tab => {
tab.addEventListener('click', () => {
state.activeTab = tab.dataset.tab;
renderPage();
});
});
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: '删除脱敏规则',
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">测试文本</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" id="dsrCompany">
${(window.companies || []).filter(company => company.id !== 'all').map(company => `<option>${escapeHtml(company.name)}</option>`).join('')}
</select>
</div>`,
onConfirm: async () => {
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: '创建请求',
});
}
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();
});