Files
opsxsql/frontend/src/pages/PlatformConfigPage.tsx
T
logan 557d88d3ed feat: 数据脱敏体系重构 — 全局设置+masked_columns+前端去硬编码
后端:
- applyDataMasking 新增 masked_columns 返回字段,告诉前端哪些列被脱敏
- seedDefaultSettings 添加 data_masking 分组(启用开关+敏感字段列表)
- 脱敏逻辑: 全局敏感字段 + 实例级 mask_fields 合并,开关关闭则跳过

前端:
- QueriesPage 去掉硬编码 sensitiveKeywords 和 maskData()
- 改用后端返回的 masked_columns 显示'敏'标签
- 脱敏值由后端统一处理(替换为*)
- PlatformConfigPage 数据脱敏分组独立显示
- EditInstanceDialog 脱敏字段简化为逗号分隔输入

验证:
- 设置页开关可正常开关/保存
- 查询 ghost_test: email列被脱敏为*,列头显示'敏'标签
- 非敏感列(name, id, status)正常显示
2026-05-28 23:26:10 +08:00

278 lines
10 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import api from '../api/request';
/* ── 类型 ── */
interface SettingItem {
key: string;
value: string;
value_type: string;
group: string;
label: string;
description: string | null;
default_value: string | null;
sort_order: number;
}
type SettingsMap = Record<string, SettingItem[]>;
/* ── 清理操作子组件 ── */
function CleanupActions() {
const [preview, setPreview] = useState<Record<string, number> | null>(null);
const [loading, setLoading] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [result, setResult] = useState<string>('');
const handlePreview = async () => {
setLoading(true);
setResult('');
try {
const res = await api.get('/settings/cleanup/preview') as Record<string, number>;
setPreview(res);
} catch {
setResult('预览失败');
} finally {
setLoading(false);
}
};
const handleCleanup = async () => {
setCleaning(true);
setResult('');
try {
const res = await api.post('/settings/cleanup/run') as { message: string; stats: Record<string, number> };
const total = Object.values(res.stats).reduce((a, b) => a + b, 0);
setResult(`清理完成,共删除 ${total} 条记录`);
setPreview(null);
} catch {
setResult('清理失败');
} finally {
setCleaning(false);
}
};
return (
<div className="px-5 pb-4 pt-2 space-y-3">
<div className="flex items-center gap-3">
<button onClick={handlePreview} disabled={loading}
className="px-4 py-1.5 rounded-lg text-sm bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors">
{loading ? '查询中...' : '预览待清理数量'}
</button>
{preview && (
<button onClick={handleCleanup} disabled={cleaning}
className="px-4 py-1.5 rounded-lg text-sm bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50 transition-colors">
{cleaning ? '清理中...' : '立即清理'}
</button>
)}
</div>
{preview && (
<div className="text-xs text-[var(--dg-text-muted)] space-y-1">
{Object.entries(preview).map(([k, v]) => (
<div key={k}>{k}: <span className="text-[var(--dg-text-primary)] font-medium">{v}</span> 条</div>
))}
</div>
)}
{result && (
<div className="text-xs text-emerald-500">{result}</div>
)}
</div>
);
}
/* ── 分组元信息 ── */
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT' },
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略' },
data_masking: { label: '数据脱敏', icon: '🛡️', desc: '控制查询结果中的敏感字段脱敏' },
cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史保留天数' },
executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时' },
};
export default function PlatformConfigPage() {
const [settings, setSettings] = useState<SettingsMap>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [changed, setChanged] = useState<Record<string, string>>({});
const [toast, setToast] = useState('');
const fetchSettings = useCallback(async () => {
try {
const res = await api.get('/settings');
setSettings(res as SettingsMap);
} catch {
setToast('加载设置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
const handleChange = (key: string, value: string) => {
setChanged(prev => ({ ...prev, [key]: value }));
};
const handleSave = async () => {
if (Object.keys(changed).length === 0) return;
setSaving(true);
try {
const items = Object.entries(changed).map(([key, value]) => ({ key, value }));
await api.put('/settings', items);
setChanged({});
setToast('保存成功');
await fetchSettings();
} catch (e: any) {
setToast(e?.response?.data?.detail || '保存失败');
} finally {
setSaving(false);
setTimeout(() => setToast(''), 3000);
}
};
const handleReset = (key: string, defaultValue: string | null) => {
if (defaultValue) handleChange(key, defaultValue);
};
const displayValue = (item: SettingItem) => changed[item.key] ?? item.value;
if (loading) {
return <div className="text-center py-20 text-[var(--dg-text-muted)]">加载中...</div>;
}
const groupKeys = Object.keys(settings);
const hasChanges = Object.keys(changed).length > 0;
return (
<div className="space-y-6">
{/* Toast */}
{toast && (
<div className={`fixed top-6 right-6 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg ${
toast === '保存成功'
? 'bg-emerald-500/90 text-white'
: 'bg-red-500/90 text-white'
}`}>
{toast}
</div>
)}
{/* 各分组 */}
{groupKeys.map(group => {
const meta = GROUP_META[group] || { label: group, icon: '⚙️', desc: '' };
const items = settings[group] || [];
return (
<div key={group} className="bg-[var(--dg-bg-secondary)] rounded-xl overflow-hidden">
{/* 分组头 */}
<div className="px-5 py-3 flex items-center gap-3">
<span className="text-lg">{meta.icon}</span>
<div>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">{meta.label}</h3>
<p className="text-xs text-[var(--dg-text-muted)]">{meta.desc}</p>
</div>
</div>
{/* 设置项列表 */}
<div className="px-5 pb-4 space-y-3">
{items.map(item => {
const val = displayValue(item);
const isChanged = changed[item.key] !== undefined;
return (
<div key={item.key} className="flex items-center justify-between gap-4 py-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--dg-text-primary)]">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{item.description}</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{/* 重置按钮 */}
{isChanged && item.default_value && (
<button
onClick={() => handleReset(item.key, item.default_value)}
className="text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]"
title="恢复默认值"
>
↩
</button>
)}
{/* 布尔 → 开关 */}
{item.value_type === 'boolean' && (
<button
onClick={() => handleChange(item.key, val === 'true' ? 'false' : 'true')}
className={`relative w-10 h-[22px] rounded-full transition-colors ${
val === 'true' ? 'bg-emerald-500' : 'bg-[var(--dg-bg-tertiary)]'
}`}
>
<span className={`absolute top-[2px] w-[18px] h-[18px] bg-white rounded-full shadow transition-transform ${
val === 'true' ? 'translate-x-[20px]' : 'translate-x-[2px]'
}`} />
</button>
)}
{/* 整数 → 数字输入 */}
{item.value_type === 'integer' && (
<input
type="number"
value={parseInt(val) || 0}
onChange={e => handleChange(item.key, e.target.value)}
className={`w-24 px-2 py-1 rounded-lg text-sm text-right bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] outline-none ${
isChanged ? 'ring-1 ring-emerald-500/50' : ''
}`}
/>
)}
{/* 字符串 → 文本 */}
{item.value_type === 'string' && (
<input
type="text"
value={val}
onChange={e => handleChange(item.key, e.target.value)}
className={`w-48 px-2 py-1 rounded-lg text-sm bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] outline-none ${
isChanged ? 'ring-1 ring-emerald-500/50' : ''
}`}
/>
)}
{/* 变更指示 */}
{isChanged && (
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
)}
</div>
</div>
);
})}
</div>
{/* 日志清理分组专属操作区 */}
{group === 'cleanup' && <CleanupActions />}
</div>
);
})}
{/* 保存按钮 */}
{hasChanges && (
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-600 text-white disabled:opacity-50 transition-colors"
>
{saving ? '保存中...' : '保存更改'}
</button>
<button
onClick={() => setChanged({})}
className="px-4 py-2 rounded-lg text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]"
>
取消
</button>
<span className="text-xs text-[var(--dg-text-muted)]">
已修改 {Object.keys(changed).length} 项
</span>
</div>
)}
</div>
);
}