Files
UHD_Media_Tool/apps/web/app/routes/console/logs.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:03:47 +08:00

443 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useRecordSheet } from "~/components/console/record-sheet.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);
// 记录抽屉(console-ia.md §9):日志页的记录参数是 ?log=<日志 id>,任务也可以只给 ?run=<引擎运行 id>
const sheet = useRecordSheet({ key: "log", scoped: ["run"] });
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-label={live ? "暂停实时" : "继续实时"}
>
{live ? <Pause /> : <Play />}
{live ? "暂停" : "继续"}
</Button>
<Button
variant="outline"
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="outline"
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) => sheet.open(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) => sheet.open(id)} onClose={sheet.close} />
</>
);
}
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>
</>
);
}