feat(filelib): 操作日志模块——防篡改哈希链、组合查询与 CSV 导出

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:54:45 +08:00
parent c96ea60482
commit 26523d1b54
29 changed files with 2881 additions and 209 deletions
+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
/**
* 可搜索的单个用户选择器(combobox)。
*
* 语义:对外只暴露一个 `userId`(选中 = 非空,未选 = 空串)。调用方拿到的
* 始终是精确 id —— 后端 `actorUserId` 等筛选口是精确匹配,不接受模糊串。
*
* 权限降级:`/database/api/users/search` 仅网站管理员可调
* (memberGroupService.ts `searchUsers` 的 `requireAdmin`),持 MANAGE 的
* 老师拿 403。此时不残废成"搜不到",而是回落为手输 id —— 与 GrantsPanel
* 既有做法一致。
*
* 已存在两处内联实现(GrantsPanel.svelte 的授权主体选择器、
* GroupAdmin.svelte 的加成员搜索)。本组件是它们的沉淀版,但**没有**改动
* 那两处 —— 后续可分别迁过来,迁移时注意 GrantsPanel 还要选 GROUP 主体,
* 那部分不在本组件职责内。
*/
import { api } from "./api.js";
import type { UserSearchResult } from "./types.js";
import Avatar from "./Avatar.svelte";
let {
userId = $bindable(""),
placeholder = "搜索姓名 / openId / userId",
id,
}: {
/** 选中的 userId;未选时为空串。双向绑定。 */
userId?: string;
placeholder?: string;
/** 传入时套到 input 上,便于外部 <label for>。 */
id?: string;
} = $props();
let query = $state("");
let options = $state<readonly UserSearchResult[] | null>(null);
let selected = $state<UserSearchResult | null>(null);
let searchUnavailable = $state(false);
let open = $state(false);
/** 键盘高亮项下标;-1 = 无。 */
let active = $state(-1);
// seq 防乱序:慢响应不覆盖新查询的结果。
let searchSeq = 0;
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const label = (u: UserSearchResult): string => (u.displayName === "" ? u.userId : u.displayName);
async function search(q: string): Promise<void> {
const seq = ++searchSeq;
try {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(q)}`,
);
if (seq !== searchSeq) return;
options = r.users;
active = -1;
searchUnavailable = false;
} catch {
if (seq !== searchSeq) return;
// 无搜索权限(403)——回落手输 id。
options = null;
searchUnavailable = true;
}
}
function onInput(): void {
// 输入即视为放弃已选:输入框内容与 userId 不再对应,不能让旧选中残留。
selected = null;
userId = "";
open = true;
if (searchTimer !== undefined) clearTimeout(searchTimer);
const q = query.trim();
searchTimer = setTimeout(() => void search(q), 250);
}
function onFocus(): void {
open = true;
// 首次聚焦拉一次默认候选(后端 q 为空返回前 20 个)。
if (options === null && !searchUnavailable) void search(query.trim());
}
function pick(u: UserSearchResult): void {
selected = u;
userId = u.userId;
query = label(u);
options = null;
active = -1;
open = false;
}
function clear(): void {
selected = null;
userId = "";
query = "";
options = null;
active = -1;
open = false;
}
function onKeydown(e: KeyboardEvent): void {
const list = options;
if (e.key === "Escape") {
open = false;
return;
}
if (list === null || list.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
open = true;
active = (active + 1) % list.length;
} else if (e.key === "ArrowUp") {
e.preventDefault();
open = true;
active = active <= 0 ? list.length - 1 : active - 1;
} else if (e.key === "Enter" && open && active >= 0) {
// 只在有高亮项时拦 Enter —— 否则要让 Enter 冒泡去触发外层的「查询」。
e.preventDefault();
const hit = list[active];
if (hit !== undefined) pick(hit);
}
}
</script>
{#if searchUnavailable}
<!-- 无 users/search 权限:直接手输 id(与 GrantsPanel 的降级一致)。 -->
<input
{id}
class="input font-mono"
placeholder="无搜索权限,请直接填写 userId"
bind:value={userId}
/>
{:else}
<div class="relative">
<input
{id}
class="input"
class:pr-7={userId !== ""}
{placeholder}
autocomplete="off"
role="combobox"
aria-expanded={open}
aria-controls="user-picker-list"
bind:value={query}
oninput={onInput}
onfocus={onFocus}
onkeydown={onKeydown}
/>
{#if userId !== ""}
<button
type="button"
class="absolute top-1/2 right-2 -translate-y-1/2 text-[13px] leading-none text-ink-3 hover:text-ink"
title="清除"
aria-label="清除已选操作人"
onclick={clear}>×</button
>
{/if}
{#if open && options !== null && options.length > 0}
<!-- 点击外部关闭:全屏透明遮罩接管点击,与 ContextMenu.svelte 同做法。 -->
<button
type="button"
class="fixed inset-0 z-10 cursor-default"
tabindex="-1"
aria-hidden="true"
onclick={() => (open = false)}
></button>
<div
id="user-picker-list"
role="listbox"
class="absolute top-full right-0 left-0 z-20 mt-1 max-h-[220px] overflow-y-auto rounded-lg border border-line-soft bg-panel shadow-[0_8px_28px_rgba(26,26,24,.12)]"
>
{#each options as u, i (u.userId)}
<button
type="button"
role="option"
aria-selected={i === active}
class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-hover"
class:bg-selected={i === active}
onclick={() => pick(u)}
>
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={22} />
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] text-ink">{label(u)}</span>
<span class="block truncate font-mono text-[11px] text-ink-3">{u.userId}</span>
</span>
</button>
{/each}
</div>
{:else if open && options !== null}
<div
class="absolute top-full right-0 left-0 z-20 mt-1 rounded-lg border border-line-soft bg-panel px-3 py-2 text-[12px] text-ink-3 shadow-[0_8px_28px_rgba(26,26,24,.12)]"
>
没有匹配的用户
</div>
{/if}
</div>
{#if selected !== null}
<span class="mt-1 block truncate font-mono text-[11px] text-ink-3" title={selected.userId}>
已选 {selected.userId}
</span>
{/if}
{/if}
+54 -1
View File
@@ -1,6 +1,6 @@
/** 展示层文案(与 API 枚举值解耦;传参仍用英文枚举)。 */
import type { Role } from "./types.js";
import type { AuditObjectType, Role } from "./types.js";
/** 文件库权限级(契约 8.1 MANAGE>EDIT>VIEW)的中文展示名。 */
export const ROLE_LABEL: Record<Role, string> = {
@@ -8,3 +8,56 @@ export const ROLE_LABEL: Record<Role, string> = {
EDIT: "可编辑",
MANAGE: "可管理",
};
/**
* 审计操作类型的中文展示名。键是后端 AUDIT_ACTIONS 的值 ——
* 缺键时 UI 回落显示原始动作串,不会因为后端加了新动作而崩。
*/
export const AUDIT_ACTION_LABEL: Record<string, string> = {
"folder.create": "创建文件夹",
"folder.rename": "重命名文件夹",
"folder.move": "移动文件夹",
"folder.delete": "删除文件夹",
"folder.restore": "恢复文件夹",
"project.create": "创建项目",
"project.rename": "重命名项目",
"project.move": "移动项目",
"project.delete": "删除项目",
"project.restore": "恢复项目",
"node.purge": "彻底删除",
"permission.grant": "授予权限",
"permission.update": "修改权限",
"permission.revoke": "收回权限",
"project.independent_permission.enable": "开启独立权限",
"project.independent_permission.disable": "关闭独立权限",
"project.independent_permission.change": "变更独立权限",
"file.upload": "上传文件",
"file.rename": "重命名文件",
"file.delete": "删除文件",
"file.commit": "提交文件修改",
"file.conflict_detected": "检测到版本冲突",
"export.run": "导出",
"admin.force_adjust": "管理员强制调整权限",
"group.create": "创建 Group",
"group.update": "修改 Group",
"group.delete": "删除 Group",
"group.restore": "恢复 Group",
"group.member_add": "添加 Group 成员",
"group.member_remove": "移除 Group 成员",
"group.reparent": "变更 Group 嵌套关系",
"audit.retention_archive": "审计日志归档",
};
export function auditActionLabel(action: string): string {
return AUDIT_ACTION_LABEL[action] ?? action;
}
export const AUDIT_OBJECT_TYPE_LABEL: Record<AuditObjectType, string> = {
FOLDER: "文件夹",
PROJECT: "项目",
FILE: "文件",
GRANT: "权限",
EXPORT_JOB: "导出任务",
GROUP: "Group",
SYSTEM: "系统",
};
+72
View File
@@ -162,9 +162,81 @@ export interface DashboardStats { readonly folders: number;
readonly label: string;
/** ISO 串;后端 JSON 序列化后不再是 Date。 */
readonly when: string;
readonly result: AuditResult;
}>;
}
/* -------------------------------------------------- 审计日志(/database/api/audit/*) */
export type AuditResult = "SUCCESS" | "FAILURE";
export type AuditObjectType =
| "FOLDER" | "PROJECT" | "FILE" | "GRANT" | "EXPORT_JOB" | "GROUP" | "SYSTEM";
/** 单条审计日志。字段与需求「日志字段」逐项对应。 */
export interface AuditLogEntry {
readonly id: string;
/** org 内单调序号(哈希链顺序);BigInt 出库转字符串。 */
readonly seq: string;
readonly occurredAt: string;
readonly action: string;
readonly result: AuditResult;
readonly failureReason: string | null;
readonly actorUserId: string;
/** 操作时的姓名快照,不随用户改名而变。 */
readonly actorName: string;
readonly actorIsAdmin: boolean;
readonly objectType: AuditObjectType;
readonly objectId: string;
readonly objectName: string;
readonly objectPath: string;
readonly beforeValue: unknown;
readonly afterValue: unknown;
readonly context: unknown;
readonly clientIp: string | null;
readonly userAgent: string | null;
readonly archivedAt: string | null;
}
export interface AuditLogPage {
readonly total: number;
readonly offset: number;
readonly limit: number;
readonly entries: readonly AuditLogEntry[];
}
/** 筛选器元数据(GET /database/api/audit/meta)。 */
export interface AuditMeta {
readonly actionGroups: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly actions: readonly string[];
}>;
readonly objectTypes: readonly AuditObjectType[];
readonly exportRowLimit: number;
/** all = 网站管理员看全系统;managed = 仅自己有 MANAGE 的范围。 */
readonly scope: "all" | "managed";
}
/** 哈希链校验结果(GET /database/api/audit/verify)。 */
export interface AuditVerifyResult {
readonly checked: number;
readonly ok: boolean;
readonly breaks: ReadonlyArray<{
readonly id: string;
readonly seq: string;
readonly occurredAt: string;
readonly reason: "hash_mismatch" | "prev_hash_mismatch" | "seq_gap";
}>;
}
/** 保留策略归档结果(POST /database/api/audit/archive)。 */
export interface AuditArchiveResult {
readonly archived: number;
readonly cutoff: string;
readonly retentionDays: number;
}
/** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */
export type OrgRole = "OWNER" | "ADMIN" | "MEMBER";
@@ -22,7 +22,7 @@
{ seg: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ seg: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ seg: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ seg: "search", label: "操作日志", icon: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6ZM14 2v6h6M8 13h8M8 17h8M8 9h2" },
{ seg: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
] as const;
@@ -1,4 +1,449 @@
<script lang="ts">
/**
* 审计日志查询(需求 5.4)。
*
* 可见性由后端裁剪,前端不做第二套判权:管理员看全系统,持 MANAGE 的用户
* 只看自己管辖的文件夹/项目子树,其余人拿到空结果。`meta.scope` 只用来
* 显示一行提示,不控制任何数据。
*
* 导出走 `logs.csv`,带上当前全部筛选条件 —— 「导出结果」的语义是
* 「导出你现在看到的这一组」,不是「导出全部」。
*/
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import { toastOk, toastErr } from "$lib/stores.js";
import { auditActionLabel, AUDIT_OBJECT_TYPE_LABEL } from "$lib/labels.js";
import Icon from "$lib/Icon.svelte";
import UserPicker from "$lib/UserPicker.svelte";
import type {
AuditArchiveResult,
AuditLogEntry,
AuditLogPage,
AuditMeta,
AuditObjectType,
AuditVerifyResult,
} from "$lib/types.js";
const PAGE_SIZE = 50;
let meta = $state<AuditMeta | null>(null);
let result = $state<AuditLogPage | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
// 筛选条件。datetime-local 的值是「本地时间无时区」串,提交前转 ISO。
let from = $state("");
let to = $state("");
let actorUserId = $state("");
// 「重置」要连带清掉选择器输入框里的姓名文本,而它是 UserPicker 的内部状态
// (对外只暴露 userId)。递增此 key 强制重挂载 —— 比为一次重置给组件加
// reset() 命令式出口更简单。
let actorPickerKey = $state(0);
let objectType = $state<AuditObjectType | "">("");
let objectId = $state("");
let objectPath = $state("");
let resultFilter = $state<"" | "SUCCESS" | "FAILURE">("");
let includeArchived = $state(false);
let selectedActions = $state<string[]>([]);
let offset = $state(0);
// 展开的行(看前后值 / 上下文详情)。
let expanded = $state<string | null>(null);
let verifyState = $state<AuditVerifyResult | null>(null);
let verifying = $state(false);
const isAdmin = $derived(meta?.scope === "all");
const totalPages = $derived(result === null ? 0 : Math.ceil(result.total / PAGE_SIZE));
const currentPage = $derived(Math.floor(offset / PAGE_SIZE) + 1);
/** 筛选条件 → 查询串。空值一律省略,后端按"未过滤"处理。 */
function buildParams(extra: Record<string, string> = {}): URLSearchParams {
const params = new URLSearchParams();
// datetime-local 没有时区,new Date() 按浏览器本地时区解释 —— 与用户
// 在输入框里看到的时间一致,这是想要的行为。
if (from !== "") params.set("from", new Date(from).toISOString());
if (to !== "") params.set("to", new Date(to).toISOString());
if (actorUserId.trim() !== "") params.set("actorUserId", actorUserId.trim());
if (objectType !== "") params.set("objectType", objectType);
if (objectId.trim() !== "") params.set("objectId", objectId.trim());
if (objectPath.trim() !== "") params.set("objectPath", objectPath.trim());
if (resultFilter !== "") params.set("result", resultFilter);
if (includeArchived) params.set("includeArchived", "true");
if (selectedActions.length > 0) params.set("actions", selectedActions.join(","));
for (const [k, v] of Object.entries(extra)) params.set(k, v);
return params;
}
async function load(): Promise<void> {
loading = true;
try {
const params = buildParams({ offset: String(offset), limit: String(PAGE_SIZE) });
result = await api<AuditLogPage>(`/database/api/audit/logs?${params.toString()}`);
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function search(): void {
offset = 0;
void load();
}
function reset(): void {
from = "";
to = "";
actorUserId = "";
actorPickerKey += 1;
objectType = "";
objectId = "";
objectPath = "";
resultFilter = "";
includeArchived = false;
selectedActions = [];
offset = 0;
void load();
}
function toggleAction(action: string): void {
selectedActions = selectedActions.includes(action)
? selectedActions.filter((a) => a !== action)
: [...selectedActions, action];
}
function toggleGroup(actions: readonly string[]): void {
const allOn = actions.every((a) => selectedActions.includes(a));
selectedActions = allOn
? selectedActions.filter((a) => !actions.includes(a))
: [...new Set([...selectedActions, ...actions])];
}
function page(delta: number): void {
const next = offset + delta * PAGE_SIZE;
if (next < 0 || (result !== null && next >= result.total)) return;
offset = next;
void load();
}
/** 导出:浏览器直接打开下载链接(带 Content-Disposition,不需要 fetch)。 */
function exportCsv(): void {
window.location.href = `/database/api/audit/logs.csv?${buildParams().toString()}`;
}
async function verify(): Promise<void> {
verifying = true;
try {
verifyState = await api<AuditVerifyResult>("/database/api/audit/verify");
if (verifyState.ok) toastOk(`哈希链完整,已校验 ${verifyState.checked} 条`);
else toastErr(`发现 ${verifyState.breaks.length} 处异常`);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
verifying = false;
}
}
async function archive(): Promise<void> {
if (!confirm("按保留策略归档超期日志?归档后默认不在查询结果中显示(仍可勾选「含已归档」查看),且不可撤销。")) return;
try {
const r = await api<AuditArchiveResult>("/database/api/audit/archive", { method: "POST" });
toastOk(`已归档 ${r.archived} 条(保留期 ${r.retentionDays} 天)`);
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
/** 精确到秒 + 时区(需求「操作时间精确到秒,建议含时区」)。 */
function formatTime(iso: string): string {
const d = new Date(iso);
const pad = (n: number): string => String(n).padStart(2, "0");
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const tz = `UTC${sign}${pad(Math.floor(Math.abs(offsetMin) / 60))}:${pad(Math.abs(offsetMin) % 60)}`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ${tz}`;
}
function formatJson(value: unknown): string {
if (value === null || value === undefined) return "—";
return JSON.stringify(value, null, 2);
}
function rowKey(entry: AuditLogEntry): string {
return entry.id;
}
onMount(async () => {
try {
meta = await api<AuditMeta>("/database/api/audit/meta");
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
await load();
});
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">查询</h1>
<p class="text-[12.5px] text-ink-3">查询功能建设中</p>
<div class="mb-4 flex items-start justify-between gap-3">
<div>
<h1 class="mb-1 text-lg font-semibold text-ink">操作日志</h1>
<p class="text-[12.5px] text-ink-3">
{#if meta === null}
加载中…
{:else if meta.scope === "all"}
你是网站管理员,可查询全系统日志。
{:else}
你可查询自己拥有「可管理」权限的文件夹/项目及其子树相关日志。
{/if}
</p>
</div>
{#if isAdmin}
<div class="flex shrink-0 gap-2">
<button class="btn disabled:opacity-50" onclick={verify} disabled={verifying}>
{verifying ? "校验中…" : "校验防篡改"}
</button>
<button class="btn" onclick={archive}>保留策略归档</button>
</div>
{/if}
</div>
{#if verifyState !== null}
<div class="panel mb-3.5">
<div class="section-title mb-1.5">防篡改校验</div>
{#if verifyState.ok}
<p class="text-[12.5px] text-ink-3">
已校验 {verifyState.checked} 条,哈希链连续完整,未发现改写或缺失。
</p>
{:else}
<p class="mb-2 text-[12.5px] text-danger">
已校验 {verifyState.checked} 条,发现 {verifyState.breaks.length} 处异常:
</p>
<ul class="text-[12px] text-ink-3">
{#each verifyState.breaks.slice(0, 10) as b (b.id)}
<li class="font-mono">
seq {b.seq} · {formatTime(b.occurredAt)} ·
{b.reason === "hash_mismatch" ? "内容被改写" : b.reason === "seq_gap" ? "记录缺失" : "链接断裂"}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<div class="panel mb-3.5">
<div class="section-title mb-2.5">筛选条件</div>
<div class="mb-3 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
<label class="block">
<span class="form-label">起始时间</span>
<input class="input" type="datetime-local" bind:value={from} />
</label>
<label class="block">
<span class="form-label">结束时间</span>
<input class="input" type="datetime-local" bind:value={to} />
</label>
<div class="block">
<label class="form-label" for="audit-actor">操作人</label>
{#key actorPickerKey}
<UserPicker id="audit-actor" bind:userId={actorUserId} />
{/key}
</div>
<label class="block">
<span class="form-label">操作结果</span>
<select class="select" bind:value={resultFilter}>
<option value="">全部</option>
<option value="SUCCESS">成功</option>
<option value="FAILURE">失败</option>
</select>
</label>
<label class="block">
<span class="form-label">对象类型</span>
<select class="select" bind:value={objectType}>
<option value="">全部</option>
{#each meta?.objectTypes ?? [] as t (t)}
<option value={t}>{AUDIT_OBJECT_TYPE_LABEL[t] ?? t}</option>
{/each}
</select>
</label>
<label class="block">
<span class="form-label">对象 ID</span>
<input class="input" placeholder="节点 / 组 / 任务 id" bind:value={objectId} />
</label>
<label class="block lg:col-span-2">
<span class="form-label">对象路径前缀</span>
<input class="input" placeholder="如 /rootId/childId(含整个子树)" bind:value={objectPath} />
</label>
</div>
{#if meta !== null}
<div class="mb-3">
<span class="form-label">操作类型{selectedActions.length > 0 ? `(已选 ${selectedActions.length})` : "(不选 = 全部)"}</span>
<div class="flex flex-col gap-2">
{#each meta.actionGroups as group (group.key)}
<div class="flex flex-wrap items-center gap-1.5">
<button
class="tag shrink-0 cursor-pointer hover:border-accent"
onclick={() => toggleGroup(group.actions)}
title="全选/全不选本组"
>
{group.label}
</button>
{#each group.actions as action (action)}
{@const on = selectedActions.includes(action)}
<button
class="tag cursor-pointer"
class:!border-accent={on}
class:!text-ink={on}
class:bg-selected={on}
onclick={() => toggleAction(action)}
>
{auditActionLabel(action)}
</button>
{/each}
</div>
{/each}
</div>
</div>
{/if}
<div class="flex flex-wrap items-center gap-2">
<button class="btn btn-primary disabled:opacity-50" onclick={search} disabled={loading}>
{loading ? "查询中…" : "查询"}
</button>
<button class="btn" onclick={reset}>重置</button>
<button class="btn" onclick={exportCsv} title="导出当前筛选结果为 CSV">
<Icon name="download" size={13} /> 导出 CSV
</button>
<label class="ml-1 flex cursor-pointer items-center gap-1.5 text-[12.5px] text-ink-3">
<input type="checkbox" bind:checked={includeArchived} />
含已归档
</label>
{#if meta !== null}
<span class="section-note ml-auto">导出上限 {meta.exportRowLimit.toLocaleString()}</span>
{/if}
</div>
</div>
<div class="panel">
<div class="mb-2.5 flex items-center justify-between gap-2">
<div class="section-title">
查询结果{result !== null ? `(共 ${result.total.toLocaleString()} )` : ""}
</div>
{#if result !== null && result.total > PAGE_SIZE}
<div class="flex items-center gap-2 text-[12.5px] text-ink-3">
<button class="btn btn-sm disabled:opacity-40" onclick={() => page(-1)} disabled={offset === 0 || loading}>上一页</button>
<span>{currentPage} / {totalPages}</span>
<button
class="btn btn-sm disabled:opacity-40"
onclick={() => page(1)}
disabled={offset + PAGE_SIZE >= result.total || loading}
>下一页</button>
</div>
{/if}
</div>
{#if error !== null}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if result === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if result.entries.length === 0}
<div class="quiet py-[18px] text-center">
{meta?.scope === "managed"
? "没有匹配的日志。你只能查询自己拥有「可管理」权限的范围。"
: "没有匹配的日志。"}
</div>
{:else}
<table class="list">
<thead>
<tr>
<th>操作时间</th>
<th>操作人</th>
<th>操作类型</th>
<th>操作对象</th>
<th>结果</th>
<th></th>
</tr>
</thead>
<tbody>
{#each result.entries as entry (rowKey(entry))}
<tr>
<td class="whitespace-nowrap font-mono text-[11.5px] text-ink-3">{formatTime(entry.occurredAt)}</td>
<td>
<span class="text-ink">{entry.actorName}</span>
{#if entry.actorIsAdmin}
<span class="tag ml-1">管理员</span>
{/if}
<div class="file-meta">{entry.actorUserId}</div>
</td>
<td class="text-ink">{auditActionLabel(entry.action)}</td>
<td>
<span class="text-ink">{entry.objectName}</span>
<div class="file-meta">
{AUDIT_OBJECT_TYPE_LABEL[entry.objectType] ?? entry.objectType} · {entry.objectId.slice(0, 8)}
</div>
</td>
<td class="whitespace-nowrap">
{#if entry.result === "SUCCESS"}
<span class="text-[12px] text-ink-3">成功</span>
{:else}
<span class="text-[12px] text-danger">失败</span>
{/if}
{#if entry.archivedAt !== null}
<span class="tag ml-1">已归档</span>
{/if}
</td>
<td class="text-right">
<button
class="btn btn-sm"
onclick={() => (expanded = expanded === entry.id ? null : entry.id)}
>
{expanded === entry.id ? "收起" : "详情"}
</button>
</td>
</tr>
{#if expanded === entry.id}
<tr>
<td colspan="6" class="!pt-0">
<div class="rounded-lg border border-line-soft bg-sidebar p-3 text-[12px]">
<div class="mb-2 grid grid-cols-1 gap-x-6 gap-y-1 md:grid-cols-2">
<div><span class="text-ink-3">日志 ID:</span> <span class="font-mono">{entry.id}</span></div>
<div><span class="text-ink-3">链序号:</span> <span class="font-mono">{entry.seq}</span></div>
<div class="md:col-span-2">
<span class="text-ink-3">对象路径:</span> <span class="font-mono break-all">{entry.objectPath}</span>
</div>
<div><span class="text-ink-3">客户端 IP:</span> <span class="font-mono">{entry.clientIp ?? "—"}</span></div>
<div class="truncate" title={entry.userAgent ?? ""}>
<span class="text-ink-3">User-Agent:</span> <span class="font-mono">{entry.userAgent ?? "—"}</span>
</div>
{#if entry.failureReason !== null}
<div class="md:col-span-2 text-danger">
<span class="text-ink-3">失败原因:</span> {entry.failureReason}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<div class="form-label">操作前值</div>
<pre class="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] text-ink-3">{formatJson(entry.beforeValue)}</pre>
</div>
<div>
<div class="form-label">操作后值</div>
<pre class="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] text-ink-3">{formatJson(entry.afterValue)}</pre>
</div>
<div>
<div class="form-label">附加上下文</div>
<pre class="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] text-ink-3">{formatJson(entry.context)}</pre>
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
</div>
</section>