feat(web): 日志页——任务 / 定时与自动 / 登录 / 审计合一,实时追加与暂停,勾选后问智能体;计划并入「定时任务」页签,侧栏系统组只留日志、运行状态、设置

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:09:56 +08:00
1 parent 2ce1841628
commit 805ba33ddf
21 files changed
+1693 -846

No files matched your search

+460
View File
@@ -0,0 +1,460 @@
import { useQueryClient } from "@tanstack/react-query";
import type { LogEntry } from "@umt/contracts";
import { Pause, Play, RotateCw, ScrollText, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { agentKeys } from "~/api/agent.ts";
import { type LogParams, type LogStreamStatus, upsertEntries, useLogStream, useLogs } from "~/api/logs.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import {
LOG_FILTER_KEYS,
LOG_LEVEL,
LOG_LEVELS,
LOG_SINCE,
LOG_TYPE_LABEL,
LOG_TYPES,
type LogFilterKey,
type LogFilters,
logBadge,
logSubtitle,
logTitle,
objectText,
sinceToFrom,
TARGET_LABEL,
} from "~/components/logs/labels.ts";
import { AskAgentButton, LogDetailSheet } from "~/components/logs/log-detail.tsx";
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
import { useLiveTasks } from "~/components/tasks/live.ts";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
export const meta = () => [{ title: "日志 · UHD 媒体自动化控制台" }];
// 日志(界面第二轮 V 线):任务、定时与自动任务、登录、审计合成一份列表(GET /logs),实时追加(GET /logs/stream);
// 「定时任务」页签是原计划页(定时任务的开关、间隔、立即执行;运行记录回到日志按定时任务筛选)。
// URL:?type= &level= &since= &q= &objectType= &objectId= &system=1 筛选;?log=<id> / ?run=<引擎运行 id> 打开详情;
// ?tab=schedules[&job=] 定时任务页签。
type Tab = "logs" | "schedules";
/** 列表里的时间:今天只写时分秒,其余带月日 */
function shortTime(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
const today = new Date();
return d.toDateString() === today.toDateString() ? hms : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hms}`;
}
const STREAM_LABEL: Record<LogStreamStatus, string> = {
off: "已暂停",
connecting: "连接中…",
open: "实时",
reconnecting: "重连中…",
};
function StreamState({ status }: { status: LogStreamStatus }) {
return (
<span className="inline-flex items-center gap-1.5 text-meta text-muted-foreground" role="status">
<span
aria-hidden
className={cn(
"inline-block size-2 rounded-full",
status === "open" ? "animate-pulse bg-success" : status === "off" ? "bg-foreground/30" : "bg-warning",
)}
/>
{STREAM_LABEL[status]}
</span>
);
}
function LogsView({
params,
setParams,
}: {
params: URLSearchParams;
setParams: ReturnType<typeof useSearchParams>[1];
}) {
const qc = useQueryClient();
useLiveTasks();
const filters = Object.fromEntries(LOG_FILTER_KEYS.map((k) => [k, params.get(k) ?? ""])) as LogFilters;
const [keyword, setKeyword] = useState(filters.q);
const [live, setLive] = useState(true);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const anchor = useRef<string | null>(null);
const update = (fn: (p: URLSearchParams) => void) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
fn(next);
return next;
},
{ replace: true },
);
const setFilter = (k: LogFilterKey, v: string) =>
update((p) => {
if (v) p.set(k, v);
else p.delete(k);
});
// 关键词输入防抖写进 URL
// biome-ignore lint/correctness/useExhaustiveDependencies: 只跟输入走
useEffect(() => {
const t = setTimeout(() => {
if (keyword.trim() !== filters.q) setFilter("q", keyword.trim());
}, 350);
return () => clearTimeout(t);
}, [keyword]);
const from = useMemo(() => sinceToFrom(filters.since), [filters.since]);
const query: LogParams = {
type: filters.type || undefined,
level: filters.level || undefined,
from,
q: filters.q || undefined,
objectType: filters.objectType || undefined,
objectId: filters.objectId || undefined,
system: filters.system || undefined,
};
const list = useLogs(query);
const listRows = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
// 流:从列表里最新一条之后接着推;时间范围只决定下界,新日志总在范围内
const stream = useLogStream(query, { enabled: live && !list.isPending, since: listRows[0]?.at ?? null });
const rows = useMemo(
() => (stream.entries.length ? upsertEntries(listRows, stream.entries) : listRows),
[listRows, stream.entries],
);
const fresh = useMemo(() => new Set(stream.entries.map((e) => e.id)), [stream.entries]);
// 筛选变了:清掉选择
const filterKey = JSON.stringify(query);
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在筛选变化时清空
useEffect(() => {
setSelected(new Set());
anchor.current = null;
}, [filterKey]);
const openId = params.get("log") ?? (params.get("run") ? `engine_run:${params.get("run")}` : null);
const openEntry = openId ? (rows.find((e) => e.id === openId) ?? null) : null;
const selectedRows = rows.filter((e) => selected.has(e.id));
/** 勾选:按住 Shift 选中与上一次勾选之间的一段 */
const toggle = (e: LogEntry, shift: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
const on = !prev.has(e.id);
if (shift && anchor.current) {
const a = rows.findIndex((r) => r.id === anchor.current);
const b = rows.findIndex((r) => r.id === e.id);
if (a >= 0 && b >= 0) {
for (const r of rows.slice(Math.min(a, b), Math.max(a, b) + 1)) {
if (on) next.add(r.id);
else next.delete(r.id);
}
return next;
}
}
if (on) next.add(e.id);
else next.delete(e.id);
return next;
});
anchor.current = e.id;
};
const filtered = LOG_FILTER_KEYS.some((k) => !!filters[k]);
const objectChip = filters.objectType || filters.objectId;
return (
<>
<div className="mb-3 flex flex-wrap items-center gap-2">
<StreamState status={live ? stream.status : "off"} />
<Button
variant="outline"
size="sm"
onClick={() => setLive((v) => !v)}
aria-pressed={live}
aria-label={live ? "暂停实时" : "继续实时"}
>
{live ? <Pause /> : <Play />}
{live ? "暂停" : "继续"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
stream.clear();
void qc.invalidateQueries({ queryKey: qk.all("logs") });
}}
>
<RotateCw />
刷新
</Button>
{selected.size ? (
<span className="ml-auto flex flex-wrap items-center gap-2" data-floating-bar>
<span className="text-caption text-muted-foreground">已选 {selected.size} 条</span>
<AskAgentButton entries={selectedRows} />
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
<X />
清除选择
</Button>
</span>
) : (
<span className="ml-auto text-meta text-muted-foreground">勾选日志(按住 Shift 选一段)后可以问智能体</span>
)}
</div>
<FilterBar
search={{ value: keyword, onChange: setKeyword, placeholder: "搜索标题、错误、对象 ID" }}
onReset={
filtered
? () => {
setKeyword("");
update((p) => {
for (const k of LOG_FILTER_KEYS) p.delete(k);
});
}
: undefined
}
>
<FilterSelect
label="类型"
value={filters.type}
onChange={(v) => setFilter("type", v)}
options={LOG_TYPES.map((value) => ({ value, label: LOG_TYPE_LABEL[value] }))}
/>
<FilterSelect
label="结果"
value={filters.level}
onChange={(v) => setFilter("level", v)}
options={LOG_LEVELS.map((value) => ({ value, label: LOG_LEVEL[value].label }))}
/>
<FilterSelect
label="时间"
value={filters.since}
onChange={(v) => setFilter("since", v)}
options={LOG_SINCE}
allLabel="不限"
/>
<Button
variant={filters.system === "1" ? "secondary" : "ghost"}
size="sm"
aria-pressed={filters.system === "1"}
title="没有业务对象的任务、例行定时任务的成功运行"
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
>
{filters.system === "1" ? "隐藏例行记录" : "显示例行记录"}
</Button>
{objectChip ? (
<Button
variant="outline"
size="sm"
title={`${filters.objectType} ${filters.objectId}`}
onClick={() =>
update((p) => {
p.delete("objectType");
p.delete("objectId");
})
}
>
{filters.objectType ? (TARGET_LABEL[filters.objectType] ?? filters.objectType) : "对象"}
{filters.objectId ? <span className="max-w-40 truncate font-mono">{filters.objectId}</span> : null}
<X />
</Button>
) : null}
</FilterBar>
<DataTable<LogEntry>
paginationKey={filterKey}
rows={list.isPending ? undefined : rows}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => void list.refetch()}
onRowClick={(r) =>
update((p) => {
p.delete("run");
p.set("log", r.id);
})
}
selectedKey={openId}
hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
empty={{
icon: ScrollText,
title: filtered ? "没有符合条件的日志" : "还没有日志",
description: filtered ? "换个筛选或放宽时间范围。" : "任务运行、定时任务执行、登录和设置修改都会记在这里。",
}}
columns={[
{
key: "pick",
header: <span className="sr-only">选择</span>,
className: "w-8",
cell: (r) => (
<input
type="checkbox"
aria-label={`选择 ${logTitle(r)}`}
checked={selected.has(r.id)}
onClick={(ev) => {
ev.stopPropagation();
toggle(r, ev.shiftKey);
}}
onChange={() => {}}
className="h-4 w-4 cursor-pointer accent-foreground"
/>
),
},
{
key: "at",
header: "时间",
cell: (r) => (
<span
className={cn(
"whitespace-nowrap font-mono text-caption tabular-nums",
fresh.has(r.id) ? "text-foreground" : "text-muted-foreground",
)}
title={`${formatDateTime(r.at)}(${formatRelative(r.at)})`}
>
{shortTime(r.at)}
</span>
),
},
{
key: "type",
header: "类型",
hideBelow: "sm",
cell: (r) => <span className="whitespace-nowrap text-caption">{LOG_TYPE_LABEL[r.type]}</span>,
},
{
key: "level",
header: "结果",
cell: (r) => {
const b = logBadge(r);
return <StatusBadge tone={b.tone}>{b.label}</StatusBadge>;
},
},
{
key: "title",
header: "摘要",
cell: (r) => {
const sub = logSubtitle(r);
return (
<div className="min-w-0 max-w-[16rem] sm:max-w-md lg:max-w-xl">
<p className="truncate font-medium text-foreground" title={logTitle(r)}>
{logTitle(r)}
</p>
{sub ? (
<p
className={cn(
"truncate text-meta",
r.level === "error" ? "text-destructive" : "text-muted-foreground",
)}
title={sub}
>
{sub}
</p>
) : null}
</div>
);
},
},
{
key: "object",
header: "对象",
hideBelow: "lg",
cell: (r) => {
const t = objectText(r.object);
return t ? (
<span className="block max-w-[14rem] truncate text-caption text-muted-foreground" title={t}>
{t}
</span>
) : (
<span className="text-muted-foreground">—</span>
);
},
},
]}
/>
<LogDetailSheet
logId={openId}
entry={openEntry}
onOpenLog={(id) =>
update((p) => {
p.delete("run");
p.set("log", id);
})
}
onClose={() =>
update((p) => {
p.delete("log");
p.delete("run");
})
}
/>
</>
);
}
export default function LogsPage() {
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const tab: Tab = params.get("tab") === "schedules" ? "schedules" : "logs";
return (
<>
<PageHeader
title="日志"
actions={
tab === "schedules" ? (
<Button
variant="outline"
size="sm"
onClick={() => {
qc.invalidateQueries({ queryKey: qk.all("schedules") });
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
}}
>
<RotateCw />
刷新
</Button>
) : null
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (v === "schedules") next.set("tab", "schedules");
else {
next.delete("tab");
next.delete("job");
}
return next;
},
{ replace: true },
)
}
>
<TabsList>
<TabsTrigger value="logs">日志</TabsTrigger>
<TabsTrigger value="schedules">定时任务</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-4">
{tab === "schedules" ? (
<PlanBoard highlightJob={params.get("job") ?? undefined} />
) : (
<LogsView params={params} setParams={setParams} />
)}
</div>
</>
);
}