Files
UHD_Media_Tool/apps/web/app/routes/console/logs.tsx
T
rootandClaude Opus 5.5 c9b4ceb301 fix(web): 控制台界面细节与 uhd_web 控件规范对齐
- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕)
- 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块
- 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签
- 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮
- 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载
- 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态

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

460 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 { 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-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) =>
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>
</>
);
}