686 lines
33 KiB
JavaScript
686 lines
33 KiB
JavaScript
/**
|
||
* 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, '<')
|
||
.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 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();
|
||
});
|