后端: - applyDataMasking 新增 masked_columns 返回字段,告诉前端哪些列被脱敏 - seedDefaultSettings 添加 data_masking 分组(启用开关+敏感字段列表) - 脱敏逻辑: 全局敏感字段 + 实例级 mask_fields 合并,开关关闭则跳过 前端: - QueriesPage 去掉硬编码 sensitiveKeywords 和 maskData() - 改用后端返回的 masked_columns 显示'敏'标签 - 脱敏值由后端统一处理(替换为*) - PlatformConfigPage 数据脱敏分组独立显示 - EditInstanceDialog 脱敏字段简化为逗号分隔输入 验证: - 设置页开关可正常开关/保存 - 查询 ghost_test: email列被脱敏为*,列头显示'敏'标签 - 非敏感列(name, id, status)正常显示
278 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|