forked from EduCraft/curriculum-project-hub
feat(filelib): 操作日志模块——防篡改哈希链、组合查询与 CSV 导出
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
@@ -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: "系统",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user