Merge branch 'deai2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # apps/server/src/modules/acquisition-organize/services/organize.ts # apps/web/app/routes/console/sites.$siteId.tsx
This commit is contained in:
commit
12bc4664b6
247 files changed
+2658
-2148
No files matched your search
@@ -3,7 +3,7 @@ import { Link, useParams } from "react-router";
|
||||
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||
import { splitContextGoal } from "~/components/agent/agent-store.ts";
|
||||
import { shortGoalTitle } from "~/components/agent/agent-store.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
@@ -17,11 +17,11 @@ export default function AgentRunPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
title="智能体会话"
|
||||
description={
|
||||
run.data ? (
|
||||
<span className="line-clamp-2 break-words" title={run.data.run.goal}>
|
||||
{splitContextGoal(run.data.run.goal).text}
|
||||
{shortGoalTitle(run.data.run.goal)}
|
||||
</span>
|
||||
) : (
|
||||
"加载中…"
|
||||
|
||||
@@ -15,10 +15,7 @@ export default function AgentPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
description="交代目标,Agent 列出步骤后在授权范围内执行并汇报;需要审批的动作停在对应步骤等你批准。"
|
||||
/>
|
||||
<PageHeader title="智能体会话" />
|
||||
{mock ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
|
||||
<FlaskConical className="h-4 w-4 shrink-0" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuditAction, type AuditEvent } from "@umt/contracts";
|
||||
import { AuditAction, type AuditEvent, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
@@ -40,10 +40,11 @@ const ACTION_LABEL: Record<string, string> = {
|
||||
"schedule.skip": "跳过定时任务",
|
||||
"acquisition_plan.manual_submit": "手动提交下载计划",
|
||||
"engine.run_retry": "重跑任务",
|
||||
"capability.invoke": "Agent 调用能力",
|
||||
"capability.invoke": "智能体操作",
|
||||
"capability.denied": "智能体操作被拒绝",
|
||||
"site.created": "登记站点",
|
||||
"site.updated": "修改站点",
|
||||
"feed_source.created": "新建 Feed",
|
||||
"feed_source.created": "新建 RSS",
|
||||
"identity.release_assigned": "指定资源身份",
|
||||
"schedule.update": "修改定时任务",
|
||||
"files.mkdir": "新建目录",
|
||||
@@ -56,14 +57,37 @@ const ACTION_LABEL: Record<string, string> = {
|
||||
};
|
||||
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
|
||||
const NOUN: Record<string, string> = {
|
||||
library_root: "库根",
|
||||
library_root: "媒体库目录",
|
||||
downloader_instance: "下载器",
|
||||
media_server_instance: "媒体服务器",
|
||||
path_mapping: "路径映射",
|
||||
pi_upstream: "Agent 上游",
|
||||
pi_upstream: "模型服务",
|
||||
schedule: "定时任务",
|
||||
automation_grant: "持续授权",
|
||||
};
|
||||
const TARGET_LABEL: Record<string, string> = {
|
||||
...NOUN,
|
||||
admin_user: "管理员",
|
||||
api_key: "API Key",
|
||||
settings: "设置",
|
||||
secret: "凭证",
|
||||
site: "站点",
|
||||
site_account: "站点账号",
|
||||
feed_source: "RSS",
|
||||
engine_run: "任务",
|
||||
acquisition_plan: "下载计划",
|
||||
download_task: "下载任务",
|
||||
media_entity: "作品",
|
||||
source_release: "资源",
|
||||
organization_record: "整理记录",
|
||||
file: "文件",
|
||||
capability: "能力",
|
||||
};
|
||||
/** 对象列:能力显示中文名,其余显示对象类型;ID 只在详情里 */
|
||||
function targetText(type: string, id: string | null): string {
|
||||
if (type === "capability" && id) return CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
|
||||
return TARGET_LABEL[type] ?? type;
|
||||
}
|
||||
function actionLabel(a: string): string {
|
||||
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
|
||||
const [noun, verb] = a.split(".");
|
||||
@@ -132,7 +156,7 @@ export default function AuditPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="审计" description="登录、设置、密钥与实例变更,新的在前。不记录凭证内容。" />
|
||||
<PageHeader title="审计" />
|
||||
<FilterBar
|
||||
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
|
||||
onReset={
|
||||
@@ -188,7 +212,6 @@ export default function AuditPage() {
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{actionLabel(r.action)}</p>
|
||||
<p className="font-mono text-meta text-muted-foreground">{r.action}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -199,10 +222,9 @@ export default function AuditPage() {
|
||||
hideBelow: "md",
|
||||
cell: (r) =>
|
||||
r.targetType ? (
|
||||
<div className="min-w-0 max-w-[18rem]">
|
||||
<p className="truncate">{r.targetType}</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground">{r.targetId}</p>
|
||||
</div>
|
||||
<p className="min-w-0 max-w-[18rem] truncate" title={`${r.targetType} · ${r.targetId ?? ""}`}>
|
||||
{targetText(r.targetType, r.targetId)}
|
||||
</p>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function DownloadPlansPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="下载面板" description="候选被选中后生成计划,同时预留版本目标、空间与预算。" />
|
||||
<PageHeader title="下载面板" />
|
||||
<DownloadsTabs />
|
||||
<FilterBar onReset={f.active ? f.reset : undefined}>
|
||||
<FilterSelect
|
||||
@@ -134,7 +134,7 @@ export default function DownloadPlansPage() {
|
||||
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
|
||||
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
|
||||
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
|
||||
{ label: "版本目标", value: openPlan.slotId, mono: true },
|
||||
{ label: "收录版本", value: openPlan.slotId, mono: true },
|
||||
{ label: "资源", value: openPlan.sourceReleaseId, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -91,7 +91,6 @@ export default function DownloadsPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="下载面板"
|
||||
description="阈值暂停不能手动绕过;无进展只记录,不自动换源。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
|
||||
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
|
||||
@@ -109,12 +108,12 @@ export default function DownloadsPage() {
|
||||
options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="纳管"
|
||||
label="来源"
|
||||
value={f.values.managed}
|
||||
onChange={(v) => f.set("managed", v)}
|
||||
options={[
|
||||
{ value: "true", label: "纳管任务" },
|
||||
{ value: "false", label: "未纳管" },
|
||||
{ value: "true", label: "本工具" },
|
||||
{ value: "false", label: "其他" },
|
||||
]}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
@@ -212,14 +212,14 @@ export default function FilesTrashPage() {
|
||||
},
|
||||
{
|
||||
key: "asset",
|
||||
header: "关联资产",
|
||||
header: "媒体库记录",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => {
|
||||
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null;
|
||||
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
|
||||
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
|
||||
return (
|
||||
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
|
||||
<span className="block max-w-48 text-meta" title="删除时已从媒体库移除;还原不会自动恢复">
|
||||
{snap.mediaEntityId ? (
|
||||
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
|
||||
{label}
|
||||
@@ -274,7 +274,7 @@ export default function FilesTrashPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="回收站"
|
||||
description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
|
||||
description="到期后自动清除,到期前可以还原。还原不会恢复媒体库记录,需要时重新整理。"
|
||||
actions={
|
||||
<>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
@@ -419,7 +419,7 @@ function RestoreToDialog({
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>还原到…</DialogTitle>
|
||||
<DialogDescription>{ids.length} 项 · 选一个可写库根下的目录。同名项已存在时不覆盖。</DialogDescription>
|
||||
<DialogDescription>{ids.length} 项 · 选一个可写媒体库目录下的目录。同名项已存在时不覆盖。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DirPicker roots={roots.data ?? []} value={target} onChange={setTarget} className="max-h-80" />
|
||||
<DialogFooter>
|
||||
|
||||
@@ -97,7 +97,7 @@ import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const HEALTH_REASON: Record<string, string> = {
|
||||
root_missing: "挂载点不存在",
|
||||
sentinel_missing: "目录里没有哨兵文件,挂载可能没生效",
|
||||
sentinel_missing: "目录里没有标记文件,挂载可能没生效",
|
||||
timeout: "探测超时",
|
||||
permission_denied: "没有读取权限",
|
||||
};
|
||||
@@ -398,7 +398,9 @@ export default function FilesPage() {
|
||||
onSuccess: (r) => {
|
||||
const ok = r.items.filter((i) => i.status === "ok").length;
|
||||
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
|
||||
description: r.items.some((i) => i.savedToAsset) ? "媒体信息已写入资产" : "未登记文件的结果缓存 24 小时",
|
||||
description: r.items.some((i) => i.savedToAsset)
|
||||
? "媒体信息已写入媒体库记录"
|
||||
: "未登记文件的结果缓存 24 小时",
|
||||
});
|
||||
},
|
||||
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
|
||||
@@ -540,7 +542,7 @@ export default function FilesPage() {
|
||||
if (items.every((i) => i.root === target.root && (i.path === target.path || parentRel(i.path) === target.path)))
|
||||
return;
|
||||
const tRoot = rootList.find((r) => r.id === target.root);
|
||||
if (!tRoot?.writable) return toast.error("只能拖到可写的库根");
|
||||
if (!tRoot?.writable) return toast.error("只能拖到可写的媒体库目录");
|
||||
setDialog({ kind: "transfer", items, mode: ev.altKey ? "copy" : "move", target });
|
||||
};
|
||||
|
||||
@@ -567,7 +569,7 @@ export default function FilesPage() {
|
||||
plain
|
||||
icon={FolderTree}
|
||||
title="还没有登记目录"
|
||||
description="在设置里添加库根或下载器路径映射。"
|
||||
description="在设置里添加媒体库目录或下载器路径映射。"
|
||||
className="px-2 py-8"
|
||||
/>
|
||||
) : null}
|
||||
@@ -596,7 +598,7 @@ export default function FilesPage() {
|
||||
</span>
|
||||
<p className="text-lead font-medium text-foreground">{root.name} 离线</p>
|
||||
<p className="mt-1.5 max-w-md text-caption text-muted-foreground">
|
||||
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}。离线期间资产状态不变。
|
||||
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}。离线期间文件状态不变。
|
||||
</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
|
||||
<Button
|
||||
@@ -693,7 +695,7 @@ export default function FilesPage() {
|
||||
key: "scrape",
|
||||
label: "刷新元数据",
|
||||
icon: RefreshCw,
|
||||
disabled: selected.every((e) => e.asset) ? null : "只对已登记项",
|
||||
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
|
||||
onSelect: () => act("scrape", selected),
|
||||
});
|
||||
items.push({ key: "copyPath", label: "复制路径", icon: ClipboardCopy, onSelect: () => void copyPaths(selected) });
|
||||
|
||||
@@ -5,18 +5,12 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CalendarClock,
|
||||
CalendarCog,
|
||||
CircleCheck,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Download,
|
||||
Library as LibraryIcon,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
Pause,
|
||||
Play,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
@@ -24,31 +18,27 @@ import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
||||
import { useDecisions } from "~/api/decisions.ts";
|
||||
import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts";
|
||||
import { useEngineRunsPage } from "~/api/engine.ts";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { useSystemHealth } from "~/api/system.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
|
||||
import { formatUsd, planPath } from "~/components/schedules/labels.ts";
|
||||
import { planStats, useLiveSchedules } from "~/components/schedules/schedule-board.tsx";
|
||||
import { engineRunStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts";
|
||||
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 首页只回答三件事(DEAI-2):在忙什么、要我处理什么、最近入库了什么;顶部一行一句话状态。
|
||||
// 运行状态、计划、缓存参数、收录版本满足率不在这里,只在出问题时写进状态行。
|
||||
|
||||
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
||||
const DASHBOARD_EVENTS = [
|
||||
"schedule.changed",
|
||||
"task.created",
|
||||
"task.status_changed",
|
||||
"download.submitted",
|
||||
@@ -56,8 +46,6 @@ const DASHBOARD_EVENTS = [
|
||||
"download.threshold",
|
||||
"organization.changed",
|
||||
"agent.run_status",
|
||||
"feed.fetched",
|
||||
"feed.health_changed",
|
||||
"decision.created",
|
||||
"decision.updated",
|
||||
"settings.changed",
|
||||
@@ -82,28 +70,24 @@ function Section({
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
extra,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
to?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** 标题右侧的摘要(计数、状态) */
|
||||
extra?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
||||
{extra}
|
||||
{to ? (
|
||||
<Link
|
||||
to={to}
|
||||
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
查看
|
||||
全部
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : null}
|
||||
@@ -117,144 +101,170 @@ function Row({ children }: { children: ReactNode }) {
|
||||
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
|
||||
}
|
||||
|
||||
// ─── 自动化总开关:显眼状态 + 切换 ───
|
||||
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
||||
/** 非零项拼一句:[[3, "个下载"], [0, "个入库中"]] → "3 个下载" */
|
||||
function nonZero(parts: [number, string][]): string {
|
||||
return parts
|
||||
.filter(([n]) => n > 0)
|
||||
.map(([n, s]) => `${n} ${s}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
// ─── 顶部一行:自动化状态 + 需要知道的异常 ───
|
||||
function StatusLine({ d }: { d: Dashboard }) {
|
||||
const a = d.automation;
|
||||
const update = useUpdateSettings();
|
||||
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
|
||||
const health = useSystemHealth(60_000);
|
||||
const sites = useSitesWithAccounts();
|
||||
const setEnabled = async (enabled: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ automation: patch });
|
||||
toast.success(ok);
|
||||
await update.mutateAsync({ automation: { enabled } });
|
||||
toast.success(enabled ? "自动化已恢复" : "自动化已暂停");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "切换失败"));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const paused = !a.enabled;
|
||||
const readOnly = a.readOnlyReconcile;
|
||||
|
||||
// 只列不正常的:服务异常、下载盘到暂停线、站点账号失效、下载器离线
|
||||
const problems: { text: string; to: string }[] = [];
|
||||
const lv = health.data?.level;
|
||||
if (lv === "critical" || lv === "warn")
|
||||
problems.push({ text: lv === "critical" ? "服务异常" : "服务需要关注", to: consolePath("status") });
|
||||
for (const v of d.cache.volumes)
|
||||
if (v.usedPercent !== null && v.usedPercent >= v.hardThresholdPercent)
|
||||
problems.push({ text: `下载盘已用 ${Math.round(v.usedPercent)}%,新下载已暂停`, to: consolePath("downloads") });
|
||||
const badAccounts = (sites.data?.items ?? []).filter(
|
||||
(s) => s.enabled && s.accountStatus && ["invalid", "expired", "challenge"].includes(s.accountStatus),
|
||||
).length;
|
||||
if (badAccounts) problems.push({ text: `${badAccounts} 个站点账号需要重新登录`, to: consolePath("sites") });
|
||||
const offline = d.sources.downloaders.filter((x) => x.enabled && ["offline", "failing"].includes(x.health)).length;
|
||||
if (offline) problems.push({ text: `${offline} 个下载器连不上`, to: settingsPath("downloaders") });
|
||||
|
||||
const paused = !a.enabled;
|
||||
const warn = paused || a.readOnlyReconcile || problems.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
|
||||
(paused || readOnly) && "border border-warning/40",
|
||||
"pg-slab mb-4 flex flex-col gap-3 rounded-xl px-4 py-3 sm:flex-row sm:items-center sm:px-5",
|
||||
warn && "border border-warning/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
|
||||
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
|
||||
)}
|
||||
>
|
||||
{readOnly ? (
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
) : paused ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-lead font-semibold text-foreground">
|
||||
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
|
||||
</p>
|
||||
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
|
||||
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-caption text-muted-foreground">
|
||||
{paused
|
||||
? "自动收录、下载、整理已停止,手动操作仍可用。"
|
||||
: readOnly
|
||||
? "只核对,不写入。确认无误后关闭。"
|
||||
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<div className="flex items-center gap-2 text-caption text-foreground">
|
||||
<label htmlFor="home-readonly">只读对账</label>
|
||||
<Switch
|
||||
id="home-readonly"
|
||||
checked={readOnly}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={(v) =>
|
||||
setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
|
||||
}
|
||||
aria-label="只读对账模式"
|
||||
/>
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 text-sm text-foreground">
|
||||
<span className={cn("mr-2 inline-block size-2 rounded-full", warn ? "bg-warning" : "bg-success")} aria-hidden />
|
||||
{paused ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
|
||||
>
|
||||
<Play />
|
||||
恢复自动化
|
||||
</Button>
|
||||
"自动化已暂停,手动操作仍可用"
|
||||
) : a.readOnlyReconcile ? (
|
||||
<>
|
||||
只读模式:不会自动下载和整理。
|
||||
<Link to={settingsPath("automation", "advanced")} className="underline underline-offset-4">
|
||||
去设置关闭
|
||||
</Link>
|
||||
</>
|
||||
) : a.activeSubscriptions > 0 ? (
|
||||
`自动化已开启 · ${a.activeSubscriptions} 个订阅`
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={update.isPending}>
|
||||
<Pause />
|
||||
暂停自动化
|
||||
</Button>
|
||||
}
|
||||
title="暂停全部自动化?"
|
||||
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
||||
confirmLabel="暂停自动化"
|
||||
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
|
||||
/>
|
||||
<>
|
||||
自动化已开启 · 还没有订阅。
|
||||
<Link to={consolePath("search")} className="underline underline-offset-4">
|
||||
去搜索添加
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{problems.map((p) => (
|
||||
<span key={p.text}>
|
||||
{" · "}
|
||||
<Link to={p.to} className="text-warning underline-offset-4 hover:underline">
|
||||
{p.text}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
{paused ? (
|
||||
<Button size="sm" disabled={update.isPending} onClick={() => setEnabled(true).catch(() => {})}>
|
||||
<Play />
|
||||
恢复
|
||||
</Button>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={update.isPending}>
|
||||
<Pause />
|
||||
暂停
|
||||
</Button>
|
||||
}
|
||||
title="暂停全部自动化?"
|
||||
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
||||
confirmLabel="暂停自动化"
|
||||
onConfirm={() => setEnabled(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 正在进行:任务流 top N ───
|
||||
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
|
||||
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
|
||||
const stats = useEngineStats();
|
||||
const rows = running.data?.items ?? [];
|
||||
// ─── 在忙什么:下载中的任务 + 进行中的智能体会话 + 有业务对象的运行 ───
|
||||
function Busy({ w }: { w: Dashboard["currentWork"] }) {
|
||||
const running = useEngineRunsPage({ status: "running" }, 10, 10_000);
|
||||
const runs = (running.data?.items ?? []).filter((r) => r.business);
|
||||
const downloads = w.items.slice(0, 4);
|
||||
const agents = w.agentRuns.slice(0, 2);
|
||||
const summary = nonZero([
|
||||
[w.downloading, "个下载中"],
|
||||
[w.importing, "个入库中"],
|
||||
[w.stalled, "个没有进展"],
|
||||
[w.paused, "个已暂停"],
|
||||
]);
|
||||
const empty = !downloads.length && !agents.length && !runs.length;
|
||||
return (
|
||||
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
|
||||
{running.isPending ? (
|
||||
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
|
||||
{running.isPending && !downloads.length ? (
|
||||
<LoadingState rows={3} />
|
||||
) : running.isError ? (
|
||||
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
|
||||
) : empty ? (
|
||||
<EmptyState plain icon={CircleCheck} title="现在没有在跑的任务" className="py-6" />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.slice(0, 5).map((r) => (
|
||||
{downloads.map((t) => (
|
||||
<Row key={t.downloadTaskId}>
|
||||
<Link to={`/console/downloads/${t.downloadTaskId}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{t.name}</span>
|
||||
{t.status === "stalled" ? <span className="block text-meta text-warning">没有进展</span> : null}
|
||||
</Link>
|
||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||
<Download className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{Math.round(t.progress * 100)}%
|
||||
</span>
|
||||
</Row>
|
||||
))}
|
||||
{agents.map((r) => (
|
||||
<Row key={r.id}>
|
||||
<Link to={`/console/agent/${r.id}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{r.goal}</span>
|
||||
</Link>
|
||||
<Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-label="智能体" />
|
||||
</Row>
|
||||
))}
|
||||
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
|
||||
<Row key={r.id}>
|
||||
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{runTitle(r)}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||
{formatDuration(spanSec(r))}
|
||||
</span>
|
||||
</Row>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
||||
排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}
|
||||
</p>
|
||||
{summary ? <p className="mt-auto pt-3 text-meta text-muted-foreground">{summary}</p> : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 需要我处理:计数 + 前 3 条 ───
|
||||
// ─── 要我处理什么:计数 + 前 3 条 ───
|
||||
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
const list = useDecisions({});
|
||||
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
|
||||
return (
|
||||
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||
{n.total === 0 ? (
|
||||
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
|
||||
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
||||
@@ -268,9 +278,7 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
<Row key={d.id}>
|
||||
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{d.title}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">
|
||||
{decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)}
|
||||
</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">{formatRelative(d.createdAt)}</span>
|
||||
</Link>
|
||||
</Row>
|
||||
))}
|
||||
@@ -281,387 +289,39 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ───
|
||||
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
|
||||
const c = a.slotCounts;
|
||||
const total = Object.values(c).reduce((x, y) => x + y, 0);
|
||||
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
|
||||
const lib = a.library;
|
||||
// ─── 最近入库了什么:成功 / 部分成功的整理 ───
|
||||
function RecentImports({ d }: { d: Dashboard }) {
|
||||
const rows = d.recentOutput.filter((r) => r.status === "succeeded" || r.status === "partial").slice(0, 6);
|
||||
const today = d.automation.today.filesImported;
|
||||
return (
|
||||
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
|
||||
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
|
||||
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span>版本目标满足率</span>
|
||||
<span className="font-semibold tabular-nums">{rate === null ? "—" : `${rate}%`}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${rate ?? 0}%` }} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-meta text-muted-foreground">
|
||||
满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
||||
今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} ·
|
||||
升级 {a.today.upgrades}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
|
||||
function PlanCount({
|
||||
to,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex min-w-0 flex-col gap-1 rounded-xl border border-border/70 px-3 py-2.5 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-caption text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) {
|
||||
return (
|
||||
<span className={cn("text-lead font-semibold tabular-nums", tone ? "text-destructive" : "text-foreground")}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanStrip() {
|
||||
const board = useLiveSchedules();
|
||||
const s = planStats(board.data?.items ?? []);
|
||||
return (
|
||||
<Section
|
||||
title="计划"
|
||||
icon={CalendarCog}
|
||||
to={planPath()}
|
||||
extra={
|
||||
board.data && !board.data.schedulerOnline ? <span className="text-meta text-warning">调度器未在线</span> : null
|
||||
}
|
||||
>
|
||||
{board.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : board.isError ? (
|
||||
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PlanCount to={planPath({ section: "auto" })} label="自动任务" icon={CalendarClock}>
|
||||
<span>
|
||||
启用 <Num>{s.auto.enabled}</Num> / {s.auto.total}
|
||||
</span>
|
||||
<span>
|
||||
失败 <Num tone={s.auto.failed ? "destructive" : undefined}>{s.auto.failed}</Num>
|
||||
</span>
|
||||
</PlanCount>
|
||||
<PlanCount to={planPath({ section: "checks" })} label="Agent 巡检" icon={Bot}>
|
||||
<span>
|
||||
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
|
||||
</span>
|
||||
<span title="近 7 天巡检会话费用合计">
|
||||
近 7 天 <Num>{formatUsd(s.checks.weekCost)}</Num>
|
||||
</span>
|
||||
</PlanCount>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 最近完成 / 失败 ───
|
||||
function RecentRuns() {
|
||||
const done = useEngineRunsPage({ status: "completed" }, 6);
|
||||
const failed = useEngineRunsPage({ status: "failed" }, 6);
|
||||
const rows = [...(done.data?.items ?? []), ...(failed.data?.items ?? [])]
|
||||
.sort((x, y) => (y.finishedAt ?? y.createdAt).localeCompare(x.finishedAt ?? x.createdAt))
|
||||
.slice(0, 6);
|
||||
const loading = done.isPending || failed.isPending;
|
||||
const err = done.error ?? failed.error;
|
||||
return (
|
||||
<Section title="最近完成 / 失败" icon={ListTodo} to="/console/tasks">
|
||||
{loading ? (
|
||||
<LoadingState rows={3} />
|
||||
) : err ? (
|
||||
<ErrorState
|
||||
plain
|
||||
error={err}
|
||||
onRetry={() => {
|
||||
void done.refetch();
|
||||
void failed.refetch();
|
||||
}}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState plain icon={ListTodo} title="还没有结束的任务" className="py-6" />
|
||||
<Section title="最近入库" icon={LibraryIcon} to={consolePath("organize")}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState plain icon={LibraryIcon} title="最近没有新入库的作品" className="py-6" />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.map((r) => {
|
||||
const s = engineRunStatus(r);
|
||||
return (
|
||||
<Row key={r.id}>
|
||||
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{runTitle(r)}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">
|
||||
{workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)}
|
||||
{rows.map((r) => (
|
||||
<Row key={r.recordId}>
|
||||
<Link
|
||||
to={r.mediaEntityId ? `/console/library/${r.mediaEntityId}` : `/console/organize/${r.recordId}`}
|
||||
className="min-w-0 flex-1 hover:underline"
|
||||
>
|
||||
<span className="block truncate">{r.title ?? r.targetPath?.split("/").pop() ?? "未关联作品"}</span>
|
||||
{r.status === "partial" ? (
|
||||
<span className="block text-meta text-warning">
|
||||
{r.counts.succeeded}/{r.counts.total} 个文件入库
|
||||
</span>
|
||||
</Link>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
</Link>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{formatRelative(r.finishedAt)}</span>
|
||||
</Row>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{today ? <p className="mt-auto pt-3 text-meta text-muted-foreground">今天入库 {today} 个文件</p> : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ───
|
||||
const ACCOUNT: Record<string, { label: string; tone: StatusTone }> = {
|
||||
unconfigured: { label: "未配置账号", tone: "neutral" },
|
||||
unknown: { label: "账号未验证", tone: "neutral" },
|
||||
active: { label: "账号正常", tone: "success" },
|
||||
invalid: { label: "账号失效", tone: "destructive" },
|
||||
expired: { label: "登录过期", tone: "warning" },
|
||||
challenge: { label: "需人工验证", tone: "warning" },
|
||||
};
|
||||
|
||||
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
|
||||
const sites = useSitesWithAccounts();
|
||||
const siteRows = (sites.data?.items ?? s.sites).map((x) => {
|
||||
const acc = "accountStatus" in x && x.accountStatus ? ACCOUNT[x.accountStatus] : undefined;
|
||||
return {
|
||||
id: x.id,
|
||||
kind: "站点",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: "healthStatus" in x ? x.healthStatus : x.health,
|
||||
account: acc,
|
||||
to: `/console/sites/${x.id}`,
|
||||
};
|
||||
});
|
||||
const rows = [
|
||||
...siteRows,
|
||||
...s.feeds.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "Feed",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
account: undefined,
|
||||
to: consolePath("sources"),
|
||||
})),
|
||||
...s.downloaders.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "下载器",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
account: undefined,
|
||||
to: settingsPath("downloaders"),
|
||||
})),
|
||||
];
|
||||
const bad = (r: (typeof rows)[number]) =>
|
||||
(r.enabled && healthLabel(r.health).tone !== "success" ? 2 : 0) +
|
||||
(r.account && r.account.tone !== "success" ? 1 : 0);
|
||||
rows.sort((x, y) => bad(y) - bad(x));
|
||||
const accountIssues = siteRows.filter(
|
||||
(r) => r.account && (r.account.tone === "destructive" || r.account.tone === "warning"),
|
||||
).length;
|
||||
return (
|
||||
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
|
||||
) : (
|
||||
<>
|
||||
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.slice(0, 7).map((r) => {
|
||||
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
||||
return (
|
||||
<Row key={`${r.kind}-${r.id}`}>
|
||||
<Link to={r.to} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">
|
||||
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
||||
{r.name}
|
||||
</span>
|
||||
{r.account ? (
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-meta",
|
||||
r.account.tone === "destructive"
|
||||
? "text-destructive"
|
||||
: r.account.tone === "warning"
|
||||
? "text-warning"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.account.label}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 运行状态摘要(/system/health) ───
|
||||
const LEVEL: Record<string, { label: string; tone: StatusTone }> = {
|
||||
ok: { label: "正常", tone: "success" },
|
||||
warn: { label: "需要关注", tone: "warning" },
|
||||
critical: { label: "需要处理", tone: "destructive" },
|
||||
unknown: { label: "未知", tone: "neutral" },
|
||||
};
|
||||
|
||||
function StatusSummary() {
|
||||
const h = useSystemHealth(30_000);
|
||||
const d = h.data;
|
||||
const stale = d?.processes.filter((p) => p.stale) ?? [];
|
||||
const alive = (d?.processes.length ?? 0) - stale.length;
|
||||
const mountsBad = d?.mounts.filter((m) => m.status !== "online").length ?? 0;
|
||||
const lv = LEVEL[d?.level ?? "unknown"] ?? LEVEL.unknown;
|
||||
return (
|
||||
<Section
|
||||
title="运行状态"
|
||||
icon={Gauge}
|
||||
to={consolePath("status")}
|
||||
className="md:col-span-2 xl:col-span-3"
|
||||
extra={d ? <StatusBadge tone={lv.tone}>{lv.label}</StatusBadge> : null}
|
||||
>
|
||||
{h.isPending ? (
|
||||
<LoadingState rows={1} />
|
||||
) : h.isError ? (
|
||||
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
|
||||
) : d ? (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
|
||||
<span>
|
||||
进程 <span className="font-medium tabular-nums">{alive}</span> 在线
|
||||
{stale.length ? (
|
||||
<span className="text-warning">
|
||||
{" "}
|
||||
· {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")})
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span>
|
||||
待执行 <span className="font-medium tabular-nums">{d.engine.pending}</span>
|
||||
{d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""}
|
||||
</span>
|
||||
<span>
|
||||
24 小时失败率{" "}
|
||||
<span className="font-medium tabular-nums">
|
||||
{d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
数据库{" "}
|
||||
<span className="font-medium tabular-nums">{d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`}</span>
|
||||
</span>
|
||||
{mountsBad ? <span className="text-destructive">{mountsBad} 个挂载根不可用</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
||||
const volumeLabel = useVolumeLabel();
|
||||
return (
|
||||
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
|
||||
{c.volumes.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={HardDrive}
|
||||
title="还没有下载器"
|
||||
description="添加下载器后显示缓存水位。"
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{c.volumes.map((v) => {
|
||||
const pct = v.usedPercent;
|
||||
const over = pct !== null && pct >= v.hardThresholdPercent;
|
||||
const near = pct !== null && pct >= v.resumePercent;
|
||||
return (
|
||||
<li key={v.volumeKey} className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-medium">{volumeLabel(v)}</span>
|
||||
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
|
||||
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${pct ?? 0}%` }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 w-px bg-destructive"
|
||||
style={{ left: `${v.hardThresholdPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-meta text-muted-foreground">
|
||||
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
|
||||
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
|
||||
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
|
||||
</p>
|
||||
{v.notes.map((n) => (
|
||||
<p key={n} className="text-meta text-muted-foreground">
|
||||
{n}
|
||||
</p>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
to={settingsPath("automation", "retention")}
|
||||
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
调整阈值与保留天数
|
||||
</Link>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIndicator() {
|
||||
const s = useSseStatus();
|
||||
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
|
||||
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
|
||||
return <StatusBadge tone={tone}>{label}</StatusBadge>;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const q = useLiveDashboard();
|
||||
useLiveTasks();
|
||||
@@ -669,21 +329,15 @@ export default function Home() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="首页"
|
||||
description="正在进行的任务、需要处理的事项、计划和最近结果。"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<LiveIndicator />
|
||||
{q.data ? (
|
||||
<span className="hidden text-meta text-muted-foreground sm:inline">
|
||||
更新于 {formatRelative(q.data.generatedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
q.data ? (
|
||||
<span className="text-meta text-muted-foreground">更新于 {formatRelative(q.data.generatedAt)}</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{q.isPending ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{["a", "b", "c", "d", "e", "f"].map((k) => (
|
||||
{["a", "b", "c"].map((k) => (
|
||||
<div key={k} className="pg-slab rounded-xl p-5">
|
||||
<LoadingState rows={3} />
|
||||
</div>
|
||||
@@ -693,16 +347,11 @@ export default function Home() {
|
||||
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
|
||||
) : (
|
||||
<>
|
||||
<AutomationBanner a={q.data.automation} />
|
||||
<StatusLine d={q.data} />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<InProgress w={q.data.currentWork} />
|
||||
<Busy w={q.data.currentWork} />
|
||||
<NeedsMe n={q.data.needsAttention} />
|
||||
<MediaOverview a={q.data.automation} />
|
||||
<PlanStrip />
|
||||
<RecentRuns />
|
||||
<CacheSection c={q.data.cache} />
|
||||
<SourcesSection s={q.data.sources} />
|
||||
<StatusSummary />
|
||||
<RecentImports d={q.data} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
|
||||
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
|
||||
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw, Search } from "lucide-react";
|
||||
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -12,17 +12,20 @@ import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
|
||||
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx";
|
||||
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
@@ -78,7 +81,7 @@ function GroupReleases({ d }: { d: Decision }) {
|
||||
))}
|
||||
{count > list.length ? (
|
||||
<li className="text-meta text-muted-foreground">
|
||||
另有 {count - list.length} 条,可在媒体概览 → 待识别按标题检索
|
||||
另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
@@ -94,8 +97,8 @@ function entityOf(d: Decision): string | null {
|
||||
}
|
||||
|
||||
const IMPACT_KEY: Record<string, string> = {
|
||||
slots: "版本目标",
|
||||
units: "内容单元",
|
||||
slots: "收录版本",
|
||||
units: "季与集",
|
||||
files: "文件",
|
||||
releases: "资源",
|
||||
tasks: "任务",
|
||||
@@ -140,14 +143,21 @@ function Evidence({ d }: { d: Decision }) {
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 text-meta text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Agent 已检查
|
||||
智能体已检查
|
||||
</span>
|
||||
{checked.map((c) => (
|
||||
<Chip key={c}>{c}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<JsonView label="原始证据" value={e} defaultOpen={false} maxHeight="14rem" />
|
||||
<TechInfo
|
||||
items={[
|
||||
{ label: "事项 ID", value: d.id },
|
||||
{ label: "类型", value: d.kind },
|
||||
]}
|
||||
>
|
||||
<JsonView label="详细信息" value={e} defaultOpen={false} maxHeight="14rem" />
|
||||
</TechInfo>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -246,6 +256,82 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 事项里能拿来搜作品的名字:代表资源标题 / 下载名 / 标题冒号后半段 */
|
||||
function subjectName(d: Decision): string | null {
|
||||
const e = d.evidence;
|
||||
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
|
||||
return (
|
||||
str(first?.title) ??
|
||||
str(e.title) ??
|
||||
str(e.name) ??
|
||||
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
|
||||
);
|
||||
}
|
||||
|
||||
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
|
||||
function queryOf(name: string | null): string {
|
||||
if (!name) return "";
|
||||
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
|
||||
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
|
||||
return (m?.[1]?.trim() || base)
|
||||
.replace(/[[\]()]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
const IDENTITY_KINDS: DecisionKind[] = ["identity_ambiguous", "identity_unresolved"];
|
||||
|
||||
/** 「没识别出作品」的主操作:搜作品并指定。资源类事项走 resolve(本地作品 / TMDB),整理类事项打开重新整理 */
|
||||
function AssignWorkButton({ d }: { d: Decision }) {
|
||||
const resolve = useResolveDecision();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [wizard, setWizard] = useState(false);
|
||||
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
|
||||
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
|
||||
const recordId = d.subjectType === "organization_record" ? d.subjectId : null;
|
||||
if (!local && !tmdb && !recordId) return null;
|
||||
const name = subjectName(d);
|
||||
const assign = (w: AssignedWork) => {
|
||||
const option = "mediaEntityId" in w ? local : tmdb;
|
||||
if (!option) {
|
||||
toast.error("mediaEntityId" in w ? "这条事项只能按 TMDB 指定" : "这条事项只能选媒体库里的作品");
|
||||
return Promise.reject(new Error("unsupported"));
|
||||
}
|
||||
return resolve
|
||||
.mutateAsync({
|
||||
id: d.id,
|
||||
optionKey: option.key,
|
||||
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
|
||||
})
|
||||
.then(
|
||||
() => toast.success(`已指定为「${w.title}」`),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "指定失败"));
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => (recordId && !local && !tmdb ? setWizard(true) : setOpen(true))}>
|
||||
<Search />
|
||||
指定作品
|
||||
</Button>
|
||||
<WorkAssignDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
subject={name}
|
||||
initialQuery={queryOf(name)}
|
||||
onAssign={assign}
|
||||
/>
|
||||
{wizard && recordId ? (
|
||||
<OrganizeWizard source={{ kind: "record", recordId }} onClose={() => setWizard(false)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DismissButton({ d }: { d: Decision }) {
|
||||
const resolve = useResolveDecision();
|
||||
return (
|
||||
@@ -274,26 +360,34 @@ function DismissButton({ d }: { d: Decision }) {
|
||||
|
||||
function DecisionCard({ d }: { d: Decision }) {
|
||||
const open = OPEN.includes(d.status);
|
||||
const options = d.options.filter((o) => o.key !== "dismiss");
|
||||
const identity = IDENTITY_KINDS.includes(d.kind);
|
||||
// 身份类:手动 TMDB / 选本地作品两个选项并进「指定作品」
|
||||
const options = d.options.filter(
|
||||
(o) =>
|
||||
o.key !== "dismiss" && !(identity && (o.input?.requires === "tmdb" || o.input?.requires === "mediaEntityId")),
|
||||
);
|
||||
const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null;
|
||||
return (
|
||||
<article className="min-w-0 border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tone v={SEVERITY[d.severity]} />
|
||||
<Tone v={DECISION_STATUS[d.status]} />
|
||||
{d.severity === "critical" ? <Tone v={SEVERITY[d.severity]} /> : null}
|
||||
{d.status === "investigating" ? <Tone v={DECISION_STATUS[d.status]} /> : null}
|
||||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">
|
||||
{relTime(d.createdAt)}
|
||||
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
|
||||
{d.summary ? <p className="mt-1 text-caption break-words text-muted-foreground">{d.summary}</p> : null}
|
||||
{d.summary ? (
|
||||
<p className="mt-1 text-caption break-words text-muted-foreground">{humanizeReason(d.summary)}</p>
|
||||
) : null}
|
||||
<GroupReleases d={d} />
|
||||
<div className="mt-3">
|
||||
<Evidence d={d} />
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{identity ? <AssignWorkButton d={d} /> : null}
|
||||
{options.map((o) => (
|
||||
<OptionButton key={o.key} d={d} option={o} />
|
||||
))}
|
||||
@@ -303,7 +397,7 @@ function DecisionCard({ d }: { d: Decision }) {
|
||||
to={consolePath("agent", d.agentRunId)}
|
||||
className="ml-auto inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Agent 调查记录
|
||||
智能体调查记录
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : null}
|
||||
@@ -404,13 +498,15 @@ export default function InboxPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="待处理"
|
||||
description="Agent 调查和重试后仍未解决的事项,按作品和问题类型合并"
|
||||
description="需要你决定的事"
|
||||
actions={
|
||||
<>
|
||||
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
{live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"}
|
||||
</span>
|
||||
{live === "reconnecting" ? (
|
||||
<span className="hidden items-center gap-1.5 text-meta text-warning sm:inline-flex">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
重连中
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -426,27 +522,26 @@ export default function InboxPage() {
|
||||
{summary.isError ? (
|
||||
<ErrorState className="mb-4" title="汇总加载失败" error={summary.error} onRetry={() => summary.refetch()} />
|
||||
) : (
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatTile
|
||||
label={isOpenView ? "未关闭" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
|
||||
value={summary.isPending ? "…" : (summary.data?.total ?? 0)}
|
||||
hint={isOpenView ? "含调查中、等你决定" : undefined}
|
||||
icon={Inbox}
|
||||
active={!kind}
|
||||
onClick={() => set("kind", "")}
|
||||
/>
|
||||
<fieldset className="mb-4 flex min-w-0 flex-wrap items-center gap-1.5 border-0 p-0" aria-label="按问题类型筛选">
|
||||
<Button variant={kind ? "ghost" : "secondary"} size="sm" onClick={() => set("kind", "")} aria-pressed={!kind}>
|
||||
<Inbox />
|
||||
{isOpenView ? "全部" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
|
||||
<span className="tabular-nums">{summary.isPending ? "…" : (summary.data?.total ?? 0)}</span>
|
||||
</Button>
|
||||
{byKind.map(([k, v]) => (
|
||||
<StatTile
|
||||
<Button
|
||||
key={k}
|
||||
label={DECISION_KIND[k]}
|
||||
value={v.count}
|
||||
hint={`最高级别:${SEVERITY[v.sev].label}`}
|
||||
tone={SEVERITY[v.sev].tone}
|
||||
active={kind === k}
|
||||
variant={kind === k ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={kind === k}
|
||||
onClick={() => set("kind", kind === k ? "" : k)}
|
||||
/>
|
||||
className={v.sev === "critical" ? "text-destructive" : undefined}
|
||||
>
|
||||
{DECISION_KIND[k]}
|
||||
<span className="tabular-nums">{v.count}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<FilterBar onReset={status || kind || sev ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
|
||||
@@ -510,7 +605,7 @@ export default function InboxPage() {
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body font-semibold text-muted-foreground">
|
||||
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
|
||||
{IDENTITY_KINDS.includes(g.kind) ? "未关联作品" : "其他"}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
||||
|
||||
@@ -44,6 +44,7 @@ import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
|
||||
import {
|
||||
CONTENT_TYPE,
|
||||
@@ -86,11 +87,12 @@ import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
|
||||
// 内容单元、下载历史与校验结果,同一页同时展示。
|
||||
// MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按版本目标),
|
||||
// MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按收录版本),
|
||||
// 头部 / 主创 / 季集组件与仅 TMDB 的作品详情页共用(components/media/media-detail)。
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -127,13 +129,13 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const rootOf = (id: string | null) => roots.data?.items.find((r) => r.id === id);
|
||||
const bindings = detail.pathBindings.filter((b) => b.status !== "superseded");
|
||||
return (
|
||||
<Panel title="实际路径与库根" description="路径绑定建立后不随译名变化">
|
||||
<Panel title="文件位置">
|
||||
{bindings.length === 0 && detail.assets.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={FolderTree}
|
||||
title="还没有入库文件"
|
||||
description="版本目标满足后,文件位置显示在这里。"
|
||||
description="收录版本满足后,文件位置显示在这里。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
@@ -146,7 +148,9 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
<li key={b.id} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Chip>{b.bindingKind === "season" ? `第 ${b.seasonNumber} 季目录` : "作品目录"}</Chip>
|
||||
<span className="text-meta text-muted-foreground">库根 {root?.name ?? "未知库根"}</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
媒体库目录 {root?.name ?? "未知媒体库目录"}
|
||||
</span>
|
||||
{b.status === "conflict" ? <StatusBadge tone="destructive">绑定冲突</StatusBadge> : null}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-caption break-all text-foreground">
|
||||
@@ -163,22 +167,19 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
|
||||
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
|
||||
{a.asset.verifiedAt ? (
|
||||
<StatusBadge tone="success">已核验 {relTime(a.asset.verifiedAt)}</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge>未核验</StatusBadge>
|
||||
)}
|
||||
{a.asset.verifiedAt ? null : <StatusBadge>未核验</StatusBadge>}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{a.locations.map((l) => (
|
||||
<li key={l.id} className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
|
||||
{l.isPrimary ? <Chip>主位置</Chip> : null}
|
||||
<Tone v={LOCATION_STATUS[l.status]} />
|
||||
{l.locationKind !== "library" ? (
|
||||
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
|
||||
) : null}
|
||||
{l.status !== "available" ? <Tone v={LOCATION_STATUS[l.status]} /> : null}
|
||||
{l.libraryRootId ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{rootOf(l.libraryRootId)?.name ?? "库根"}
|
||||
{rootOf(l.libraryRootId)?.name ?? "媒体库目录"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -197,6 +198,8 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb", douban: "豆瓣" };
|
||||
|
||||
// ── 外部身份映射 ──
|
||||
function IdentityPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -204,8 +207,7 @@ function IdentityPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const candidates = detail.identities.filter((i) => i.status === "proposed" || i.status === "conflicted");
|
||||
return (
|
||||
<Panel
|
||||
title="外部身份"
|
||||
description="以本地 ID 为准,外部身份可修改"
|
||||
title="匹配的 TMDB 条目"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<PencilLine />
|
||||
@@ -245,10 +247,13 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
|
||||
return (
|
||||
<li className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-caption break-all text-foreground">
|
||||
{i.provider}:{i.entityType}:{i.externalId}
|
||||
<span
|
||||
className="text-caption break-all text-foreground"
|
||||
title={`${i.provider}:${i.entityType}:${i.externalId}`}
|
||||
>
|
||||
{PROVIDER_LABEL[i.provider] ?? i.provider} {i.externalId}
|
||||
</span>
|
||||
<Tone v={IDENTITY_STATUS[i.status]} />
|
||||
{i.status !== "accepted" ? <Tone v={IDENTITY_STATUS[i.status]} /> : null}
|
||||
{i.locked ? (
|
||||
<StatusBadge>
|
||||
<Lock className="h-3 w-3" />
|
||||
@@ -256,9 +261,12 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-meta text-muted-foreground">
|
||||
证据 {evidenceLabel(i.evidenceLevel)} · {VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus}
|
||||
{i.verifiedAt ? `(${relTime(i.verifiedAt)})` : ""} · r{i.revision}
|
||||
<p
|
||||
className="mt-1 text-meta text-muted-foreground"
|
||||
title={`证据 ${evidenceLabel(i.evidenceLevel)} · ${VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus} · r${i.revision}`}
|
||||
>
|
||||
{i.locked || i.evidenceLevel === "manual" ? "你确认的" : "自动匹配"}
|
||||
{i.verifiedAt ? ` · ${relTime(i.verifiedAt)}` : ""}
|
||||
</p>
|
||||
{reason ? <p className="mt-0.5 text-meta break-words text-muted-foreground">{reason}</p> : null}
|
||||
</li>
|
||||
@@ -365,29 +373,44 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
.filter((x) => x.final || !ENRICH_ONLY_FIELDS.has(x.field));
|
||||
const candidatesOf = (f: string) => (entity?.metadataFields ?? []).filter((m) => m.field === f);
|
||||
const current = editing ? finals.find((x) => x.field === editing)?.final?.value : undefined;
|
||||
const prefs = useUiPrefs();
|
||||
const [showSources, setShowSources] = useState(prefs.techInfoExpanded);
|
||||
const [allAliases, setAllAliases] = useState(false);
|
||||
// 别名:默认只列中英文与手动加的,其余折成 +N
|
||||
const aliases = entity?.aliases ?? [];
|
||||
const keyAliases = aliases.filter(
|
||||
(a) => a.locked || !a.language || /^(zh|en|cn)/i.test(a.language) || /[\p{Script=Han}]/u.test(a.alias),
|
||||
);
|
||||
const shownAliases = allAliases ? aliases : keyAliases.slice(0, 12);
|
||||
const moreAliases = aliases.length - shownAliases.length;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="字段来源与锁"
|
||||
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
|
||||
title="元数据"
|
||||
description={`更新于 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() =>
|
||||
refresh.mutate(
|
||||
{ full: true },
|
||||
{
|
||||
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
刷新元数据
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="ghost" size="sm" aria-pressed={showSources} onClick={() => setShowSources((v) => !v)}>
|
||||
{showSources ? "隐藏来源" : "显示来源"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() =>
|
||||
refresh.mutate(
|
||||
{ full: true },
|
||||
{
|
||||
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
刷新元数据
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="divide-y divide-border/60">
|
||||
@@ -400,14 +423,14 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
<p className="line-clamp-4 text-sm break-words text-foreground">
|
||||
{final ? fieldText(field, final.value) : <span className="text-muted-foreground">未提供</span>}
|
||||
</p>
|
||||
{final ? (
|
||||
{final && showSources ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
来源 {METADATA_SOURCE[final.source] ?? final.source}
|
||||
{final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}
|
||||
</p>
|
||||
) : null}
|
||||
{final ? <EvidenceLine field={cands.find((c) => c.source === final.source)} /> : null}
|
||||
{cands.length ? (
|
||||
{final && showSources ? <EvidenceLine field={cands.find((c) => c.source === final.source)} /> : null}
|
||||
{cands.length && showSources ? (
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground">
|
||||
候选:
|
||||
{cands
|
||||
@@ -461,15 +484,20 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
</ul>
|
||||
{entity?.aliases.length ? (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
|
||||
<p className="mb-1.5 text-meta text-muted-foreground">别名</p>
|
||||
<ChipList
|
||||
items={entity.aliases.map((a) => (
|
||||
items={shownAliases.map((a) => (
|
||||
<span key={a.id}>
|
||||
{a.alias}
|
||||
{a.locked ? <Lock className="ml-1 inline h-3 w-3" /> : null}
|
||||
</span>
|
||||
))}
|
||||
/>
|
||||
{moreAliases > 0 || allAliases ? (
|
||||
<Button variant="ghost" size="xs" className="mt-1" onClick={() => setAllAliases((v) => !v)}>
|
||||
{allAliases ? "收起" : `还有 ${moreAliases} 个`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<FieldLockDialog entityId={id} field={editing} current={current} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
@@ -481,12 +509,12 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
|
||||
if (detail.slots.length === 0) {
|
||||
return (
|
||||
<Panel title="版本目标">
|
||||
<Panel title="收录版本">
|
||||
<EmptyState
|
||||
plain
|
||||
icon={CalendarClock}
|
||||
title="还没有版本目标"
|
||||
description="新建订阅或版本目标后,按目标补齐和升级。"
|
||||
title="还没有收录版本"
|
||||
description="新建订阅或收录版本后,按目标补齐和升级。"
|
||||
action={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("subscriptions")}>去订阅</Link>
|
||||
@@ -499,7 +527,7 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
|
||||
}
|
||||
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
|
||||
return (
|
||||
<Panel title="版本目标" description="每个目标单独判断硬条件、软偏好和升级">
|
||||
<Panel title="收录版本" description="每个目标单独判断硬条件、软偏好和升级">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{detail.slots.map((s) => {
|
||||
const cur = assetOf(s.currentAssetId);
|
||||
@@ -547,7 +575,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
|
||||
cell: (u) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.status === "provisional" ? (
|
||||
<StatusBadge tone="warning">临时单元</StatusBadge>
|
||||
<StatusBadge tone="warning">编号待确认</StatusBadge>
|
||||
) : u.status === "retired" ? (
|
||||
<StatusBadge>已退役</StatusBadge>
|
||||
) : (
|
||||
@@ -582,7 +610,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
|
||||
a.segment.localeCompare(b.segment),
|
||||
);
|
||||
return (
|
||||
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
|
||||
<Panel title="季与集" bodyClassName="p-0 sm:p-0">
|
||||
<DataTable
|
||||
plain
|
||||
columns={columns}
|
||||
@@ -592,15 +620,15 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
|
||||
error={entity.error}
|
||||
onRetry={() => entity.refetch()}
|
||||
empty={{
|
||||
title: detail.entity.kind === "movie" ? "电影没有季和集" : "还没有内容单元",
|
||||
description: detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集后会建临时单元。",
|
||||
title: detail.entity.kind === "movie" ? "电影没有季和集" : "还没有季和集",
|
||||
description: detail.entity.kind === "movie" ? undefined : "识别到新的一集后会出现在这里。",
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按版本目标 ──
|
||||
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按收录版本 ──
|
||||
|
||||
const CELL_TONE: Record<CellKind, "success" | "warning" | "destructive" | "neutral"> = {
|
||||
have: "success",
|
||||
@@ -717,7 +745,7 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
}
|
||||
>
|
||||
{rows.length > 1 ? (
|
||||
<fieldset aria-label="按版本目标看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
|
||||
<fieldset aria-label="按收录版本看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
|
||||
{rows.map((r, i) => (
|
||||
<button
|
||||
key={r.slotId ?? "any"}
|
||||
@@ -931,7 +959,7 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
{r.targetPath ?? r.sourcePath ?? r.id}
|
||||
</Link>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "Agent" : "自动"} · {r.fileSucceeded}/
|
||||
{r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "智能体" : "自动"} · {r.fileSucceeded}/
|
||||
{r.fileTotal} 个文件 · {formatDateTime(r.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
@@ -1104,8 +1132,8 @@ export default function LibraryDetailPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||
<span className="font-mono text-meta break-all text-muted-foreground sm:ml-auto">{e.id}</span>
|
||||
</div>
|
||||
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
|
||||
|
||||
<div className="space-y-5">
|
||||
{credits.cast.length || credits.crew.length ? (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx"
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
|
||||
export const meta = () => [{ title: "媒体概览 · UHD 媒体自动化控制台" }];
|
||||
export const meta = () => [{ title: "媒体库 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const FILTERS: { value: NonNullable<LibraryListQuery["filter"]>; label: string }[] = [
|
||||
{ value: "gap", label: "有缺口" },
|
||||
@@ -61,8 +61,7 @@ export default function LibraryPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="媒体概览"
|
||||
description="本地账本:作品、版本目标、资产与外部身份。自动匹配不覆盖人工锁定的外部身份。"
|
||||
title="媒体库"
|
||||
actions={
|
||||
view === "unidentified" ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
@@ -77,7 +76,7 @@ export default function LibraryPage() {
|
||||
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams(VIEW_QUERY[v as View]), { replace: true })}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="works">作品</TabsTrigger>
|
||||
<TabsTrigger value="unidentified">待识别</TabsTrigger>
|
||||
<TabsTrigger value="unidentified">未识别</TabsTrigger>
|
||||
<TabsTrigger value="identities">外部身份</TabsTrigger>
|
||||
<TabsTrigger value="aliases">别名</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -155,10 +154,10 @@ function WorksView() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "slots", header: "版本目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
|
||||
{ key: "slots", header: "收录版本", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
|
||||
{
|
||||
key: "assets",
|
||||
header: "资产",
|
||||
header: "文件",
|
||||
align: "right",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="tabular-nums">{r.assetCount}</span>,
|
||||
@@ -214,7 +213,7 @@ function WorksView() {
|
||||
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选。", icon: LibraryIcon }
|
||||
: {
|
||||
title: "还没有作品",
|
||||
description: "扫描库根或新建订阅后,作品会出现在这里。",
|
||||
description: "扫描媒体库目录或新建订阅后,作品会出现在这里。",
|
||||
icon: LibraryIcon,
|
||||
action: (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
|
||||
@@ -89,10 +89,10 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
|
||||
{ provider: "tmdb", externalId: d.externalId, kind: d.entityType },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
toast.success(r.created ? `已建档「${r.title}」` : `「${r.title}」已在媒体库`);
|
||||
toast.success(r.created ? `已加入媒体库「${r.title}」` : `「${r.title}」已在媒体库`);
|
||||
navigate(consolePath("library", r.mediaEntityId));
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "建档失败")),
|
||||
onError: (e) => toast.error(errorMessage(e, "加入媒体库失败")),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -147,7 +147,7 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
|
||||
) : (
|
||||
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
|
||||
<FolderPlus />
|
||||
{entity.isPending ? "建档中…" : "建档不订阅"}
|
||||
{entity.isPending ? "加入中…" : "加入媒体库"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -197,13 +197,13 @@ function InventoryPanel({ d }: { d: TmdbMediaDetail }) {
|
||||
const inv = d.inventory;
|
||||
const line = slotLine(inv);
|
||||
return (
|
||||
<Panel title="库内对比" description="本地账本按 TMDB 身份对应;Emby 查询失败记为未知,不等于缺失">
|
||||
<Panel title="库中情况">
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">本地账本</dt>
|
||||
<dt className="text-meta text-muted-foreground">媒体库</dt>
|
||||
<dd className="mt-1 space-y-1 text-caption text-foreground">
|
||||
{inv.state === "not_collected" ? (
|
||||
<p>未收录。订阅或建档后开始追踪。</p>
|
||||
<p>不在媒体库。</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
@@ -283,7 +283,7 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
|
||||
});
|
||||
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
|
||||
return (
|
||||
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,不显示库内状态"}>
|
||||
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "不在媒体库"}>
|
||||
<div className="space-y-2">
|
||||
{seasons.map((s) => (
|
||||
<SeasonSection
|
||||
|
||||
@@ -8,9 +8,11 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { actorTypeLabel } from "~/lib/labels.ts";
|
||||
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
const ACTION_LABEL = {
|
||||
@@ -23,7 +25,6 @@ const ACTION_LABEL = {
|
||||
delete: "永久删除",
|
||||
restore: "从回收站还原",
|
||||
} as const;
|
||||
const VIEW_LABEL = { downloader: "下载器视角", local: "执行进程本地", library: "库根相对" } as const;
|
||||
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
|
||||
queued: { label: "排队", tone: "neutral" },
|
||||
running: { label: "执行中", tone: "neutral" },
|
||||
@@ -42,22 +43,14 @@ const ROLE_LABEL = {
|
||||
other: "其他",
|
||||
} as const;
|
||||
|
||||
/** 稳定 code 与说明不同时才附上 code */
|
||||
function reasonText(r: { code: string; message: string }) {
|
||||
return r.code && r.code !== r.message && /^[a-z0-9_.-]+$/i.test(r.code) ? `${r.message}(${r.code})` : r.message;
|
||||
}
|
||||
|
||||
/** failed:失败且没有目标路径时直接说"未生成目标路径",不再显示视角与"未知或未授权" */
|
||||
/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */
|
||||
function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) {
|
||||
const noTarget = failed && p.path === null;
|
||||
return (
|
||||
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{label}
|
||||
{noTarget ? "" : ` · ${VIEW_LABEL[p.view]}`}
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground">{label}</p>
|
||||
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
|
||||
{p.path ?? (noTarget ? "未生成目标路径" : "未知或未授权")}
|
||||
{p.path ?? (noTarget ? "未生成目标路径" : "未整理")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -107,13 +100,19 @@ function FileRow({ f }: { f: PublicOrganizationFile }) {
|
||||
</div>
|
||||
{reason ? (
|
||||
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
|
||||
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
|
||||
{f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
|
||||
{duration(f.startedAt, f.finishedAt) ? ` · 用时 ${duration(f.startedAt, f.finishedAt)}` : ""}
|
||||
</p>
|
||||
<TechInfo
|
||||
items={[
|
||||
{ label: "原因码", value: reasonCode(reason) },
|
||||
{ label: "用时", value: duration(f.startedAt, f.finishedAt), mono: false },
|
||||
{ label: "文件结果 ID", value: f.id },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
@@ -153,14 +152,15 @@ export default function OrganizeRecordPage() {
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
|
||||
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
|
||||
recordId: r.id,
|
||||
entityId: r.media?.entityId,
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />让 Agent 排查
|
||||
<Bot />
|
||||
让智能体排查
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
@@ -175,15 +175,17 @@ export default function OrganizeRecordPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
<StatusBadge>{ACTION_LABEL[r.action]}</StatusBadge>
|
||||
<StatusBadge>
|
||||
{TRIGGER_LABEL[r.trigger]}
|
||||
{r.trigger === "agent" ? "(计为自动)" : ""}
|
||||
</StatusBadge>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{r.counts.succeeded}/{r.counts.total} 成功 · 失败 {r.counts.failed} · 跳过 {r.counts.skipped}
|
||||
{[
|
||||
`${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`,
|
||||
r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null,
|
||||
r.counts.failed ? `${r.counts.failed} 个失败` : null,
|
||||
r.counts.skipped ? `${r.counts.skipped} 个跳过` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
{r.reason ? (
|
||||
@@ -195,7 +197,7 @@ export default function OrganizeRecordPage() {
|
||||
: "border-warning/40 bg-warning/10 text-warning",
|
||||
)}
|
||||
>
|
||||
{reasonText(r.reason)}
|
||||
{reasonLine(r.reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
@@ -207,19 +209,21 @@ export default function OrganizeRecordPage() {
|
||||
items={[
|
||||
{ label: "开始", value: formatDateTime(r.startedAt) },
|
||||
{ label: "完成", value: formatDateTime(r.finishedAt) },
|
||||
{ label: "用时", value: duration(r.startedAt, r.finishedAt) },
|
||||
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
|
||||
]}
|
||||
/>
|
||||
<TechInfo
|
||||
items={[
|
||||
{ label: "原因码", value: reasonCode(r.reason) },
|
||||
{ label: "用时", value: duration(r.startedAt, r.finishedAt), mono: false },
|
||||
{
|
||||
label: "触发者",
|
||||
value: `${actorTypeLabel(r.actor.type)}${r.actor.id && r.actor.type !== "system" ? ` · ${r.actor.id}` : ""}`,
|
||||
},
|
||||
{
|
||||
label: "外部身份",
|
||||
label: "外部编号",
|
||||
value: r.media?.externalIds.map((e) => `${e.provider}:${e.entityType}:${e.externalId}`).join(", "),
|
||||
mono: true,
|
||||
},
|
||||
{ label: "变更游标", value: r.changeSeq, mono: true },
|
||||
{ label: "记录 ID", value: r.id, mono: true, wide: true },
|
||||
{ label: "任务 ID", value: r.taskId, mono: true, wide: true },
|
||||
{ label: "操作者 ID", value: r.actor.type !== "system" ? r.actor.id : null },
|
||||
{ label: "变更序号", value: r.changeSeq },
|
||||
{ label: "记录 ID", value: r.id, wide: true },
|
||||
{ label: "任务 ID", value: r.taskId, wide: true },
|
||||
]}
|
||||
/>
|
||||
{r.media ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { reasonLine } from "~/lib/reasons.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
|
||||
const ACTION_LABEL = {
|
||||
@@ -63,7 +64,7 @@ const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
|
||||
{r.reason ? (
|
||||
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{r.reason.message}</span>
|
||||
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{reasonLine(r.reason)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
@@ -120,7 +121,6 @@ export default function OrganizePage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => navigate("/console/files")}>
|
||||
<FolderInput />
|
||||
|
||||
@@ -72,12 +72,11 @@ export default function RulesPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="规则"
|
||||
description="规则组决定哪些资源能下载、同一作品优先挑哪个。收录策略决定哪些新作品自动收录。"
|
||||
actions={
|
||||
tab === "policies" ? (
|
||||
<Button onClick={() => setCreatingPolicy(true)}>
|
||||
<FilePlus2 />
|
||||
新建策略草案
|
||||
新建
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={create}>
|
||||
@@ -104,9 +103,9 @@ export default function RulesPage() {
|
||||
<TabsList aria-label="规则分节">
|
||||
<TabsTrigger value="groups">规则组</TabsTrigger>
|
||||
<TabsTrigger value="atoms">自定义条件</TabsTrigger>
|
||||
<TabsTrigger value="defaults">默认值</TabsTrigger>
|
||||
<TabsTrigger value="policies">收录策略</TabsTrigger>
|
||||
<TabsTrigger value="suppressions">抑制记录</TabsTrigger>
|
||||
<TabsTrigger value="defaults">默认规则</TabsTrigger>
|
||||
<TabsTrigger value="policies">自动收新作品</TabsTrigger>
|
||||
<TabsTrigger value="suppressions">已忽略</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="groups">
|
||||
|
||||
@@ -18,7 +18,6 @@ export default function SchedulesPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="计划"
|
||||
description="定时任务的开关、间隔和下次运行。每次运行的结果在任务页。"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function SearchPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="搜索" description="先看作品和库内状态,需要时再到站点搜资源。" />
|
||||
<PageHeader title="搜索" />
|
||||
<form onSubmit={submit} className="mb-6 flex gap-2">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-xl">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="设置" description="全局参数、实例与凭证。凭证保存后不回显。" />
|
||||
<PageHeader title="设置" description="密钥保存后不再显示。" />
|
||||
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
|
||||
{/* 窄屏:顶部下拉 */}
|
||||
<div className="lg:hidden">
|
||||
|
||||
@@ -44,7 +44,7 @@ import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
|
||||
{ value: "resolved", label: "已识别" },
|
||||
{ value: "unresolved", label: "待识别" },
|
||||
{ value: "unresolved", label: "未识别" },
|
||||
{ value: "ambiguous", label: "有歧义" },
|
||||
{ value: "unidentifiable", label: "无法识别" },
|
||||
];
|
||||
@@ -213,7 +213,7 @@ export default function SiteWorkbenchPage() {
|
||||
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
|
||||
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
|
||||
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
|
||||
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
|
||||
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">抓取已过期</StatusBadge> : null}
|
||||
</span>
|
||||
) : (
|
||||
"加载中…"
|
||||
@@ -257,7 +257,7 @@ export default function SiteWorkbenchPage() {
|
||||
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
|
||||
>
|
||||
<Bot />
|
||||
<span className="sr-only sm:not-sr-only">问 Agent</span>
|
||||
<span className="sr-only sm:not-sr-only">问智能体</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export default function SiteWorkbenchPage() {
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
<TabsTrigger value="resources">资源</TabsTrigger>
|
||||
<TabsTrigger value="origin">原站视图</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">抓取与 RSS</TabsTrigger>
|
||||
<TabsTrigger value="accounts">账号</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -305,26 +305,26 @@ export default function SiteWorkbenchPage() {
|
||||
snap.mutate(
|
||||
{ id: siteId },
|
||||
{
|
||||
onSuccess: () => toast.success("快照已排队"),
|
||||
onSuccess: () => toast.success("已开始抓取"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera />
|
||||
快照
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!q || !s?.enabled || (live && releases.isFetching)}
|
||||
title={q ? "跳过本地快照,直接在站点搜索" : "先输入搜索词"}
|
||||
title={q ? "跳过本地资源,直接在站点搜索" : "先输入搜索词"}
|
||||
onClick={() => {
|
||||
if (live) void releases.refetch();
|
||||
else setLive(true);
|
||||
}}
|
||||
>
|
||||
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
|
||||
实时补查
|
||||
去站点搜
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -384,8 +384,8 @@ export default function SiteWorkbenchPage() {
|
||||
: quickHidden
|
||||
? "没有符合筛选条件的资源"
|
||||
: q
|
||||
? "本地快照里没有匹配的资源"
|
||||
: "还没有快照数据"
|
||||
? "本地资源里没有匹配的资源"
|
||||
: "还没抓取过"
|
||||
}
|
||||
description={
|
||||
all.length
|
||||
@@ -393,10 +393,10 @@ export default function SiteWorkbenchPage() {
|
||||
: quickHidden
|
||||
? "减少快速筛选条件再看。"
|
||||
: coverage?.liveError
|
||||
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
|
||||
? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。`
|
||||
: q
|
||||
? "点「实时补查」直接在站点搜索。"
|
||||
: "点「快照」抓取站点列表。"
|
||||
? "点「去站点搜」直接在站点搜索。"
|
||||
: "点「刷新」抓取站点列表。"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -452,11 +452,11 @@ export default function SiteWorkbenchPage() {
|
||||
<div className="space-y-8">
|
||||
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
|
||||
<DetailSection
|
||||
title="Feed"
|
||||
title="RSS"
|
||||
actions={
|
||||
<Button size="sm" variant="outline" onClick={() => setCreatingFeed(true)}>
|
||||
<Plus />
|
||||
新建 Feed
|
||||
新建 RSS
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -501,12 +501,12 @@ function CoverageBanner({
|
||||
notes.push({
|
||||
tone: "warning",
|
||||
text: coverage.lastSnapshotAt
|
||||
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
|
||||
: "没有快照,列表只来自 Feed 和实时补查",
|
||||
? `抓取已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
|
||||
: "还没抓取过,列表只来自 RSS 和站点搜索",
|
||||
});
|
||||
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError}` });
|
||||
if (coverage.liveError) notes.push({ tone: "warning", text: `去站点搜失败:${coverage.liveError}` });
|
||||
else if (coverage.live)
|
||||
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "已实时补查" });
|
||||
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "站点搜索完成" });
|
||||
}
|
||||
if (!notes.length) return null;
|
||||
return (
|
||||
|
||||
@@ -100,7 +100,6 @@ export default function SitesPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="站点"
|
||||
description="凭证、账号数据和健康状态;点站点名看资源、快照与 Feed"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import type { FeedHealth, Site } from "@umt/contracts";
|
||||
import { Globe, Plus, RefreshCw } from "lucide-react";
|
||||
import type { FeedHealth } from "@umt/contracts";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useFeeds } from "~/api/feeds.ts";
|
||||
import { snapshotStale, useSiteAccounts, useSites } from "~/api/sites.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { useSites } from "~/api/sites.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
// 来源健康:跨站点的 Feed 列表与站点健康总表;单个站点的快照、Feed、账号在站点详情。
|
||||
// RSS:跨站点的 RSS 列表;站点健康只在站点页(DEAI-2 去掉重复的站点表)。
|
||||
|
||||
export const meta = () => [{ title: "来源健康 · UHD 媒体自动化控制台" }];
|
||||
export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }];
|
||||
|
||||
// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed"
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
@@ -27,88 +22,24 @@ export default function SourcesPage() {
|
||||
const f = useQueryState(["health", "siteId"] as const);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const feeds = useFeeds({
|
||||
health: (f.values.health || undefined) as FeedHealth | undefined,
|
||||
siteId: f.values.siteId || undefined,
|
||||
});
|
||||
|
||||
const siteColumns: DataColumn<Site>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "站点",
|
||||
cell: (s) => (
|
||||
<Link
|
||||
to={`${consolePath("sites", s.id)}?tab=snapshots`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "health",
|
||||
header: "健康",
|
||||
cell: (s) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<StatusBadge tone={SITE_HEALTH[s.healthStatus].tone}>{SITE_HEALTH[s.healthStatus].label}</StatusBadge>
|
||||
{circuitLabel(s) ? <StatusBadge tone="warning">{circuitLabel(s)}</StatusBadge> : null}
|
||||
{!s.enabled && s.healthStatus !== "disabled" ? <StatusBadge>已停用</StatusBadge> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
header: "账号",
|
||||
hideBelow: "md",
|
||||
cell: (s) => {
|
||||
const a = accountSummary((accounts.data?.items ?? []).filter((x) => x.siteId === s.id));
|
||||
return <StatusBadge tone={a.tone}>{a.label}</StatusBadge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "snapshot",
|
||||
header: "最近快照",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => (
|
||||
<span className="flex flex-wrap items-center gap-1.5 whitespace-nowrap text-muted-foreground">
|
||||
{s.snapshotEnabled ? relTime(s.lastSnapshotAt) : "未开启"}
|
||||
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="来源健康"
|
||||
description="单个站点的快照、Feed 和账号在站点页管理。"
|
||||
title="RSS"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
新建 Feed
|
||||
新建 RSS
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<DetailSection title="站点">
|
||||
<DataTable
|
||||
columns={siteColumns}
|
||||
rows={sites.data?.items}
|
||||
rowKey={(s) => s.id}
|
||||
loading={sites.isPending}
|
||||
error={sites.error}
|
||||
onRetry={() => void sites.refetch()}
|
||||
empty={{
|
||||
icon: Globe,
|
||||
title: "还没有登记站点",
|
||||
description: "在站点页添加。",
|
||||
}}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="Feed">
|
||||
<DetailSection title="RSS 地址">
|
||||
<FilterBar
|
||||
onReset={f.active ? f.reset : undefined}
|
||||
actions={
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { HistoryChart } from "~/components/status/history-chart.tsx";
|
||||
import { StorageSection } from "~/components/status/storage-section.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -43,7 +44,7 @@ const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
api: "API",
|
||||
worker: "Worker(io)",
|
||||
"worker:agent": "Worker(Agent)",
|
||||
"worker:agent": "Worker(智能体)",
|
||||
all: "单进程(all)",
|
||||
};
|
||||
|
||||
@@ -51,13 +52,13 @@ const KIND_LABEL: Record<string, string> = {
|
||||
const KIND_NAME: Record<string, string> = {
|
||||
api: "API",
|
||||
worker: "io worker",
|
||||
"worker:agent": "Agent worker",
|
||||
"worker:agent": "智能体 worker",
|
||||
all: "单进程",
|
||||
};
|
||||
|
||||
const MOUNT_REASON: Record<string, string> = {
|
||||
root_missing: "目录不存在",
|
||||
sentinel_missing: "哨兵文件缺失",
|
||||
sentinel_missing: "目录不可访问或未挂载",
|
||||
permission_denied: "没有权限",
|
||||
error: "检查出错",
|
||||
timeout: "检查超时",
|
||||
@@ -227,7 +228,7 @@ function Processes({ d }: { d: SystemHealth }) {
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
io {p.concurrency.io ?? 0} · Agent {p.concurrency.agent ?? 0}
|
||||
io {p.concurrency.io ?? 0} · 智能体 {p.concurrency.agent ?? 0}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -371,7 +372,7 @@ function History() {
|
||||
|
||||
function Disks({ d }: { d: SystemHealth }) {
|
||||
const t = d.diskThresholds;
|
||||
const scope = { data_dir: "数据目录", library_root: "库根", download_dir: "下载目录" } as const;
|
||||
const scope = { data_dir: "数据目录", library_root: "媒体库目录", download_dir: "下载目录" } as const;
|
||||
return (
|
||||
<Card title="磁盘" icon={HardDrive} level={worst(d.disks.map((x) => x.level))}>
|
||||
<p className="mb-3 text-meta text-muted-foreground">
|
||||
@@ -428,7 +429,7 @@ function Mounts({ d }: { d: SystemHealth }) {
|
||||
return (
|
||||
<Card title="挂载根" icon={HardDrive} level={d.mounts.length ? (bad ? "critical" : "ok") : undefined}>
|
||||
{d.mounts.length === 0 ? (
|
||||
<p className="text-caption text-muted-foreground">没有启用的库根或下载目录映射。</p>
|
||||
<p className="text-caption text-muted-foreground">没有启用的媒体库目录或下载目录映射。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{d.mounts.map((m) => (
|
||||
@@ -436,7 +437,7 @@ function Mounts({ d }: { d: SystemHealth }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate">
|
||||
<span className="mr-1.5 text-meta text-muted-foreground">
|
||||
{m.kind === "library_root" ? "库根" : "下载目录"}
|
||||
{m.kind === "library_root" ? "媒体库目录" : "下载目录"}
|
||||
</span>
|
||||
{m.name}
|
||||
</p>
|
||||
@@ -465,7 +466,6 @@ export default function StatusPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="运行状态"
|
||||
description="进程心跳、数据库、磁盘、挂载根与存储。15 秒刷新一次。"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -485,16 +485,6 @@ export default function StatusPage() {
|
||||
) : d ? (
|
||||
<div className="space-y-4">
|
||||
<Banner d={d} />
|
||||
<section className="min-w-0">
|
||||
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
进程
|
||||
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
|
||||
</h2>
|
||||
<Processes d={d} />
|
||||
<RetiredProcesses rows={d.retiredProcesses} />
|
||||
</section>
|
||||
<History />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card title="数据库" icon={Database} level={d.db.level}>
|
||||
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}
|
||||
@@ -578,6 +568,18 @@ export default function StatusPage() {
|
||||
<Mounts d={d} />
|
||||
</div>
|
||||
<StorageSection health={d} />
|
||||
<TechInfo title="进程与趋势" columns={1}>
|
||||
<section className="min-w-0">
|
||||
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
进程
|
||||
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
|
||||
</h2>
|
||||
<Processes d={d} />
|
||||
<RetiredProcesses rows={d.retiredProcesses} />
|
||||
</section>
|
||||
<History />
|
||||
</TechInfo>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -39,7 +39,7 @@ import { errorMessage } from "~/lib/api.ts";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 版本目标矩阵;版本目标排序与自动补全;订阅信息。
|
||||
// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 收录版本矩阵;收录版本排序与自动补全;订阅信息。
|
||||
|
||||
export const meta = () => [{ title: "订阅详情 · UHD 媒体自动化控制台" }];
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function SubscriptionDetailPage() {
|
||||
{item?.group ? <StatusBadge>{GROUP_LABEL[item.group]}</StatusBadge> : null}
|
||||
{item?.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
|
||||
{item?.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
|
||||
{!sub.mediaEntityId ? <StatusBadge tone="warning">尚未建档</StatusBadge> : null}
|
||||
{!sub.mediaEntityId ? <StatusBadge tone="warning">不在媒体库</StatusBadge> : null}
|
||||
</div>
|
||||
{item ? (
|
||||
<div className="grid max-w-xl gap-2">
|
||||
@@ -163,7 +163,7 @@ export default function SubscriptionDetailPage() {
|
||||
<div className="space-y-5">
|
||||
<Panel
|
||||
title="覆盖对比"
|
||||
description={cov ? `按版本目标逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按版本目标逐集对比"}
|
||||
description={cov ? `按收录版本逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按收录版本逐集对比"}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={doRefresh} disabled={refresh.isPending || !sub.mediaEntityId}>
|
||||
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
|
||||
@@ -172,7 +172,7 @@ export default function SubscriptionDetailPage() {
|
||||
}
|
||||
>
|
||||
{!sub.mediaEntityId ? (
|
||||
<p className="text-caption text-muted-foreground">作品建档后才能对比。建档后自动关联到这个订阅。</p>
|
||||
<p className="text-caption text-muted-foreground">作品加入媒体库后才能对比,加入后自动关联到这个订阅。</p>
|
||||
) : covQ.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : covQ.isError ? (
|
||||
@@ -271,7 +271,7 @@ export default function SubscriptionDetailPage() {
|
||||
/>
|
||||
</Panel>
|
||||
{sub.mediaEntityId ? (
|
||||
<Panel title="版本目标规则" description="条件、阶梯与升级;新建的版本目标排在最后">
|
||||
<Panel title="收录版本规则" description="条件、阶梯与升级;新建的收录版本排在最后">
|
||||
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
@@ -275,63 +275,68 @@ export default function SubscriptionsPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<Pills
|
||||
label="分组"
|
||||
value={v.group as SubscriptionGroup | ""}
|
||||
onChange={(x) => f.set("group", x)}
|
||||
items={[
|
||||
{ value: "", label: "全部", count: allCount },
|
||||
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FilterBar
|
||||
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setQ("");
|
||||
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="站点"
|
||||
value={v.site}
|
||||
onChange={(x) => f.set("site", x)}
|
||||
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="规则组"
|
||||
value={v.rg}
|
||||
onChange={(x) => f.set("rg", x)}
|
||||
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={v.origin}
|
||||
onChange={(x) => f.set("origin", x)}
|
||||
options={opts(SUB_ORIGIN)}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="洗版"
|
||||
value={v.up}
|
||||
onChange={(x) => f.set("up", x)}
|
||||
options={[
|
||||
{ value: "true", label: "洗版中" },
|
||||
{ value: "false", label: "未洗版" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="排序"
|
||||
value={v.sort}
|
||||
onChange={(x) => f.set("sort", x)}
|
||||
allLabel="按分组"
|
||||
options={opts(SORT_LABEL)}
|
||||
/>
|
||||
</FilterBar>
|
||||
{/* 一条订阅都没有时只留空态和「新建订阅」,不铺筛选 */}
|
||||
{allCount === 0 && !filtered && !v.group && !v.kind ? null : (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<Pills
|
||||
label="分组"
|
||||
value={v.group as SubscriptionGroup | ""}
|
||||
onChange={(x) => f.set("group", x)}
|
||||
items={[
|
||||
{ value: "", label: "全部", count: allCount },
|
||||
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FilterBar
|
||||
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setQ("");
|
||||
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="站点"
|
||||
value={v.site}
|
||||
onChange={(x) => f.set("site", x)}
|
||||
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="规则组"
|
||||
value={v.rg}
|
||||
onChange={(x) => f.set("rg", x)}
|
||||
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={v.origin}
|
||||
onChange={(x) => f.set("origin", x)}
|
||||
options={opts(SUB_ORIGIN)}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="洗版"
|
||||
value={v.up}
|
||||
onChange={(x) => f.set("up", x)}
|
||||
options={[
|
||||
{ value: "true", label: "洗版中" },
|
||||
{ value: "false", label: "未洗版" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="排序"
|
||||
value={v.sort}
|
||||
onChange={(x) => f.set("sort", x)}
|
||||
allLabel="按分组"
|
||||
options={opts(SORT_LABEL)}
|
||||
/>
|
||||
</FilterBar>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selecting ? (
|
||||
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||
@@ -393,7 +398,7 @@ export default function SubscriptionsPage() {
|
||||
icon={CalendarClock}
|
||||
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
|
||||
description={
|
||||
filtered || v.group || v.kind ? undefined : "从媒体库、搜索或 Agent 添加,也可以点「新建订阅」。"
|
||||
filtered || v.group || v.kind ? undefined : "在搜索或媒体库里点「订阅」,或点右上角「新建订阅」。"
|
||||
}
|
||||
/>
|
||||
) : grouped ? (
|
||||
|
||||
@@ -59,7 +59,7 @@ const OP_KIND: Record<AssetOperation["kind"], string> = {
|
||||
delete_source: "删除源文件",
|
||||
delete_target: "删除目标文件",
|
||||
verify: "校验",
|
||||
register: "登记资产",
|
||||
register: "登记入库",
|
||||
qb_pause: "暂停下载",
|
||||
qb_resume: "恢复下载",
|
||||
qb_remove: "移除下载",
|
||||
|
||||
@@ -20,7 +20,7 @@ export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
type Tab = StreamTab;
|
||||
const TABS: Tab[] = ["all", "active", "failed"];
|
||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"];
|
||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
|
||||
|
||||
function Count({ n }: { n: number | undefined }) {
|
||||
if (!n) return null;
|
||||
@@ -99,7 +99,6 @@ export default function TasksPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="任务"
|
||||
description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
|
||||
<RotateCw />
|
||||
|
||||
Reference in new issue
Block a user