feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6280d52ec3
commit
7103bc8f7c
35 files changed
+6134
-45
No files matched your search
@@ -1,6 +1,31 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
export default function AgentRunPage() {
|
||||
const { runId = "" } = useParams();
|
||||
const run = useAgentRun(runId);
|
||||
const mock = agentMockEnabled();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
description={run.data ? <span className="line-clamp-2 break-words">{run.data.run.goal}</span> : "加载中…"}
|
||||
actions={
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={`/console/agent${mock ? "?mock=1" : ""}`}>
|
||||
<ArrowLeft />
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="pg-slab rounded-xl p-4 md:p-5">
|
||||
<AgentRunPanel runId={runId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,109 @@
|
||||
import { Bot, Send } from "lucide-react";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import { Bot, FlaskConical } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
||||
import { RunStatusBadge } from "~/components/agent/agent-conversation.tsx";
|
||||
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
const COLUMNS: DataColumn<AgentRunView>[] = [
|
||||
{
|
||||
key: "goal",
|
||||
header: "目标",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="line-clamp-2 break-words text-foreground">{r.goal}</p>
|
||||
{r.summary ? <p className="mt-0.5 line-clamp-1 text-meta text-muted-foreground">{r.summary}</p> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (r) => <RunStatusBadge status={r.status} /> },
|
||||
{
|
||||
key: "mode",
|
||||
header: "模式",
|
||||
hideBelow: "md",
|
||||
cell: (r) => ((r.mode ?? r.scope.mode) === "plan_only" ? "只要方案" : "执行"),
|
||||
},
|
||||
{ key: "tools", header: "工具调用", hideBelow: "md", align: "right", cell: (r) => r.toolCallCount },
|
||||
{
|
||||
key: "at",
|
||||
header: "开始",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="whitespace-nowrap text-muted-foreground">{relTime(r.createdAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export default function AgentPage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const runs = useAgentRuns((status || undefined) as AgentRunStatus | undefined);
|
||||
const create = useCreateAgentRun();
|
||||
const navigate = useNavigate();
|
||||
const mock = agentMockEnabled();
|
||||
const missing = !mock && isMissingEndpoint(runs.error);
|
||||
const rows = runs.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
/** Agent 会话页占位:消息流 + 输入框骨架,W2/W3 接入 agent_run 与 SSE 增量 */
|
||||
export default function AgentSession() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent 会话"
|
||||
description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。"
|
||||
/>
|
||||
<div className="flex min-h-[60vh] flex-col gap-4">
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
title="还没有会话"
|
||||
description="例如:“把某部电影的 1080p 收进来”。会话能力计划在 W2 接入。"
|
||||
className="flex-1"
|
||||
/>
|
||||
<form className="flex items-center gap-2" onSubmit={(e) => e.preventDefault()}>
|
||||
<Input disabled placeholder="Agent 尚未接入" className="flex-1" />
|
||||
<Button type="submit" disabled size="icon" aria-label="发送">
|
||||
<Send className="h-4 w-4" />
|
||||
{missing ? (
|
||||
<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" />
|
||||
<span className="min-w-0 flex-1">Agent 会话端点还没上线(W2-D 实现中)。可以先用 mock 数据预览交互。</span>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=1")}>
|
||||
用 mock 预览
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pg-slab mb-6 rounded-xl p-4">
|
||||
<GoalComposer
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
create.mutate(
|
||||
{ goal, mode },
|
||||
{
|
||||
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FilterBar onReset={status ? () => setStatus("") : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={Object.entries(RUN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
rows={missing ? [] : rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={runs.isPending}
|
||||
error={missing ? undefined : runs.error}
|
||||
onRetry={() => void runs.refetch()}
|
||||
empty={{
|
||||
icon: Bot,
|
||||
title: missing ? "会话端点未就绪" : "还没有会话",
|
||||
description: "例如:“把某部电影的 1080p 收进来”,或在任意页面右上角打开 Agent 侧栏。",
|
||||
}}
|
||||
onRowClick={(r) => navigate(`/console/agent/${r.id}${mock ? "?mock=1" : ""}`)}
|
||||
hasMore={runs.hasNextPage}
|
||||
onLoadMore={() => void runs.fetchNextPage()}
|
||||
loadingMore={runs.isFetchingNextPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "luc
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
||||
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
||||
import { ErrorState } from "~/components/console/empty-state.tsx";
|
||||
import { CONSOLE_BASE, findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
|
||||
@@ -222,6 +223,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AgentDrawerTrigger />
|
||||
<ThemeToggle />
|
||||
<AccountMenu session={session} />
|
||||
</div>
|
||||
@@ -232,6 +234,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<AgentDrawer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,234 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts";
|
||||
import { ArrowLeft, ArrowRight, Bot, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
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 { Button } from "~/components/ui/button.tsx";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const ACTION_LABEL = {
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
rename: "重命名",
|
||||
relocate: "目录调整",
|
||||
hardlink: "硬链接",
|
||||
} 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" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
skipped: { label: "跳过", tone: "neutral" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
};
|
||||
const ROLE_LABEL = {
|
||||
main: "主视频",
|
||||
subtitle: "字幕",
|
||||
audio: "音轨",
|
||||
bluray_dir: "原盘目录",
|
||||
nfo: "NFO",
|
||||
image: "图片",
|
||||
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;
|
||||
}
|
||||
|
||||
function PathBlock({ label, p }: { label: string; p: PublicPathRef }) {
|
||||
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} · {VIEW_LABEL[p.view]}
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-caption break-all text-foreground">{p.path ?? "未知或未授权"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function duration(a: string | null, b: string | null) {
|
||||
if (!a || !b) return null;
|
||||
const s = (new Date(b).getTime() - new Date(a).getTime()) / 1000;
|
||||
return s < 60 ? `${s.toFixed(1)} 秒` : `${Math.round(s / 60)} 分钟`;
|
||||
}
|
||||
|
||||
function FileRow({ f }: { f: PublicOrganizationFile }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const st = FILE_STATUS[f.status];
|
||||
const reason = f.failure ?? f.skipReason;
|
||||
return (
|
||||
<li className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{ROLE_LABEL[f.role]}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-meta text-foreground">
|
||||
{(f.target.path ?? f.source.path ?? "").split("/").pop()}
|
||||
</span>
|
||||
{f.sizeBytes !== null ? (
|
||||
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
|
||||
{formatBytes(f.sizeBytes, 1)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="space-y-2 px-3 pb-3 pl-9">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<PathBlock label="整理前" p={f.source} />
|
||||
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
|
||||
<PathBlock label="整理后" p={f.target} />
|
||||
</div>
|
||||
{reason ? (
|
||||
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
|
||||
{f.failure ? "失败" : "跳过"}原因:{reasonText(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>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrganizeRecordPage() {
|
||||
const { id = "" } = useParams();
|
||||
const rec = useRecord(id);
|
||||
const r = rec.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
description={
|
||||
r?.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : r ? "未关联作品" : "加载中…"
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/organize">
|
||||
<ArrowLeft />
|
||||
全部记录
|
||||
</Link>
|
||||
</Button>
|
||||
{r && (r.status === "failed" || r.status === "partial") ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
|
||||
recordId: r.id,
|
||||
entityId: r.media?.entityId,
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />让 Agent 排查
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{rec.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={5} />
|
||||
</div>
|
||||
) : rec.error || !r ? (
|
||||
<ErrorState error={rec.error} onRetry={() => void rec.refetch()} />
|
||||
) : (
|
||||
<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">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
{r.reason ? (
|
||||
<p
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-caption break-words",
|
||||
r.status === "failed"
|
||||
? "border-destructive/40 bg-destructive/10 text-destructive"
|
||||
: "border-warning/40 bg-warning/10 text-warning",
|
||||
)}
|
||||
>
|
||||
{reasonText(r.reason)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<PathBlock label="整理前" p={r.source} />
|
||||
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
|
||||
<PathBlock label="整理后" p={r.target} />
|
||||
</div>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "开始", value: formatDateTime(r.startedAt) },
|
||||
{ label: "完成", value: formatDateTime(r.finishedAt) },
|
||||
{ label: "用时", value: duration(r.startedAt, r.finishedAt) },
|
||||
{ label: "触发者", value: `${r.actor.type}${r.actor.id ? ` · ${r.actor.id}` : ""}` },
|
||||
{
|
||||
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 },
|
||||
]}
|
||||
/>
|
||||
{r.media ? (
|
||||
<Link
|
||||
to={`/console/library/${r.media.entityId}`}
|
||||
className="inline-block text-caption underline underline-offset-4"
|
||||
>
|
||||
查看作品
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="pg-slab overflow-hidden rounded-xl">
|
||||
<h2 className="px-4 pt-4 pb-2 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
文件明细({r.files?.length ?? 0})
|
||||
</h2>
|
||||
{r.files?.length ? (
|
||||
<ul className="divide-y divide-border/60 border-t border-border/60">
|
||||
{r.files.map((f) => (
|
||||
<FileRow key={f.id} f={f} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 pb-4 text-caption text-muted-foreground">
|
||||
{r.status === "queued" ? "排队中,尚未生成文件明细。" : "没有文件级结果。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,171 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { FileStack, FolderInput, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecords } from "~/api/records.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { OrganizeDialog } from "~/components/downloads/organize-dialog.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
const ACTION_LABEL = {
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
rename: "重命名",
|
||||
relocate: "目录调整",
|
||||
hardlink: "硬链接",
|
||||
} as const;
|
||||
|
||||
function Counts({ r }: { r: PublicOrganizationRecord }) {
|
||||
const c = r.counts;
|
||||
return (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
<span className="text-foreground">
|
||||
{c.succeeded}/{c.total}
|
||||
</span>
|
||||
{c.failed ? <span className="text-destructive"> · 失败 {c.failed}</span> : null}
|
||||
{c.skipped ? <span className="text-muted-foreground"> · 跳过 {c.skipped}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
|
||||
{
|
||||
key: "media",
|
||||
header: "作品 / 目标",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="truncate text-foreground">
|
||||
{r.media ? `${r.media.title}${r.media.year ? ` (${r.media.year})` : ""}` : "未关联作品"}
|
||||
{r.media?.seasonNumber != null
|
||||
? ` S${String(r.media.seasonNumber).padStart(2, "0")}${r.media.episodeNumber != null ? `E${String(r.media.episodeNumber).padStart(2, "0")}` : ""}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground" title={r.target.path ?? undefined}>
|
||||
{r.target.path ?? r.source.path ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "action", header: "动作", hideBelow: "sm", cell: (r) => ACTION_LABEL[r.action] },
|
||||
{ key: "files", header: "文件", hideBelow: "md", cell: (r) => <Counts r={r} /> },
|
||||
{ key: "trigger", header: "来源", hideBelow: "lg", cell: (r) => TRIGGER_LABEL[r.trigger] },
|
||||
{
|
||||
key: "at",
|
||||
header: "时间",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{relTime(r.finishedAt ?? r.startedAt ?? r.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function OrganizePage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [status, setStatus] = useState("");
|
||||
const [trigger, setTrigger] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [organizing, setOrganizing] = useState(false);
|
||||
const entityId = params.get("mediaEntityId") ?? undefined;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQ(qInput.trim()), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [qInput]);
|
||||
const records = useRecords({
|
||||
status: (status || undefined) as OrganizationStatus | undefined,
|
||||
trigger: (trigger || undefined) as OrganizationTrigger | undefined,
|
||||
mediaEntityId: entityId,
|
||||
q: q || undefined,
|
||||
});
|
||||
useSse([EventType.organizationChanged], () => void qc.invalidateQueries({ queryKey: ["records"] }));
|
||||
const rows = records.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = !!(status || trigger || q || entityId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="整理记录"
|
||||
description="Copy/Move 入库前后路径、动作与逐文件结果;自动与手动整理产生同格式记录。"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOrganizing(true)}>
|
||||
<FolderInput />
|
||||
手动整理
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<FilterBar
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "搜索作品或路径" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setStatus("");
|
||||
setTrigger("");
|
||||
setQInput("");
|
||||
setParams({});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={trigger}
|
||||
onChange={setTrigger}
|
||||
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
{entityId ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParams({})}>
|
||||
只看该作品
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={records.isPending}
|
||||
error={records.error}
|
||||
onRetry={() => void records.refetch()}
|
||||
onRowClick={(r) => navigate(`/console/organize/${r.id}`)}
|
||||
hasMore={records.hasNextPage}
|
||||
onLoadMore={() => void records.fetchNextPage()}
|
||||
loadingMore={records.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: FileStack,
|
||||
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||
description: "下载完成后自动整理,或点“手动整理”选择文件发起。",
|
||||
}}
|
||||
/>
|
||||
{organizing ? <OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,405 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowLeft, Bot, Camera, Globe, Radar, SearchX, TriangleAlert } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useInventory } from "~/api/inventory.ts";
|
||||
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
|
||||
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||||
import {
|
||||
accountSummary,
|
||||
categoryLabel,
|
||||
circuitLabel,
|
||||
hrOf,
|
||||
isFree,
|
||||
relTime,
|
||||
SITE_HEALTH,
|
||||
} from "~/components/workbench/release-fields.ts";
|
||||
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const PROMOS = [
|
||||
{ value: "free", label: "免费" },
|
||||
{ value: "2xfree", label: "2x 免费" },
|
||||
{ value: "50%", label: "50%" },
|
||||
{ value: "30%", label: "30%" },
|
||||
{ value: "2x", label: "2x 上传" },
|
||||
];
|
||||
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
|
||||
{ value: "resolved", label: "已识别" },
|
||||
{ value: "unresolved", label: "未识别" },
|
||||
{ value: "ambiguous", label: "有歧义" },
|
||||
{ value: "unidentifiable", label: "无法识别" },
|
||||
];
|
||||
const SIZE = [
|
||||
{ value: "lt5", label: "< 5 GB" },
|
||||
{ value: "5to20", label: "5–20 GB" },
|
||||
{ value: "20to60", label: "20–60 GB" },
|
||||
{ value: "gt60", label: "> 60 GB" },
|
||||
];
|
||||
const AGE = [
|
||||
{ value: "1", label: "24 小时内" },
|
||||
{ value: "7", label: "7 天内" },
|
||||
{ value: "30", label: "30 天内" },
|
||||
];
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
/** 服务端不支持的维度(H&R/做种/大小/上传时间)在已加载结果里筛选,界面上写明 */
|
||||
type LocalFilters = { hr: string; seeding: string; size: string; age: string };
|
||||
|
||||
function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) {
|
||||
if (f.hr === "no" && hrOf(r) !== "no") return false;
|
||||
if (f.hr === "yes" && hrOf(r) !== "yes") return false;
|
||||
if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false;
|
||||
if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false;
|
||||
if (f.size) {
|
||||
const s = r.sizeBytes;
|
||||
if (s === null) return false;
|
||||
if (f.size === "lt5" && s >= 5 * GB) return false;
|
||||
if (f.size === "5to20" && (s < 5 * GB || s >= 20 * GB)) return false;
|
||||
if (f.size === "20to60" && (s < 20 * GB || s >= 60 * GB)) return false;
|
||||
if (f.size === "gt60" && s < 60 * GB) return false;
|
||||
}
|
||||
if (f.age) {
|
||||
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
|
||||
if (t === null || now - t > Number(f.age) * 86_400_000) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, ms = 400) {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
export default function SiteWorkbenchPage() {
|
||||
const { siteId = "" } = useParams();
|
||||
const site = useSite(siteId);
|
||||
const accounts = useSiteAccounts(siteId);
|
||||
const snap = useSnapshotSite();
|
||||
|
||||
const [qInput, setQInput] = useState("");
|
||||
const q = useDebounced(qInput.trim());
|
||||
const [category, setCategory] = useState("");
|
||||
const [promotion, setPromotion] = useState("");
|
||||
const [resolution, setResolution] = useState("");
|
||||
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
|
||||
const [live, setLive] = useState(false);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const filters: ReleaseFilters = {
|
||||
q: q || undefined,
|
||||
category: category || undefined,
|
||||
promotion: promotion || undefined,
|
||||
resolution: (resolution || undefined) as ResolutionStatus | undefined,
|
||||
};
|
||||
const releases = useSiteReleases(siteId, filters, live);
|
||||
// 实时补查只对当前搜索生效一次
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
|
||||
useEffect(() => setLive(false), [q, category, promotion, resolution]);
|
||||
|
||||
const pages = releases.data?.pages ?? [];
|
||||
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
|
||||
const coverage = pages[0]?.coverage;
|
||||
const now = Date.now();
|
||||
const rows = all.filter((r) => localMatch(r, local, now));
|
||||
const localActive = Object.values(local).some(Boolean);
|
||||
const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : [])));
|
||||
const categories = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw);
|
||||
if (category && !m.has(category)) m.set(category, category);
|
||||
return [...m].map(([value, label]) => ({ value, label }));
|
||||
}, [all, category]);
|
||||
|
||||
const s = site.data;
|
||||
const refreshFailed = !!releases.error && !!releases.data;
|
||||
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
|
||||
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
|
||||
const selected = all.find((r) => r.id === openId) ?? null;
|
||||
const freeCount = all.filter(isFree).length;
|
||||
|
||||
const reset = () => {
|
||||
setQInput("");
|
||||
setCategory("");
|
||||
setPromotion("");
|
||||
setResolution("");
|
||||
setLocal({ hr: "", seeding: "", size: "", age: "" });
|
||||
};
|
||||
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
|
||||
|
||||
if (site.error && !s) return <ErrorState error={site.error} onRetry={() => void site.refetch()} />;
|
||||
|
||||
const health = s ? SITE_HEALTH[s.healthStatus] : null;
|
||||
const account = accountSummary(accounts.data?.items ?? []);
|
||||
const circuit = s ? circuitLabel(s) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={s?.name ?? "站点工作台"}
|
||||
description={
|
||||
s ? (
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{health ? <StatusBadge tone={health.tone}>{health.label}</StatusBadge> : null}
|
||||
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
|
||||
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只展示历史数据</StatusBadge> : null}
|
||||
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
|
||||
<span className="text-meta text-muted-foreground">
|
||||
快照 {relTime(coverage?.lastSnapshotAt ?? s.lastSnapshotAt)}
|
||||
</span>
|
||||
{snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
|
||||
</span>
|
||||
) : (
|
||||
"加载中…"
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/sites">
|
||||
<ArrowLeft />
|
||||
站点
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
|
||||
>
|
||||
<Bot />问 Agent
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="organized">
|
||||
<TabsList>
|
||||
<TabsTrigger value="organized">整理视图</TabsTrigger>
|
||||
<TabsTrigger value="origin">原站视图</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="organized">
|
||||
<FilterBar
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
|
||||
onReset={anyFilter ? reset : undefined}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!s?.enabled || snap.isPending}
|
||||
title="手动快照(受站点限流)"
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: siteId, maxPages: 2 },
|
||||
{
|
||||
onSuccess: () => toast.success("已排队快照,完成后刷新列表"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera />
|
||||
快照
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!q || !s?.enabled || (live && releases.isFetching)}
|
||||
title={q ? "绕过本地快照,向站点实时搜索当前关键词" : "先输入搜索词"}
|
||||
onClick={() => {
|
||||
if (live) void releases.refetch();
|
||||
else setLive(true);
|
||||
}}
|
||||
>
|
||||
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
|
||||
实时补查
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
|
||||
<FilterSelect label="促销" value={promotion} onChange={setPromotion} options={PROMOS} />
|
||||
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
|
||||
<FilterSelect
|
||||
label="H&R"
|
||||
value={local.hr}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, hr: v }))}
|
||||
options={[
|
||||
{ value: "no", label: "无 H&R" },
|
||||
{ value: "yes", label: "有 H&R" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="做种"
|
||||
value={local.seeding}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, seeding: v }))}
|
||||
options={[
|
||||
{ value: "alive", label: "有人做种" },
|
||||
{ value: "hot", label: "≥ 20 做种" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="大小"
|
||||
value={local.size}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, size: v }))}
|
||||
options={SIZE}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="上传"
|
||||
value={local.age}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, age: v }))}
|
||||
options={AGE}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
{coverage || refreshFailed ? (
|
||||
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
|
||||
) : null}
|
||||
|
||||
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
{releases.isPending ? (
|
||||
<LoadingState rows={6} className="p-4" />
|
||||
) : releases.error && !releases.data ? (
|
||||
<ErrorState
|
||||
plain
|
||||
title="读取站点资源失败"
|
||||
error={releases.error}
|
||||
onRetry={() => void releases.refetch()}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={all.length ? SearchX : Globe}
|
||||
title={
|
||||
all.length ? "已加载的结果里没有符合筛选的资源" : q ? "本地快照里没有匹配的资源" : "还没有快照数据"
|
||||
}
|
||||
description={
|
||||
all.length
|
||||
? "H&R / 做种 / 大小 / 上传时间只在已加载结果中筛选,可以加载更多或放宽条件。"
|
||||
: coverage?.liveError
|
||||
? `实时补查未成功(${coverage.liveError}),这不代表站点没有该资源。`
|
||||
: q
|
||||
? "可以点“实时补查”向站点直接搜索。"
|
||||
: "点“快照”抓取站点列表;快照受站点限流。"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
|
||||
<span>
|
||||
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
|
||||
</span>
|
||||
{freeCount ? <span>其中免费 {freeCount}</span> : null}
|
||||
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.map((r) => (
|
||||
<ReleaseRow
|
||||
key={r.id}
|
||||
r={r}
|
||||
entry={r.entity ? inventory.data?.get(r.entity.id) : undefined}
|
||||
inventoryLoading={inventory.isLoading}
|
||||
staleBefore={staleBefore}
|
||||
selected={openId === r.id}
|
||||
onOpen={() => setOpenId(r.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{releases.hasNextPage ? (
|
||||
<div className="flex justify-center border-t border-border/60 px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void releases.fetchNextPage()}
|
||||
disabled={releases.isFetchingNextPage}
|
||||
>
|
||||
{releases.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="origin">
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title="原站视图在 M2 提供"
|
||||
description={
|
||||
<>
|
||||
原站视图会接入后端浏览器会话(camofox-browser),在同一会话里查看真实页面、人工登录或处理验证,并与 Agent
|
||||
交接控制权。首版只提供整理视图;需要看原站时,整理视图的详情里保留了资源的站点 ID 与来源时间。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ReleaseDetailSheet
|
||||
siteId={siteId}
|
||||
release={selected}
|
||||
entry={selected?.entity ? inventory.data?.get(selected.entity.id) : undefined}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageBanner({
|
||||
coverage,
|
||||
refreshError,
|
||||
}: {
|
||||
coverage: import("@umt/contracts").WorkbenchCoverage | undefined;
|
||||
refreshError: unknown;
|
||||
}) {
|
||||
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
|
||||
if (refreshError)
|
||||
notes.push({ tone: "warning", text: `刷新失败,保留上次数据并标为过期:${errorMessage(refreshError)}` });
|
||||
if (coverage) {
|
||||
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只展示历史数据" });
|
||||
if (coverage.stale)
|
||||
notes.push({
|
||||
tone: "warning",
|
||||
text: coverage.lastSnapshotAt
|
||||
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)}),列表为既有数据`
|
||||
: "从未快照,列表只来自 Feed 观察与实时补查",
|
||||
});
|
||||
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError};已保留本地数据` });
|
||||
else if (coverage.live)
|
||||
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一实时搜索" : "已实时补查站点" });
|
||||
}
|
||||
if (!notes.length) return null;
|
||||
return (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{notes.map((n) => (
|
||||
<p
|
||||
key={n.text}
|
||||
className={
|
||||
n.tone === "warning"
|
||||
? "flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
: n.tone === "success"
|
||||
? "flex items-start gap-2 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-caption text-success"
|
||||
: "rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{n.tone === "warning" ? <TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span className="min-w-0 break-words">{n.text}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,159 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { Site, SiteAccount } from "@umt/contracts";
|
||||
import { ArrowRight, Camera, Globe, RefreshCw } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
const ADAPTER_LABEL: Record<Site["adapter"], string> = {
|
||||
nexusphp: "NexusPHP",
|
||||
mteam_api: "M-Team API",
|
||||
api: "站点 API",
|
||||
generic_rss: "通用 RSS",
|
||||
};
|
||||
|
||||
function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
|
||||
const toggle = useToggleSite();
|
||||
const snap = useSnapshotSite();
|
||||
const health = SITE_HEALTH[site.healthStatus];
|
||||
const account = accountSummary(accounts);
|
||||
const stale = site.snapshotEnabled && snapshotStale(site);
|
||||
const circuit = circuitLabel(site);
|
||||
|
||||
return (
|
||||
<div className="pg-slab flex min-w-0 flex-col rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/console/sites/${site.id}`}
|
||||
className="block truncate text-lead font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{site.name}
|
||||
</Link>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{site.key} · {ADAPTER_LABEL[site.adapter]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
|
||||
<label htmlFor={`site-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label>
|
||||
<Switch
|
||||
id={`site-${site.id}`}
|
||||
checked={site.enabled}
|
||||
disabled={toggle.isPending}
|
||||
aria-label={site.enabled ? "停用站点" : "启用站点"}
|
||||
onCheckedChange={(v) =>
|
||||
toggle.mutate(
|
||||
{ id: site.id, enabled: v },
|
||||
{
|
||||
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name},自动访问停止`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
|
||||
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
|
||||
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-caption">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">最近快照</dt>
|
||||
<dd className="flex flex-wrap items-center gap-1.5 text-foreground">
|
||||
{site.snapshotEnabled ? relTime(site.lastSnapshotAt) : "未开启快照"}
|
||||
{stale ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">快照周期</dt>
|
||||
<dd className="text-foreground">
|
||||
{site.snapshotIntervalSec ? `${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!site.enabled || snap.isPending}
|
||||
title={site.enabled ? "立即快照(受站点限流)" : "站点已停用"}
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: site.id },
|
||||
{
|
||||
onSuccess: () => toast.success("已排队快照,完成后自动刷新"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera />
|
||||
快照
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/console/sites/${site.id}`}>
|
||||
打开工作台
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SitesPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const bySite = new Map<string, SiteAccount[]>();
|
||||
for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="PT 站点工作台"
|
||||
description="按统一格式浏览站点资源,并叠加本地库存;下载走与自动收录相同的提交流程。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
|
||||
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{sites.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={4} />
|
||||
</div>
|
||||
) : sites.error && !sites.data ? (
|
||||
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
|
||||
) : !sites.data?.items.length ? (
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title="还没有登记站点"
|
||||
description="在“来源 / Feed / 快照”或系统设置里登记站点与账号后,这里会列出可浏览的站点。"
|
||||
action={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/console/sources">去来源页</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{sites.data.items.map((s) => (
|
||||
<SiteCard key={s.id} site={s} accounts={bySite.get(s.id) ?? []} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,320 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { FeedHealth, FeedSource } from "@umt/contracts";
|
||||
import { Plus, RefreshCw, RotateCcw, Rss, Zap } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { feedGapHint, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
||||
import { useSiteAccounts, useSites } from "~/api/sites.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.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 { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { FeedFormDialog, PURPOSE_LABEL } from "~/components/workbench/feed-form.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
const HEALTH: Record<FeedHealth, { label: string; tone: StatusTone }> = {
|
||||
unknown: { label: "未抓取", tone: "neutral" },
|
||||
healthy: { label: "健康", tone: "success" },
|
||||
degraded: { label: "降级", tone: "warning" },
|
||||
failing: { label: "失败", tone: "destructive" },
|
||||
circuit_open: { label: "熔断", tone: "destructive" },
|
||||
disabled: { label: "已停用", tone: "neutral" },
|
||||
};
|
||||
|
||||
const lastErrorText = (f: FeedSource) =>
|
||||
f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null;
|
||||
|
||||
/** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */
|
||||
function cursorSummary(f: FeedSource): string {
|
||||
const w = f.cursor.window as { lastFetchedAt?: string | null; lastGuidSet?: unknown[] } | undefined;
|
||||
if (!w) return "尚无游标";
|
||||
const n = Array.isArray(w.lastGuidSet) ? w.lastGuidSet.length : null;
|
||||
return `${n !== null ? `窗口 ${n} 条` : "窗口"}${w.lastFetchedAt ? ` · ${relTime(w.lastFetchedAt)}` : ""}`;
|
||||
}
|
||||
|
||||
function FeedActions({ f }: { f: FeedSource }) {
|
||||
const fetchNow = useFetchFeedNow();
|
||||
const action = useFeedAction();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Switch
|
||||
checked={f.enabled}
|
||||
disabled={action.isPending}
|
||||
aria-label={f.enabled ? "停用" : "启用"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(v) =>
|
||||
action.mutate(
|
||||
{ id: f.id, action: v ? "enable" : "disable" },
|
||||
{ onSuccess: () => toast.success(v ? "已启用" : "已停用"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="立即抓取(仍受站点限流)"
|
||||
aria-label="立即抓取"
|
||||
disabled={!f.enabled || fetchNow.isPending}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fetchNow.mutate(f.id, {
|
||||
onSuccess: () => toast.success("已排队抓取"),
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Zap />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SourcesPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [health, setHealth] = useState("");
|
||||
const [siteFilter, setSiteFilter] = useState("");
|
||||
const feeds = useFeeds({ health: (health || undefined) as FeedHealth | undefined, siteId: siteFilter || undefined });
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const [editing, setEditing] = useState<FeedSource | null | "new">(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const action = useFeedAction();
|
||||
const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name]));
|
||||
const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label]));
|
||||
const open = feeds.data?.items.find((f) => f.id === openId) ?? null;
|
||||
|
||||
const columns: DataColumn<FeedSource>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (f) => (
|
||||
<div className="min-w-0 max-w-xs">
|
||||
<p className="truncate font-medium text-foreground">{f.name}</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground" title={f.urlDisplay}>
|
||||
{f.urlDisplay}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "health",
|
||||
header: "健康",
|
||||
cell: (f) => {
|
||||
const gap = feedGapHint(f);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<StatusBadge tone={HEALTH[f.healthStatus].tone}>{HEALTH[f.healthStatus].label}</StatusBadge>
|
||||
{gap ? <StatusBadge tone="warning">疑似缺口</StatusBadge> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] },
|
||||
{
|
||||
key: "site",
|
||||
header: "站点 / 账号",
|
||||
hideBelow: "lg",
|
||||
cell: (f) => (
|
||||
<span className="text-muted-foreground">
|
||||
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
|
||||
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "poll",
|
||||
header: "轮询",
|
||||
hideBelow: "lg",
|
||||
cell: (f) => <span className="whitespace-nowrap">{Math.round(f.pollIntervalSec / 60)} 分钟</span>,
|
||||
},
|
||||
{
|
||||
key: "success",
|
||||
header: "最后成功",
|
||||
hideBelow: "sm",
|
||||
cell: (f) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<p className="text-foreground">{relTime(f.lastSuccessAt)}</p>
|
||||
{f.consecutiveFailures ? (
|
||||
<p className="text-meta text-destructive">连续失败 {f.consecutiveFailures}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "ops", header: "", align: "right", cell: (f) => <FeedActions f={f} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="来源 / Feed / 快照"
|
||||
description="RSS 与站点快照的抓取状态、新鲜度与健康;站点不可访问时不会把零条目当作“没有更新”。"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
新建 Feed
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Tabs defaultValue="feeds">
|
||||
<TabsList>
|
||||
<TabsTrigger value="feeds">Feed</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">站点快照</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="feeds">
|
||||
<FilterBar
|
||||
onReset={
|
||||
health || siteFilter
|
||||
? () => {
|
||||
setHealth("");
|
||||
setSiteFilter("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
|
||||
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="健康"
|
||||
value={health}
|
||||
onChange={setHealth}
|
||||
options={Object.entries(HEALTH).map(([value, h]) => ({ value, label: h.label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="站点"
|
||||
value={siteFilter}
|
||||
onChange={setSiteFilter}
|
||||
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={feeds.data?.items}
|
||||
rowKey={(f) => f.id}
|
||||
loading={feeds.isPending}
|
||||
error={feeds.error}
|
||||
onRetry={() => void feeds.refetch()}
|
||||
onRowClick={(f) => setOpenId(f.id)}
|
||||
selectedKey={openId}
|
||||
empty={{
|
||||
icon: Rss,
|
||||
title: health || siteFilter ? "没有符合筛选的 Feed" : "还没有 Feed",
|
||||
description: "新建 RSS Feed 后按轮询策略抓取,新条目进入识别与收录评估。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="snapshots">
|
||||
{sites.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : sites.error ? (
|
||||
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
|
||||
) : !sites.data.items.length ? (
|
||||
<EmptyState title="还没有登记站点" description="登记站点后可以在这里看快照覆盖范围与新鲜度。" />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{sites.data.items.map((s) => (
|
||||
<SnapshotPanel key={s.id} site={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DetailSheet
|
||||
open={!!open}
|
||||
onOpenChange={(o) => !o && setOpenId(null)}
|
||||
title={open?.name ?? ""}
|
||||
description={open ? <span className="font-mono break-all">{open.urlDisplay}</span> : undefined}
|
||||
footer={
|
||||
open ? (
|
||||
<>
|
||||
{open.healthStatus === "circuit_open" || open.healthStatus === "failing" || open.backoffUntil ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={action.isPending}
|
||||
onClick={() =>
|
||||
action.mutate(
|
||||
{ id: open.id, action: "reset" },
|
||||
{
|
||||
onSuccess: () => toast.success("已关闭熔断并清空退避"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw />
|
||||
复位熔断
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => setEditing(open)}>
|
||||
编辑
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{open ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone={HEALTH[open.healthStatus].tone}>{HEALTH[open.healthStatus].label}</StatusBadge>
|
||||
<StatusBadge>{open.enabled ? "已启用" : "已停用"}</StatusBadge>
|
||||
<StatusBadge>{PURPOSE_LABEL[open.purpose]}</StatusBadge>
|
||||
</div>
|
||||
{feedGapHint(open) ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
{feedGapHint(open)}
|
||||
</p>
|
||||
) : null}
|
||||
{lastErrorText(open) ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption break-words text-destructive">
|
||||
{lastErrorText(open)}
|
||||
</p>
|
||||
) : null}
|
||||
<DetailSection title="抓取状态">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "最后成功", value: formatDateTime(open.lastSuccessAt) },
|
||||
{ label: "最后抓取", value: formatDateTime(open.lastFetchAt) },
|
||||
{ label: "下次抓取", value: formatDateTime(open.nextFetchAt) },
|
||||
{ label: "退避至", value: formatDateTime(open.backoffUntil) },
|
||||
{ label: "连续失败", value: open.consecutiveFailures },
|
||||
{ label: "轮询间隔", value: `${Math.round(open.pollIntervalSec / 60)} 分钟` },
|
||||
{ label: "游标", value: cursorSummary(open) },
|
||||
{ label: "解析器", value: open.parserVersion, mono: true },
|
||||
{ label: "ETag", value: open.etag, mono: true },
|
||||
{ label: "Last-Modified", value: open.lastModified, mono: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<JsonView label="过滤条件" value={open.filter} />
|
||||
<JsonView label="游标原文" value={open.cursor} />
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
|
||||
{editing ? (
|
||||
<FeedFormDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
feed={editing === "new" ? null : editing}
|
||||
sites={sites.data?.items ?? []}
|
||||
accounts={accounts.data?.items ?? []}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,38 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useDownloadTask } from "~/api/downloads.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { DownloadDetail } from "~/components/downloads/download-detail.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
export default function DownloadTaskPage() {
|
||||
const { id = "" } = useParams();
|
||||
const task = useDownloadTask(id);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={task.data?.name ?? "下载任务"}
|
||||
description="下载 → 整理 → 核验的业务时间线与子任务"
|
||||
className="[&_h1]:break-all"
|
||||
actions={
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/tasks">
|
||||
<ArrowLeft />
|
||||
下载与任务
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="pg-slab rounded-xl p-4 md:p-5">
|
||||
{task.isPending ? (
|
||||
<LoadingState rows={5} />
|
||||
) : task.error ? (
|
||||
<ErrorState plain error={task.error} onRetry={() => void task.refetch()} />
|
||||
) : (
|
||||
<DownloadDetail task={task.data} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,308 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { AcquisitionPlan, DownloadStatus, DownloadTask, PlanStatus } from "@umt/contracts";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { Download, ListChecks, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import {
|
||||
DOWNLOAD_STATUS_LABEL,
|
||||
PLAN_STATUS_LABEL,
|
||||
useDownloaderNames,
|
||||
useDownloadTasks,
|
||||
usePlans,
|
||||
} from "~/api/downloads.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
||||
import {
|
||||
DownloadDetail,
|
||||
DownloadStatusBadges,
|
||||
PauseResume,
|
||||
ProgressLine,
|
||||
} from "~/components/downloads/download-detail.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const;
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function TasksPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [status, setStatus] = useState("");
|
||||
const [managed, setManaged] = useState("");
|
||||
const [planStatus, setPlanStatus] = useState("");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [openPlan, setOpenPlan] = useState<AcquisitionPlan | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const downloads = useDownloadTasks({
|
||||
status: (status || undefined) as DownloadStatus | undefined,
|
||||
managed: (managed || undefined) as "true" | "false" | undefined,
|
||||
});
|
||||
const plans = usePlans((planStatus || undefined) as PlanStatus | undefined);
|
||||
const names = useDownloaderNames();
|
||||
|
||||
// 下载进度/状态、阈值事件到达时刷新列表(SSE 为主,轮询兜底)
|
||||
useSse(
|
||||
[
|
||||
EventType.downloadProgress,
|
||||
EventType.downloadStatusChanged,
|
||||
EventType.downloadSubmitted,
|
||||
EventType.downloadThreshold,
|
||||
],
|
||||
() => void qc.invalidateQueries({ queryKey: ["downloads"] }),
|
||||
);
|
||||
|
||||
const rows = downloads.data?.pages.flatMap((p) => p.items);
|
||||
const planRows = plans.data?.pages.flatMap((p) => p.items);
|
||||
const open = rows?.find((t) => t.id === openId) ?? null;
|
||||
const linkedDownload = openPlan ? rows?.find((t) => t.acquisitionPlanId === openPlan.id) : undefined;
|
||||
|
||||
const columns: DataColumn<DownloadTask>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "任务",
|
||||
cell: (t) => (
|
||||
<div className="min-w-0 max-w-sm">
|
||||
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground">
|
||||
{names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (t) => <DownloadStatusBadges t={t} /> },
|
||||
{ key: "progress", header: "进度", hideBelow: "sm", cell: (t) => <ProgressLine t={t} /> },
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
hideBelow: "lg",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "更新",
|
||||
hideBelow: "md",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">{relTime(t.lastSyncedAt ?? t.updatedAt)}</span>
|
||||
),
|
||||
},
|
||||
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
|
||||
];
|
||||
|
||||
const planColumns: DataColumn<AcquisitionPlan>[] = [
|
||||
{
|
||||
key: "purpose",
|
||||
header: "计划",
|
||||
cell: (p) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground">
|
||||
{PURPOSE[p.purpose]} · 优先级 {p.priority}
|
||||
</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground">{p.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (p) => (
|
||||
<StatusBadge
|
||||
tone={
|
||||
p.status === "failed"
|
||||
? "destructive"
|
||||
: p.status === "completed"
|
||||
? "success"
|
||||
: p.status === "expired"
|
||||
? "warning"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{PLAN_STATUS_LABEL[p.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reserved",
|
||||
header: "预留空间",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (p) => <span className="tabular-nums">{formatBytes(p.reservedBytes, 1)}</span>,
|
||||
},
|
||||
{
|
||||
key: "reason",
|
||||
header: "说明",
|
||||
hideBelow: "lg",
|
||||
cell: (p) => <span className="line-clamp-1 text-muted-foreground">{p.statusReason ?? "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "创建",
|
||||
hideBelow: "sm",
|
||||
cell: (p) => <span className="whitespace-nowrap text-muted-foreground">{relTime(p.createdAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="下载与任务"
|
||||
description="下载、整理、核验串成一条业务时间线;阈值暂停不能手动绕过,无进展只记录不自动换源。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
|
||||
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CacheVolumes />
|
||||
<Tabs defaultValue="downloads">
|
||||
<TabsList>
|
||||
<TabsTrigger value="downloads">下载任务</TabsTrigger>
|
||||
<TabsTrigger value="plans">下载计划</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="downloads">
|
||||
<FilterBar
|
||||
onReset={
|
||||
status || managed
|
||||
? () => {
|
||||
setStatus("");
|
||||
setManaged("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="纳管"
|
||||
value={managed}
|
||||
onChange={setManaged}
|
||||
options={[
|
||||
{ value: "true", label: "纳管任务" },
|
||||
{ value: "false", label: "未纳管" },
|
||||
]}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(t) => t.id}
|
||||
loading={downloads.isPending}
|
||||
error={downloads.error}
|
||||
onRetry={() => void downloads.refetch()}
|
||||
onRowClick={(t) => setOpenId(t.id)}
|
||||
selectedKey={openId}
|
||||
hasMore={downloads.hasNextPage}
|
||||
onLoadMore={() => void downloads.fetchNextPage()}
|
||||
loadingMore={downloads.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: Download,
|
||||
title: status || managed ? "没有符合筛选的下载" : "还没有下载任务",
|
||||
description: "自动收录或在站点工作台手动下载后,任务会出现在这里。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="plans">
|
||||
<FilterBar onReset={planStatus ? () => setPlanStatus("") : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={planStatus}
|
||||
onChange={setPlanStatus}
|
||||
options={Object.entries(PLAN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={planColumns}
|
||||
rows={planRows}
|
||||
rowKey={(p) => p.id}
|
||||
loading={plans.isPending}
|
||||
error={plans.error}
|
||||
onRetry={() => void plans.refetch()}
|
||||
onRowClick={setOpenPlan}
|
||||
selectedKey={openPlan?.id}
|
||||
hasMore={plans.hasNextPage}
|
||||
onLoadMore={() => void plans.fetchNextPage()}
|
||||
loadingMore={plans.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: ListChecks,
|
||||
title: "还没有下载计划",
|
||||
description: "规则评估选中候选后生成计划并预留 Slot 与预算。",
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DetailSheet
|
||||
open={!!open}
|
||||
onOpenChange={(o) => !o && setOpenId(null)}
|
||||
width="lg"
|
||||
title={open?.name ?? ""}
|
||||
footer={
|
||||
open ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/tasks/${open.id}`}>在独立页面打开</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{open ? <DownloadDetail task={open} /> : null}
|
||||
</DetailSheet>
|
||||
|
||||
<DetailSheet
|
||||
open={!!openPlan}
|
||||
onOpenChange={(o) => !o && setOpenPlan(null)}
|
||||
title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""}
|
||||
description={openPlan?.statusReason ?? undefined}
|
||||
footer={
|
||||
linkedDownload ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpenPlan(null);
|
||||
navigate(`/console/tasks/${linkedDownload.id}`);
|
||||
}}
|
||||
>
|
||||
查看下载任务
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{openPlan ? (
|
||||
<>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: PLAN_STATUS_LABEL[openPlan.status] },
|
||||
{ label: "优先级", value: openPlan.priority },
|
||||
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
|
||||
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
|
||||
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
|
||||
{ label: "Slot", value: openPlan.slotId, mono: true },
|
||||
{ label: "资源", value: openPlan.sourceReleaseId, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
<DetailSection title="评估依据">
|
||||
<JsonView value={openPlan.evaluation} />
|
||||
</DetailSection>
|
||||
<JsonView label="预算预留" value={openPlan.budgetReservation} />
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user