diff --git a/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts index 45090d35..7e97533c 100644 --- a/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts +++ b/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts @@ -445,6 +445,12 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临 expect(dash.body.data.recentOutput.length).toBeGreaterThan(0); expect(dash.body.data.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 }); expect(dash.body.data.needsAttention.total).toBeGreaterThan(0); + const auto = dash.body.data.automation; + expect(auto.library.entities).toBeGreaterThanOrEqual(1); + expect(auto.library.movies).toBeGreaterThanOrEqual(1); + expect(auto.library.assets).toBeGreaterThanOrEqual(1); + const slotTotal = Object.values(auto.slotCounts as Record).reduce((a, b) => a + b, 0); + expect(auto.slotSatisfiedRate).toBe(slotTotal ? auto.slotCounts.satisfied / slotTotal : null); const libList = await h.api("GET", "/library?q=沙丘"); expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 }); const det = await h.api("GET", `/library/${movieId}`); diff --git a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts index 5bb9087f..0180111c 100644 --- a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts +++ b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts @@ -63,6 +63,26 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) { attention: 0, }; for (const r of slotRows) slotCounts[r.status] = r.n; + const slotTotal = Object.values(slotCounts).reduce((a, b) => a + b, 0); + const library = ( + await sql<{ + entities: number; + movies: number; + series: number; + seasons: number; + episodes: number; + assets: number; + }>` + select + (select count(*) from media_entity where status <> 'merged')::int as entities, + (select count(*) from media_entity where status <> 'merged' and kind = 'movie')::int as movies, + (select count(*) from media_entity where status <> 'merged' and kind in ('series','variety'))::int as series, + (select count(*) from content_unit where kind = 'season' and status <> 'retired')::int as seasons, + (select count(*) from content_unit where kind = 'episode' and status <> 'retired')::int as episodes, + (select count(*) from media_asset where status = 'active' and kind in ('video','bluray_dir'))::int as assets`.execute( + db, + ) + ).rows[0]!; const dlRows = await db .selectFrom("download_task") @@ -203,6 +223,8 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) { }, activeSubscriptions: today.subs, slotCounts, + slotSatisfiedRate: slotTotal ? slotCounts.satisfied / slotTotal : null, + library, }, currentWork: { downloading: count("downloading"), diff --git a/apps/server/src/modules/auth-settings/engine-routes.ts b/apps/server/src/modules/auth-settings/engine-routes.ts index b98b0d74..ced23e85 100644 --- a/apps/server/src/modules/auth-settings/engine-routes.ts +++ b/apps/server/src/modules/auth-settings/engine-routes.ts @@ -114,7 +114,7 @@ const RUN_COLS = sql.raw(`id, workflow_name, version, status, attempts, worker_i export interface EngineQueries { listRuns( q: z.infer, - ): Promise<{ items: ReturnType[]; nextCursor: string | null }>; + ): Promise<{ items: ReturnType[]; nextCursor: string | null; cursors: string[] }>; getRun(id: string): Promise | null>; stats(): Promise>; } @@ -127,7 +127,7 @@ export function engineQueries(db: Kysely, schema = "openworkflow"): EngineQu async listRuns(q) { const cur: Cursor | null = decodeCursor(q.cursor); const conds = [sql`namespace_id = ${NAMESPACE}`]; - if (q.status) conds.push(sql`status = ${q.status}`); + if (q.status?.length) conds.push(sql`status = any(${q.status}::text[])`); if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`); if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`); if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`); @@ -144,6 +144,8 @@ export function engineQueries(db: Kysely, schema = "openworkflow"): EngineQu items: items.map(runView), nextCursor: rows.rows.length > q.limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null, + // 每行的游标(triggeredBy 逐页筛选时在中途截断用) + cursors: items.map((r) => encodeCursor({ us: Number(r.cursor_us), id: r.id })), }; }, @@ -242,6 +244,10 @@ export function engineQueries(db: Kysely, schema = "openworkflow"): EngineQu }; } +/** triggeredBy 筛选:每批扫描行数与单次请求扫描上限 */ +const TRIGGER_SCAN_BATCH = 200; +const TRIGGER_SCAN_CAP = 2000; + export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: { schema?: string } = {}) { let q: EngineQueries | null = null; const queries = () => { @@ -251,8 +257,32 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: { // W9-I:列表与详情补 business 字段(engine-business.ts;查询失败降级为 null) const schema = opts.schema ?? "openworkflow"; endpoint(app, D.runs, async ({ query }) => { - const page = await queries().listRuns(query); - return { ...page, items: await attachBusiness(ctx.db, schema, page.items) }; + const triggered = query.triggeredBy?.length ? new Set(query.triggeredBy) : null; + if (!triggered) { + const page = await queries().listRuns(query); + return { items: await attachBusiness(ctx.db, schema, page.items), nextCursor: page.nextCursor }; + } + // 触发来源是逐行推断的(engine-business.ts),不能下推成 SQL:按批扫描、补字段后筛选, + // 凑够 limit 或扫满 TRIGGER_SCAN_CAP 行就返回;游标停在最后检查的那一行,可能少于 limit 但仍有下一页 + const out: Awaited>>> = []; + let cursor = query.cursor; + let scanned = 0; + for (;;) { + const page = await queries().listRuns({ ...query, cursor, limit: TRIGGER_SCAN_BATCH }); + const items = await attachBusiness(ctx.db, schema, page.items); + for (let i = 0; i < items.length; i++) { + scanned += 1; + const r = items[i]!; + if (r.business?.triggeredBy && triggered.has(r.business.triggeredBy)) out.push(r); + if (out.length === query.limit) { + const more = i < items.length - 1 || page.nextCursor !== null; + return { items: out, nextCursor: more ? page.cursors[i]! : null }; + } + } + if (!page.nextCursor) return { items: out, nextCursor: null }; + cursor = page.nextCursor; + if (scanned >= TRIGGER_SCAN_CAP) return { items: out, nextCursor: cursor }; + } }); endpoint(app, D.run, async ({ params }) => { const r = await queries().getRun(params.id); diff --git a/apps/server/test/auth-settings/admin-api.test.ts b/apps/server/test/auth-settings/admin-api.test.ts index 3b87d28c..5895cc5f 100644 --- a/apps/server/test/auth-settings/admin-api.test.ts +++ b/apps/server/test/auth-settings/admin-api.test.ts @@ -298,6 +298,9 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => { expect(p2.data.items.map((r: { id: string }) => r.id)).toEqual(["r1"]); expect(p2.data.nextCursor).toBeNull(); expect((await admin("GET", "/engine/runs?status=bogus")).status).toBe(422); + const multi = await json(await admin("GET", "/engine/runs?status=running,pending")); + expect(multi.data.items.map((r: { id: string }) => r.id)).toEqual(["r4", "r3"]); + expect((await admin("GET", "/engine/runs?status=running,bogus")).status).toBe(422); }); it("run 详情:step attempts + signals;敏感字段脱敏,不返回 config/context", async () => { diff --git a/apps/server/test/system/system.test.ts b/apps/server/test/system/system.test.ts index 8724dc1a..895085d5 100644 --- a/apps/server/test/system/system.test.ts +++ b/apps/server/test/system/system.test.ts @@ -353,6 +353,28 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => { expect(s.data.items.map((r: { id: string }) => r.id)).toEqual(["b4"]); expect((await admin("GET", "/engine/runs?businessKind=bogus")).status).toBe(422); + // triggeredBy:服务端逐页推断后筛选;多值逗号分隔;limit 截断时游标接着扫 + const range = "from=2026-09-10T00:00:00Z&to=2026-09-11T00:00:00Z"; + const ids = (r: { data: { items: { id: string }[] } }) => r.data.items.map((x) => x.id); + expect(ids(await json(await admin("GET", `/engine/runs?${range}&triggeredBy=schedule`)))).toEqual(["b4", "b2"]); + expect(ids(await json(await admin("GET", `/engine/runs?${range}&triggeredBy=event,api_key`)))).toEqual([ + "b3", + "b1", + ]); + const t1 = await json(await admin("GET", `/engine/runs?${range}&triggeredBy=schedule&limit=1`)); + expect(ids(t1)).toEqual(["b4"]); + expect(t1.data.nextCursor).toBeTruthy(); + const t2 = await json( + await admin("GET", `/engine/runs?${range}&triggeredBy=schedule&limit=1&cursor=${t1.data.nextCursor}`), + ); + expect(ids(t2)).toEqual(["b2"]); + const t3 = await json( + await admin("GET", `/engine/runs?${range}&triggeredBy=schedule&limit=1&cursor=${t2.data.nextCursor}`), + ); + expect(ids(t3)).toEqual([]); + expect(t3.data.nextCursor).toBeNull(); + expect((await admin("GET", "/engine/runs?triggeredBy=bogus")).status).toBe(422); + const detail = (await json(await admin("GET", "/engine/runs/b2"))).data; expect(detail.business).toMatchObject({ kind: "agent", refId: agentId }); expect(detail.stepAttempts).toEqual([]); diff --git a/apps/web/app/api/engine.ts b/apps/web/app/api/engine.ts index b0cf33b6..8f2c0c5e 100644 --- a/apps/web/app/api/engine.ts +++ b/apps/web/app/api/engine.ts @@ -5,7 +5,10 @@ import { qk } from "~/lib/query-keys.ts"; import { errorCode } from "./parse.ts"; export type EngineRunFilters = { + /** 单值或逗号分隔多值(running,pending) */ status?: string; + /** 触发来源,单值或逗号分隔多值(user,api_key) */ + triggeredBy?: string; workflowName?: string; businessKind?: EngineBusinessKind; businessRefId?: string; @@ -16,9 +19,10 @@ export type EngineRunFilters = { /** 终态:不能再 cancel */ export const ENGINE_TERMINAL = new Set(["completed", "succeeded", "failed", "canceled"]); -export function useEngineRuns(filters: EngineRunFilters, enabled = true) { +export function useEngineRuns(filters: EngineRunFilters, enabled = true, refetchMs: number | false = false) { return useInfiniteQuery({ enabled, + refetchInterval: refetchMs, queryKey: qk.list("engine", filters), initialPageParam: undefined as string | undefined, queryFn: ({ pageParam, signal }) => diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index f9b9b65d..ad67e288 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -25,6 +25,13 @@ export const SOURCE_LABEL: Record = { manual: "手动", agent: "Agent", }; +/** 来源 → 服务端 triggeredBy 筛选值(逗号分隔多值) */ +export const SOURCE_TRIGGERED_BY: Record = { + schedule: "schedule", + event: "event", + manual: "user,api_key", + agent: "agent", +}; export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null { if (!t) return null; if (t === "user" || t === "api_key") return "manual"; diff --git a/apps/web/app/components/tasks/task-stream.tsx b/apps/web/app/components/tasks/task-stream.tsx index 2cb43f47..be070f34 100644 --- a/apps/web/app/components/tasks/task-stream.tsx +++ b/apps/web/app/components/tasks/task-stream.tsx @@ -3,7 +3,7 @@ import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { Link } from "react-router"; -import { useEngineRuns, useEngineRunsPage } from "~/api/engine.ts"; +import { useEngineRuns } from "~/api/engine.ts"; import { useScheduleBoard } from "~/api/schedules.ts"; import { DataTable } from "~/components/console/data-table.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; @@ -22,6 +22,7 @@ import { runStatus, runTitle, SOURCE_LABEL, + SOURCE_TRIGGERED_BY, sourceOf, type TaskSource, workflowLabel, @@ -132,26 +133,20 @@ export function TaskStream({ businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined, businessRefId: filters.ref || undefined, workflowName: filters.workflow || undefined, + triggeredBy: SOURCE_TRIGGERED_BY[filters.source as TaskSource] ?? undefined, from, }; + const active = tab === "active"; + // 进行中 = 进行中 + 排队(status 多值,服务端分页);来源筛选同样在服务端做 const list = useEngineRuns( { ...base, - status: tab === "failed" ? "failed" : filters.status || undefined, + status: active ? "running,pending" : tab === "failed" ? "failed" : filters.status || undefined, }, - tab !== "active", + true, + active ? 10_000 : false, ); - // 进行中 = 进行中 + 排队(两次取第一页合并) - const running = useEngineRunsPage({ ...base, status: "running" }, 100, 10_000, tab === "active"); - const pending = useEngineRunsPage({ ...base, status: "pending" }, 100, 10_000, tab === "active"); - - const active = tab === "active"; - const loaded = active - ? running.data && pending.data - ? [...running.data.items, ...pending.data.items] - : undefined - : list.data?.pages.flatMap((p) => p.items); - const rows = filters.source ? loaded?.filter((r) => sourceOf(r.business?.triggeredBy) === filters.source) : loaded; + const rows = list.data?.pages.flatMap((p) => p.items); const filtered = Object.values(filters).some(Boolean); return ( @@ -212,17 +207,12 @@ export function TaskStream({ rows={rows} rowKey={(r) => r.id} - loading={active ? running.isPending || pending.isPending : list.isPending} - error={active ? (running.error ?? pending.error) : list.error} - onRetry={() => { - if (active) { - void running.refetch(); - void pending.refetch(); - } else void list.refetch(); - }} + loading={list.isPending} + error={list.error} + onRetry={() => void list.refetch()} onRowClick={(r) => onOpen(r.id)} selectedKey={selectedId} - hasMore={!active && !!list.hasNextPage} + hasMore={!!list.hasNextPage} onLoadMore={() => list.fetchNextPage()} loadingMore={list.isFetchingNextPage} empty={{ @@ -234,7 +224,7 @@ export function TaskStream({ : tab === "failed" ? "没有失败的任务" : "还没有任务", - description: filters.source && loaded?.length ? "来源筛选只作用于已加载的记录,可加载更多再看。" : undefined, + description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可加载更多继续查找。" : undefined, }} columns={[ { diff --git a/apps/web/app/routes/console/home.tsx b/apps/web/app/routes/console/home.tsx index f14e6813..4668b18f 100644 --- a/apps/web/app/routes/console/home.tsx +++ b/apps/web/app/routes/console/home.tsx @@ -280,19 +280,18 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) { ); } -// ─── 媒体概览:版本目标满足率 + 今日产出(作品/季/资产计数待 /dashboard 提供) ─── +// ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ─── function MediaOverview({ a }: { a: Dashboard["automation"] }) { const c = a.slotCounts; const total = Object.values(c).reduce((x, y) => x + y, 0); - const rate = total ? Math.round((c.satisfied / total) * 100) : null; - const lib = (a as { library?: { entities?: number; seasons?: number; assets?: number } }).library; - const num = (v: number | undefined) => (v === undefined ? "—" : v); + const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100); + const lib = a.library; return (
- - - + + +
diff --git a/deno.lock b/deno.lock index 21107b17..e37bc505 100644 --- a/deno.lock +++ b/deno.lock @@ -45,7 +45,6 @@ "npm:react-dom@^19.2.4": "19.3.0_react@19.3.0", "npm:react-router@7.12.0": "7.12.0_react@19.3.0_react-dom@19.3.0", "npm:react@^19.2.4": "19.3.0", - "npm:recharts@^3.10.1": "3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1", "npm:shadcn@^3.8.5": "3.8.5_@types+node@24.19.0_typescript@7.0.2", "npm:sonner@^2.0.7": "2.0.8_@types+react@19.3.0_react@19.3.0_react-dom@19.3.0", "npm:tailwind-merge@^3.5.0": "3.7.0", @@ -2111,23 +2110,6 @@ "typescript" ] }, - "@reduxjs/toolkit@2.12.0_react@19.3.0_react-redux@9.3.0_@types+react@19.3.0": { - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "dependencies": [ - "@standard-schema/spec", - "@standard-schema/utils", - "immer", - "react", - "react-redux", - "redux", - "redux-thunk", - "reselect" - ], - "optionalPeers": [ - "react", - "react-redux" - ] - }, "@remix-run/node-fetch-server@0.9.0": { "integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==" }, @@ -2310,12 +2292,6 @@ "@stablelib/base64@1.0.1": { "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" }, - "@standard-schema/spec@1.1.0": { - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" - }, - "@standard-schema/utils@0.3.0": { - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" - }, "@tailwindcss/node@4.3.3": { "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dependencies": [ @@ -2438,42 +2414,6 @@ "assertion-error" ] }, - "@types/d3-array@3.2.2": { - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" - }, - "@types/d3-color@3.1.3": { - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" - }, - "@types/d3-ease@3.0.2": { - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" - }, - "@types/d3-interpolate@3.0.4": { - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dependencies": [ - "@types/d3-color" - ] - }, - "@types/d3-path@3.1.1": { - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" - }, - "@types/d3-scale@4.0.9": { - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dependencies": [ - "@types/d3-time" - ] - }, - "@types/d3-shape@3.2.0": { - "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", - "dependencies": [ - "@types/d3-path" - ] - }, - "@types/d3-time@3.0.4": { - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" - }, - "@types/d3-timer@3.0.2": { - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" - }, "@types/deep-eql@4.0.2": { "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" }, @@ -2521,9 +2461,6 @@ "@types/statuses@2.0.6": { "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==" }, - "@types/use-sync-external-store@0.0.6": { - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" - }, "@types/validate-npm-package-name@4.0.2": { "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==" }, @@ -3019,61 +2956,6 @@ "csstype@3.2.3": { "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, - "d3-array@3.2.4": { - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dependencies": [ - "internmap" - ] - }, - "d3-color@3.1.0": { - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" - }, - "d3-ease@3.0.1": { - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" - }, - "d3-format@3.1.2": { - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==" - }, - "d3-interpolate@3.0.1": { - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dependencies": [ - "d3-color" - ] - }, - "d3-path@3.1.0": { - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" - }, - "d3-scale@4.0.2": { - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dependencies": [ - "d3-array", - "d3-format", - "d3-interpolate", - "d3-time", - "d3-time-format" - ] - }, - "d3-shape@3.2.0": { - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dependencies": [ - "d3-path" - ] - }, - "d3-time-format@4.1.0": { - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dependencies": [ - "d3-time" - ] - }, - "d3-time@3.1.0": { - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dependencies": [ - "d3-array" - ] - }, - "d3-timer@3.0.1": { - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" - }, "data-uri-to-buffer@4.0.1": { "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" }, @@ -3095,9 +2977,6 @@ "ms" ] }, - "decimal.js-light@2.5.1": { - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" - }, "dedent@1.7.2": { "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==" }, @@ -3258,9 +3137,6 @@ "es-errors" ] }, - "es-toolkit@1.52.0": { - "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==" - }, "esbuild@0.28.2": { "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "optionalDependencies": [ @@ -3313,9 +3189,6 @@ "etag@1.8.1": { "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, - "eventemitter3@5.0.4": { - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" - }, "eventsource-parser@3.1.1": { "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==" }, @@ -3739,9 +3612,6 @@ "ignore@7.0.8": { "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==" }, - "immer@11.1.18": { - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==" - }, "import-fresh@3.3.1": { "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dependencies": [ @@ -3752,9 +3622,6 @@ "inherits@2.0.4": { "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "internmap@2.0.3": { - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" - }, "ip-address@10.7.2": { "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==" }, @@ -4729,23 +4596,6 @@ "scheduler" ] }, - "react-is@19.3.0": { - "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==" - }, - "react-redux@9.3.0_@types+react@19.3.0_react@19.3.0_redux@5.0.1": { - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "dependencies": [ - "@types/react", - "@types/use-sync-external-store", - "react", - "redux", - "use-sync-external-store" - ], - "optionalPeers": [ - "@types/react", - "redux" - ] - }, "react-refresh@0.14.2": { "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" }, @@ -4822,43 +4672,12 @@ "tslib" ] }, - "recharts@3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1": { - "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", - "dependencies": [ - "@reduxjs/toolkit", - "clsx", - "decimal.js-light", - "es-toolkit", - "eventemitter3", - "immer", - "react", - "react-dom", - "react-is", - "react-redux", - "reselect", - "tiny-invariant", - "use-sync-external-store", - "victory-vendor" - ] - }, - "redux-thunk@3.1.0_redux@5.0.1": { - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "dependencies": [ - "redux" - ] - }, - "redux@5.0.1": { - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" - }, "require-directory@2.1.1": { "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, "require-from-string@2.0.2": { "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, - "reselect@5.2.0": { - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==" - }, "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, @@ -5376,12 +5195,6 @@ "@types/react" ] }, - "use-sync-external-store@1.7.0_react@19.3.0": { - "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", - "dependencies": [ - "react" - ] - }, "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, @@ -5400,25 +5213,6 @@ "vary@1.1.2": { "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, - "victory-vendor@37.3.6": { - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "dependencies": [ - "@types/d3-array", - "@types/d3-ease", - "@types/d3-interpolate", - "@types/d3-scale", - "@types/d3-shape", - "@types/d3-time", - "@types/d3-timer", - "d3-array", - "d3-ease", - "d3-interpolate", - "d3-scale", - "d3-shape", - "d3-time", - "d3-timer" - ] - }, "vite-node@3.2.4_@types+node@24.19.0_yaml@2.9.1": { "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dependencies": [ @@ -5652,7 +5446,6 @@ "npm:react-dom@^19.2.4", "npm:react-router@7.12.0", "npm:react@^19.2.4", - "npm:recharts@^3.10.1", "npm:shadcn@^3.8.5", "npm:sonner@^2.0.7", "npm:tailwind-merge@^3.5.0", diff --git a/docs/design/console-ia.md b/docs/design/console-ia.md index 242d597d..9455c04b 100644 --- a/docs/design/console-ia.md +++ b/docs/design/console-ia.md @@ -322,13 +322,13 @@ - 页签:全部 / 进行中(进行中 + 排队)/ 失败 / 计划;页签带计数(`/engine/stats`)。 - 任务流 = `GET /engine/runs` + `business`:行显示业务标题(`title`,否则类型 + 短 id,否则工作流做什么)、来源(定时 / 事件 / 手动 / Agent;API Key 算手动)、状态、开始与耗时、关联链接。 -- 筛选写进 URL:`source`、`kind`(businessKind)、`ref`(businessRefId)、`status`、`since`(1h/24h/7d/30d → `from`)、`workflow`(旧引擎链接)。来源筛选在前端对已加载记录做(后端无 triggeredBy 参数)。 +- 筛选写进 URL:`source`、`kind`(businessKind)、`ref`(businessRefId)、`status`、`since`(1h/24h/7d/30d → `from`)、`workflow`(旧引擎链接)。来源筛选走服务端 `triggeredBy`(手动 = `user,api_key`)。 - 详情抽屉 `?run=`:业务摘要 → 时间线(创建、开始、等待、失败步骤、结束)→ 相关审计(`/audit?targetType=engine_run&targetId=`,定时任务另链 `scheduled_job`)→ 折叠的引擎步骤(原引擎面板详情:步骤尝试、信号、输入输出)。操作:未结束可取消,已结束可重跑(重跑后抽屉切到新任务)。`/console/tasks/:id` 是同内容的独立页。 - 计划页签 = 原定时任务看板(`components/schedules/schedule-board.tsx`)。卡片"运行记录"跳任务流 `kind=schedule&ref=`;该筛选下页头给出定时任务自己的运行记录(含跳过)与回到卡片的链接;`?job=` 高亮并滚到卡片。 ### 7.4 首页 -正在进行(任务流进行中前 5 + 排队/下载计数)、需要我处理(计数 + 前 3 条)、媒体概览(版本目标满足率、今日产出;作品/季/资产数占位)、计划带(下一次运行的 5 项、启用/失败计数、快速开关)、最近完成/失败、缓存与保留、来源健康(站点 + 账号状态,来自 `/sites?include=accounts`,有问题的排前)、运行状态摘要(`/system/health`)。 +正在进行(任务流进行中前 5 + 排队/下载计数)、需要我处理(计数 + 前 3 条)、媒体概览(作品/季/资产计数、版本目标满足率、今日产出)、计划带(下一次运行的 5 项、启用/失败计数、快速开关)、最近完成/失败、缓存与保留、来源健康(站点 + 账号状态,来自 `/sites?include=accounts`,有问题的排前)、运行状态摘要(`/system/health`)。 ### 7.5 运行状态页(`/console/status`) @@ -336,7 +336,10 @@ ### 7.6 后端缺口 -- `/dashboard` 没有 `library{entities, seasons, assets}`,首页媒体概览三个计数显示"—"(site-center.md §10 已列)。 -- `/engine/runs` 没有 `triggeredBy` 筛选参数,来源筛选只作用于已加载的记录。 -- `/engine/runs` 的 `status` 只收单值,"进行中"页签用 running、pending 两次查询各取前 100 条合并,不分页。 -- 定时任务的运行记录(`scheduled_job_run`)没有对应引擎 run id,卡片与任务之间只能按 `business.kind=schedule` + key 关联;feeds.poll 这类只发事件的定时任务,触发的任务记在 Feed 名下。 +W10 已补(2026-09-28): + +- `/dashboard` 的 `automation.library{entities, movies, series, seasons, episodes, assets}` 与 `automation.slotSatisfiedRate`,首页媒体概览三个计数与满足率直接取用。 +- `/engine/runs?triggeredBy=`(单值或逗号多值,服务端逐页推断后筛选,一页可能少于 limit,以 `nextCursor` 为准,单次请求最多扫 2000 行);来源筛选改走服务端。 +- `/engine/runs?status=running,pending` 多值;"进行中"页签改为一次查询 + 游标分页,不再两次各取 100 条合并。 + +仍未补:定时任务的运行记录(`scheduled_job_run`)没有对应引擎 run id,卡片与任务之间只能按 `business.kind=schedule` + key 关联;feeds.poll 这类只发事件的定时任务,触发的任务记在 Feed 名下。 diff --git a/packages/contracts/src/ops.ts b/packages/contracts/src/ops.ts index 05d613ec..78a1bc6b 100644 --- a/packages/contracts/src/ops.ts +++ b/packages/contracts/src/ops.ts @@ -208,6 +208,28 @@ export type AuditEventQuery = z.infer; // ── 引擎面板(只读直查 openworkflow schema,仅 admin;docs/design/openworkflow-dashboard-reference.md) ── +/** 逗号分隔的枚举多选(查询串):`a,b` → ["a","b"],未知值报错 */ +function csvEnum(e: z.ZodType, name: string) { + return z + .string() + .max(200) + .transform((s, ctx) => { + const out: T[] = []; + for (const p of s + .split(",") + .map((x) => x.trim()) + .filter(Boolean)) { + const r = e.safeParse(p); + if (!r.success) { + ctx.addIssue({ code: "custom", message: `未知 ${name}: ${p}` }); + return z.NEVER; + } + if (!out.includes(r.data)) out.push(r.data); + } + return out; + }); +} + /** 引擎 run 状态(含 legacy sleeping/succeeded) */ export const EngineRunStatus = z.enum([ "pending", @@ -273,7 +295,8 @@ export type EngineRun = z.infer; export const EngineRunQuery = z.object({ cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), - status: EngineRunStatus.optional(), + /** 单值或逗号分隔多值,如 status=running,pending */ + status: csvEnum(EngineRunStatus, "status").optional(), workflowName: z.string().max(200).optional(), /** created_at 下界(含) */ from: Timestamp.optional(), @@ -283,6 +306,8 @@ export const EngineRunQuery = z.object({ businessKind: EngineBusinessKind.optional(), /** 按关联业务对象 id 筛选(可与 businessKind 同用) */ businessRefId: z.string().min(1).max(200).optional(), + /** 按触发来源筛选(单值或逗号分隔多值);来源在服务端逐页推断,一页可能少于 limit,以 nextCursor 为准 */ + triggeredBy: csvEnum(EngineTriggeredBy, "triggeredBy").optional(), }); export type EngineRunQuery = z.infer; diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 73b249e5..f773aff5 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -469,6 +469,17 @@ export const Dashboard = z.object({ }), activeSubscriptions: NonNegInt, slotCounts: SlotStatusCounts, + /** 版本目标满足率 = satisfied / 全部版本目标(0–1);没有版本目标时为 null */ + slotSatisfiedRate: z.number().min(0).max(1).nullable(), + /** 本地账本计数:作品不含已合并;季 = content_unit.kind=season(不含 retired);资产 = active 的视频 / 原盘目录 */ + library: z.object({ + entities: NonNegInt, + movies: NonNegInt, + series: NonNegInt, + seasons: NonNegInt, + episodes: NonNegInt, + assets: NonNegInt, + }), }), currentWork: z.object({ downloading: NonNegInt,