diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c506ec..3d1d645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 变更日志 +## 2026-06-24 + +- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 6 个表 8 个列 + +## 2026-06-23 + +- [新增] 审计日志详情可视化:字段变更用表格对比(旧值/新值),新建/删除用卡片,登录信息用键值对 +- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈 +- [优化] 审计日志列表详情列:显示操作摘要而非"-",支持 message/keys/username 等格式 +- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行 +- [修复] 导出文件名使用北京时间(修复凌晨时区偏移) + ## 2026-05-18 - [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航 diff --git a/CLAUDE.md b/CLAUDE.md index e9c12b9..caa06cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,9 +240,10 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets ipAddress: getClientIP(request) }) ``` -- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。两处必须遵守: +- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。必须遵守: 1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` - 2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。 + 2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。 + 3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`,INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`。 --- diff --git a/src/app/(app)/settings/audit-logs/page.tsx b/src/app/(app)/settings/audit-logs/page.tsx index 22cb557..ffb9f27 100644 --- a/src/app/(app)/settings/audit-logs/page.tsx +++ b/src/app/(app)/settings/audit-logs/page.tsx @@ -53,6 +53,14 @@ export default function AuditLogsPage() { const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') + // 保留天数设置 + const [retentionSaved, setRetentionSaved] = useState(180) + const [retentionInput, setRetentionInput] = useState('180') + const [retentionSaving, setRetentionSaving] = useState(false) + const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false) + const [retentionError, setRetentionError] = useState(false) + const retentionDirty = retentionInput !== String(retentionSaved) + // 详情 Modal const [detailLog, setDetailLog] = useState(null) @@ -79,6 +87,49 @@ export default function AuditLogsPage() { useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo]) + async function fetchRetentionDays() { + try { + const res = await fetch('/api/audit-logs/cleanup') + if (res.ok) { + const data = await res.json() + setRetentionSaved(data.days) + setRetentionInput(String(data.days)) + } + } catch { /* ignore */ } + } + + async function saveRetentionDays() { + const days = parseInt(retentionInput, 10) + if (isNaN(days) || days < 30 || days > 365) { + setRetentionError(true) + setTimeout(() => setRetentionError(false), 2000) + return + } + setRetentionSaving(true) + setRetentionError(false) + try { + const res = await fetch('/api/audit-logs/cleanup', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days }) + }) + if (res.ok) { + setRetentionSaved(days) + setRetentionSavedFeedback(true) + setTimeout(() => setRetentionSavedFeedback(false), 2000) + } else { + setRetentionError(true) + setTimeout(() => setRetentionError(false), 2000) + } + } catch { + setRetentionError(true) + setTimeout(() => setRetentionError(false), 2000) + } + setRetentionSaving(false) + } + + useEffect(() => { fetchRetentionDays() }, []) + function resetFilters() { setActionFilter('') setEntityFilter('') @@ -110,7 +161,9 @@ export default function AuditLogsPage() { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv` + const now = new Date() + const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + a.download = `audit-logs-${dateStr}.csv` a.click() URL.revokeObjectURL(url) } @@ -129,23 +182,200 @@ export default function AuditLogsPage() { if (details.export) return `导出 ${details.export.count} 条` if (details.batch_update) return `批量更新 ${details.batch_update.count} 条` if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新` + if (details.message) return details.message + if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '') + if (details.username) return `用户 ${details.username} 登录` + if (details.retention_days !== undefined) return `保留 ${details.retention_days} 天` + if (details.cleanup) return `清理 ${details.cleanup.deleted} 条` return '-' } catch { return '-' } } + // 字段名汉化映射 + const FIELD_LABELS: Record = { + name: '名称', status: '状态', type: '类型', category: '分类', + model: '型号', sn: '序列号', location: '位置', department: '部门', + ip: 'IP', mac: 'MAC', os: '操作系统', cpu: 'CPU', memory: '内存', + disk: '硬盘', purchase_date: '采购日期', warranty_date: '质保到期', + description: '描述', notes: '备注', email: '邮箱', role: '角色', + username: '用户名', permissions: '权限', count: '数量', + } + + function formatValue(val: unknown): string { + if (val === null || val === undefined) return '-' + if (typeof val === 'object') return JSON.stringify(val) + return String(val) + } + + function renderDetails(details: Record) { + // 字段变更 + if (details.changes && typeof details.changes === 'object') { + const changes = details.changes as Record + return ( +
+

📋 修改了以下字段:

+ + + + + + + + + + {Object.entries(changes).map(([key, val]) => ( + + + + + + ))} + +
字段旧值新值
{FIELD_LABELS[key] || key}{formatValue(val.from)}{formatValue(val.to)}
+
+ ) + } + + // 新建记录 + if (details.created && typeof details.created === 'object') { + const data = details.created as Record + return ( +
+

🟢 新建记录:

+
+ {Object.entries(data).map(([key, val]) => ( +
+ {FIELD_LABELS[key] || key}: + {formatValue(val)} +
+ ))} +
+
+ ) + } + + // 删除记录 + if (details.deleted && typeof details.deleted === 'object') { + const data = details.deleted as Record + return ( +
+

🔴 删除记录:

+
+ {Object.entries(data).map(([key, val]) => ( +
+ {FIELD_LABELS[key] || key}: + {formatValue(val)} +
+ ))} +
+
+ ) + } + + // 批量更新 + if (details.batch_update && typeof details.batch_update === 'object') { + const data = details.batch_update as Record + return ( +
+

📦 批量更新 {String(data.count)} 条记录

+ {data.ids ?

涉及 ID:{String(data.ids)}

: null} +
+ ) + } + + // 导入 + if (details.import && typeof details.import === 'object') { + const data = details.import as Record + return ( +
+

📥 导入操作

+
+ 新增:{String(data.created)} 条 + 更新:{String(data.updated)} 条 +
+
+ ) + } + + // 导出 + if (details.export && typeof details.export === 'object') { + const data = details.export as Record + return ( +
+

📤 导出 {String(data.count)} 条记录

+
+ ) + } + + // 登录信息 + if (details.username && typeof details.username === 'string') { + return ( +
+

👤 登录信息:

+
+
+ 用户名: + {details.username} +
+ {details.role && ( +
+ 角色: + {String(details.role)} +
+ )} + {details.displayName && ( +
+ 显示名: + {String(details.displayName)} +
+ )} +
+
+ ) + } + + // 保留天数设置 + if (details.retention_days !== undefined) { + return ( +
+

⚙️ 保留天数设置:

+

日志保留期限调整为 {String(details.retention_days)}

+
+ ) + } + + // 清理操作 + if (details.cleanup) { + const data = details.cleanup as Record + return ( +
+

🧹 清理操作:

+

已清理 {String(data.deleted)} 条过期日志

+
+ ) + } + + // 兜底:格式化 JSON + return ( +
+        {JSON.stringify(details, null, 2)}
+      
+ ) + } + const columns: Column[] = [ { key: 'created_at', title: '时间', - width: '160px', - render: (r) => {r.created_at} + width: '170px', + render: (r) => {r.created_at} }, { key: 'username', title: '用户', - width: '120px', + width: '100px', render: (r) => ( -
+
{r.username || '-'} {r.api_key_id && [API]}
@@ -154,17 +384,17 @@ export default function AuditLogsPage() { { key: 'action', title: '操作', - width: '80px', + width: '70px', render: (r) => { const info = ACTION_LABELS[r.action] || { label: r.action, color: 'gray' as const } - return {info.label} + return {info.label} } }, { key: 'entity_type', title: '对象', width: '80px', - render: (r) => ENTITY_LABELS[r.entity_type] || r.entity_type + render: (r) => {ENTITY_LABELS[r.entity_type] || r.entity_type} }, { key: 'details', @@ -191,13 +421,40 @@ export default function AuditLogsPage() {

审计日志

-

系统操作与事件记录,保留 180 天

+

系统操作与事件记录

+ {/* 保留天数设置 */} +
+ 保留天数: + setRetentionInput(e.target.value)} + className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900" + /> + (30-365 天,默认 180 天) + {retentionSavedFeedback ? ( + ✓ 已保存 + ) : retentionError ? ( + ✗ 保存失败 + ) : retentionDirty ? ( + + ) : null} +
+ {/* 过滤器 */}