- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 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
460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|