Files
UHD_Media_Tool/apps/web/app/components/logs/log-detail.tsx
T
rootandClaude Opus 5.5 89795802b5 docs(copy): 日志、计划、运行状态文案按 human-copy 重写
- 「立即执行」统一为「立即运行」,Toast 写「已加入队列」
- 巡检不再提「待处理」:触发条件写「有需要处理的事项时」,开关叫「生成巡检报告」
- 下载巡检触发条件写清包含失败的下载

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:46:30 +08:00

313 lines
10 KiB
TypeScript

import type { LogEntry } from "@umt/contracts";
import { Bot, ExternalLink, History } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { useEngineRun } from "~/api/engine.ts";
import { type AgentContext, openAgentDrawer } from "~/components/agent/agent-store.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { runTitle } from "~/components/tasks/labels.ts";
import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx";
import { Button } from "~/components/ui/button.tsx";
import { actorTypeLabel, formatDuration } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
import {
actorLabel,
agentGoalFor,
LOG_TYPE_LABEL,
logBadge,
logsPath,
logTitle,
objectLogsPath,
objectText,
TARGET_LABEL,
} from "./labels.ts";
// 日志详情抽屉:任务复用原任务详情(业务摘要、时间线、引擎步骤、取消 / 重跑);定时任务运行、后台调查、审计各自一组字段。
// 底部「问智能体」带上这条日志打开智能体抽屉。
/** 选中的日志 → 智能体上下文(单条时带上能识别的对象 id,服务端据此取数) */
export function agentContextFor(entries: LogEntry[]): AgentContext {
const ctx: AgentContext = {
label: entries.length === 1 ? `日志:${logTitle(entries[0]!)}` : `日志:${entries.length} 条`,
goal: agentGoalFor(entries),
};
if (entries.length === 1) {
const e = entries[0]!;
if (e.source === "engine_run") ctx.taskId = e.refId;
const o = e.object;
if (o?.id) {
if (o.type === "download_task") ctx.downloadTaskId = o.id;
else if (o.type === "organization_record") ctx.recordId = o.id;
else if (o.type === "media_entity") ctx.entityId = o.id;
else if (o.type === "site") ctx.siteId = o.id;
}
}
return ctx;
}
export function AskAgentButton({ entries, size = "sm" }: { entries: LogEntry[]; size?: "sm" | "xs" }) {
return (
<Button
variant="outline"
size={size}
disabled={!entries.length}
onClick={() => openAgentDrawer(agentContextFor(entries))}
>
<Bot />
问智能体
</Button>
);
}
function LinkRow({ links }: { links: { to: string; label: string; icon?: ReactNode }[] }) {
return (
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
{links.map((l) => (
<li key={l.to}>
<Link
to={l.to}
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{l.icon ?? <ExternalLink className="h-3.5 w-3.5" />}
{l.label}
</Link>
</li>
))}
</ul>
);
}
function ScheduleRunBody({ e }: { e: LogEntry }) {
const x = e.extra as {
jobId?: string;
jobKind?: string;
trigger?: string;
agentRunId?: string | null;
summary?: string | null;
error?: string | null;
};
const jobId = x.jobId ?? e.object?.id ?? "";
return (
<>
<DetailSection title="概况">
<KvList
items={[
{ label: "定时任务", value: e.title, wide: true },
{ label: "类型", value: x.jobKind === "agent_check" ? "智能体巡检" : "自动任务" },
{ label: "触发", value: actorLabel(e) },
{ label: "开始", value: formatDateTime(e.at) },
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
{ label: "摘要", value: x.summary, wide: true },
]}
/>
{x.error ? <p className="mt-3 text-caption break-words text-destructive">{x.error}</p> : null}
</DetailSection>
<DetailSection title="相关">
<LinkRow
links={[
...(jobId
? [
{ to: logsPath({ tab: "schedules", job: jobId }), label: "定时任务设置" },
{
to: objectLogsPath("scheduled_job", jobId),
label: "这个定时任务的全部运行记录",
icon: <History className="h-3.5 w-3.5" />,
},
]
: []),
...(x.agentRunId ? [{ to: `/console/agent/${encodeURIComponent(x.agentRunId)}`, label: "巡检会话" }] : []),
]}
/>
</DetailSection>
<TechInfo>
<KvList
items={[
{ label: "运行记录 ID", value: e.refId, mono: true },
{ label: "定时任务 ID", value: jobId, mono: true },
]}
/>
</TechInfo>
</>
);
}
function AgentRunBody({ e }: { e: LogEntry }) {
const x = e.extra as { toolCalls?: number; spawnReason?: string | null; model?: string | null };
return (
<>
<DetailSection title="概况">
<KvList
items={[
{ label: "目标", value: <span className="whitespace-pre-wrap">{e.title}</span>, wide: true },
{ label: "开始", value: formatDateTime(e.at) },
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
{ label: "结果", value: e.message, wide: true },
{ label: "工具调用", value: x.toolCalls ?? 0 },
]}
/>
</DetailSection>
<DetailSection title="相关">
<LinkRow links={[{ to: `/console/agent/${encodeURIComponent(e.refId)}`, label: "打开会话" }]} />
</DetailSection>
<TechInfo>
<KvList
items={[
{ label: "会话 ID", value: e.refId, mono: true },
{ label: "模型", value: x.model, mono: true },
]}
/>
</TechInfo>
</>
);
}
function AuditBody({ e }: { e: LogEntry }) {
const x = e.extra as { action?: string; traceId?: string | null; payload?: unknown };
const o = e.object;
return (
<>
<DetailSection title="事件">
<KvList
items={[
{
label: "操作者",
value: e.actor ? `${actorTypeLabel(e.actor.type)}${e.actor.id ? ` · ${e.actor.id}` : ""}` : "—",
wide: true,
},
{ label: "对象", value: o ? (TARGET_LABEL[o.type] ?? o.type) : "—" },
{ label: "摘要", value: e.message },
]}
/>
</DetailSection>
{o?.id ? (
<DetailSection title="相关">
<LinkRow
links={[
{
to: objectLogsPath(o.type, o.id),
label: "同一对象的全部日志",
icon: <History className="h-3.5 w-3.5" />,
},
]}
/>
</DetailSection>
) : null}
<JsonView label="负载" value={x.payload} />
<TechInfo>
<KvList
items={[
{ label: "动作", value: x.action, mono: true },
{ label: "对象类型", value: o?.type, mono: true },
{ label: "对象 ID", value: o?.id, mono: true },
{ label: "Trace", value: x.traceId, mono: true },
{ label: "事件 ID", value: e.refId, mono: true },
]}
/>
</TechInfo>
</>
);
}
/** 任务:按引擎运行 id 取详情(列表里没有这条也能打开,旧链接 ?run= 用) */
function RunSheet({
runId,
entry,
onOpenRun,
onClose,
}: {
runId: string;
entry: LogEntry | null;
onOpenRun: (id: string) => void;
onClose: () => void;
}) {
const q = useEngineRun(runId);
const r = q.data;
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
width="lg"
title={r ? runTitle(r) : entry ? logTitle(entry) : "任务"}
description={r ? <TaskStatusLine r={r} /> : undefined}
footer={
r ? (
<>
<Button asChild variant="ghost" size="sm">
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
<ExternalLink />
打开任务页
</Link>
</Button>
{entry ? <AskAgentButton entries={[entry]} /> : null}
<RunActions r={r} onOpenRun={onOpenRun} />
</>
) : null
}
>
{q.isPending ? (
<LoadingState rows={6} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : r ? (
<TaskDetailBody r={r} onOpenRun={onOpenRun} />
) : null}
</DetailSheet>
);
}
/**
* 日志详情抽屉。open = 日志 id(<source>:<原始 id>);任务类只要 id 就能打开,其余要在已加载的列表里找到这条。
*/
export function LogDetailSheet({
logId,
entry,
onOpenLog,
onClose,
}: {
logId: string | null;
entry: LogEntry | null;
onOpenLog: (id: string) => void;
onClose: () => void;
}) {
if (!logId) return null;
const sep = logId.indexOf(":");
const source = logId.slice(0, sep);
const refId = logId.slice(sep + 1);
if (source === "engine_run")
return <RunSheet runId={refId} entry={entry} onOpenRun={(id) => onOpenLog(`engine_run:${id}`)} onClose={onClose} />;
const badge = entry ? logBadge(entry) : null;
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
title={entry ? logTitle(entry) : "日志"}
description={
entry ? (
<span className="flex flex-wrap items-center gap-2">
{badge ? <StatusBadge tone={badge.tone}>{badge.label}</StatusBadge> : null}
<span>{LOG_TYPE_LABEL[entry.type]}</span>
<span>· {formatDateTime(entry.at)}</span>
{entry.object ? <span>· {objectText(entry.object)}</span> : null}
</span>
) : undefined
}
footer={entry ? <AskAgentButton entries={[entry]} /> : null}
>
{!entry ? (
<EmptyState plain title="这条日志不在当前列表里" description="换个筛选或时间范围再找。" className="py-6" />
) : entry.source === "schedule_run" ? (
<ScheduleRunBody e={entry} />
) : entry.source === "agent_run" ? (
<AgentRunBody e={entry} />
) : (
<AuditBody e={entry} />
)}
</DetailSheet>
);
}