feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

+5 -1
View File
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyUpdate, CursorPage } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { fetchAllCursorPages } from "./parse.ts";
export function useApiKeys() {
return useQuery({
queryKey: qk.list("api-keys"),
queryFn: ({ signal }) => apiData<CursorPage<ApiKey>>("/api-keys", { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
apiData<CursorPage<ApiKey>>("/api-keys", { params: { limit: 200, cursor }, signal }),
),
});
}
+4 -2
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { cursorPage, type Dashboard, SiteListItem } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { apiParsed } from "./parse.ts";
import { apiParsed, fetchAllCursorPages } from "./parse.ts";
/** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */
export function useDashboard() {
@@ -20,7 +20,9 @@ export function useSitesWithAccounts() {
return useQuery({
queryKey: [...qk.dashboard, "sites-accounts"],
queryFn: ({ signal }) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, cursor, include: "accounts" }, signal }),
),
refetchInterval: 60_000,
});
}
+7 -2
View File
@@ -13,7 +13,7 @@ import {
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
// 下载任务、计划(界面统称"下载")、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
@@ -181,7 +181,12 @@ export function useDownloaderNames() {
queryKey: ["downloads", "instances"],
staleTime: 5 * 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", { params: { limit: 200 }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", {
params: { limit: 200, cursor },
signal,
}),
),
select: (d) => new Map(d.items.map((i) => [i.id, i.name])),
});
}
+1
View File
@@ -9,6 +9,7 @@ export type EngineRunFilters = {
status?: string;
/** 触发来源,单值或逗号分隔多值(user,api_key) */
triggeredBy?: string;
/** 单值或逗号分隔多个工作流名称 */
workflowName?: string;
businessKind?: EngineBusinessKind;
businessRefId?: string;
+4 -2
View File
@@ -10,7 +10,7 @@ import {
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
// Feed 来源(W2-B:/feed-sources)
@@ -25,7 +25,9 @@ export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) {
return useQuery({
queryKey: feedKeys.list(f),
queryFn: ({ signal }) =>
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200 }), signal }),
fetchAllCursorPages((cursor) =>
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200, cursor }), signal }),
),
refetchInterval: 30 * TTL_SECOND,
});
}
+10 -4
View File
@@ -24,14 +24,16 @@ import type {
} from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { type QueryArea, qk } from "~/lib/query-keys.ts";
import { fetchAllCursorPages } from "./parse.ts";
/** 实例类资源的标准 CRUD 钩子(列表取前 200 条,首版实例数量很少) */
/** 实例类资源的标准 CRUD 钩子(列表按游标取齐配置) */
function crudHooks<Row extends { id: string }, Create, Update>(area: QueryArea, base: string) {
return {
useList: () =>
useQuery({
queryKey: qk.list(area),
queryFn: ({ signal }) => apiData<CursorPage<Row>>(base, { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => apiData<CursorPage<Row>>(base, { params: { limit: 200, cursor }, signal })),
}),
useCreate: () => {
const qc = useQueryClient();
@@ -100,14 +102,18 @@ export const pathMappings = {
export function useSiteOptions() {
return useQuery({
queryKey: qk.list("sites", { limit: 200 }),
queryFn: ({ signal }) => apiData<CursorPage<Site>>("/sites", { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => apiData<CursorPage<Site>>("/sites", { params: { limit: 200, cursor }, signal })),
});
}
export function useSiteAccounts() {
return useQuery({
queryKey: qk.list("site-accounts"),
queryFn: ({ signal }) => apiData<CursorPage<SiteAccount>>("/site-accounts", { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
apiData<CursorPage<SiteAccount>>("/site-accounts", { params: { limit: 200, cursor }, signal }),
),
});
}
+3 -1
View File
@@ -16,6 +16,7 @@ import {
} from "@umt/contracts";
import type { z } from "zod";
import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 媒体库:列表 / 详情 / 作品详情(内容单元、别名、字段来源)与作品级操作。
// 所有响应都按 contracts 的 zod schema 校验,结构不符按错误处理(不静默渲染半截数据)。
@@ -106,7 +107,8 @@ const RootPage = cursorPage(LibraryRoot);
export function useLibraryRoots() {
return useQuery({
queryKey: libraryKeys.roots,
queryFn: ({ signal }) => getParsed(RootPage, "/library-roots", { limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(RootPage, "/library-roots", { limit: 200, cursor }, signal)),
staleTime: 5 * 60_000,
});
}
+54
View File
@@ -0,0 +1,54 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ClassificationConfigView,
type OrganizeSourceInput,
OrganizeSourceList,
OrganizeSourceView,
OrganizeStockResult,
} from "@umt/contracts";
import { z } from "zod";
import { apiParsed } from "./parse.ts";
export function useOrganizeSources() {
return useQuery({
queryKey: ["organize-sources"],
queryFn: ({ signal }) => apiParsed(OrganizeSourceList, "/organize-sources", { signal }),
refetchInterval: 10_000,
});
}
export function useSaveOrganizeSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: OrganizeSourceInput }) =>
apiParsed(OrganizeSourceView, `/organize-sources/${id}`, { method: "PUT", body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["organize-sources"] }),
});
}
export function useRemoveOrganizeSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiParsed(z.object({ deleted: z.boolean() }), `/organize-sources/${id}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["organize-sources"] }),
});
}
export function useOrganizeStock() {
return useMutation({
mutationFn: ({ id, path }: { id: string; path?: string }) =>
apiParsed(OrganizeStockResult, `/organize-sources/${id}/stock`, { method: "POST", body: { path } }),
});
}
export function useClassification() {
return useQuery({
queryKey: ["classification"],
queryFn: ({ signal }) => apiParsed(ClassificationConfigView, "/classification", { signal }),
});
}
export function useSaveClassification() {
const qc = useQueryClient();
return useMutation({
mutationFn: (yaml: string) =>
apiParsed(ClassificationConfigView, "/classification", { method: "PUT", body: { yaml } }),
onSuccess: (data) => qc.setQueryData(["classification"], data),
});
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { fetchAllCursorPages } from "./parse.ts";
describe("fetchAllCursorPages", () => {
it("keeps configuration rows beyond the first cursor page", async () => {
const fetchPage = vi.fn(async (cursor?: string) =>
cursor
? { items: [201, 202], nextCursor: null, total: 202 }
: { items: Array.from({ length: 200 }, (_, i) => i + 1), nextCursor: "page-2", total: 202 },
);
const result = await fetchAllCursorPages(fetchPage);
expect(result.items).toHaveLength(202);
expect(result.items.slice(-2)).toEqual([201, 202]);
expect(result.total).toBe(202);
expect(result.nextCursor).toBeNull();
expect(fetchPage.mock.calls).toEqual([[], ["page-2"]]);
});
it("rejects a repeated cursor instead of looping indefinitely", async () => {
const fetchPage = vi.fn(async () => ({ items: [1], nextCursor: "same" }));
await expect(fetchAllCursorPages(fetchPage)).rejects.toThrow("分页游标重复");
expect(fetchPage).toHaveBeenCalledTimes(2);
});
it("propagates a later page failure instead of returning a partial configuration", async () => {
const failure = new Error("aborted");
await expect(
fetchAllCursorPages(async (cursor) => {
if (cursor) throw failure;
return { items: [1], nextCursor: "next" };
}),
).rejects.toBe(failure);
});
});
+18
View File
@@ -37,3 +37,21 @@ export function cleanParams(p: Record<string, string | number | boolean | undefi
for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== null && v !== "") out[k] = v;
return out;
}
/** 小规模配置集合:跟随游标取齐,保留列表返回形状供选择器与本地分页共用。 */
export async function fetchAllCursorPages<P extends { items: unknown[]; nextCursor?: string | null }>(
fetchPage: (cursor?: string) => Promise<P>,
): Promise<P> {
const first = await fetchPage();
const items = [...first.items];
const seen = new Set<string>();
let cursor = first.nextCursor;
while (cursor) {
if (seen.has(cursor)) throw new ApiRequestError("internal", "分页游标重复,请重试");
seen.add(cursor);
const page = await fetchPage(cursor);
items.push(...page.items);
cursor = page.nextCursor;
}
return { ...first, items, nextCursor: null };
}
+3 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 策略版本:草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4)
@@ -24,7 +25,8 @@ const Page = cursorPage(PolicyRevision);
export function usePolicyRevisions(filters: PolicyFilters = {}) {
return useQuery({
queryKey: policyKeys.list(filters),
queryFn: ({ signal }) => getParsed(Page, "/policy-revisions", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(Page, "/policy-revisions", { ...filters, limit: 200, cursor }, signal)),
});
}
+12 -6
View File
@@ -11,6 +11,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 偏好标签与抑制记录(REQ §6.7):停用/删除/语义变更都会留抑制记录,Agent 不得据旧证据自动加回。
@@ -29,7 +30,10 @@ const SupPage = cursorPage(PreferenceSuppression);
export function usePreferenceTags(filters: TagFilters = {}) {
return useQuery({
queryKey: preferenceKeys.tags(filters),
queryFn: ({ signal }) => getParsed(TagPage, "/preference-tags", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
getParsed(TagPage, "/preference-tags", { ...filters, limit: 200, cursor }, signal),
),
});
}
@@ -37,11 +41,13 @@ export function useSuppressions(active?: boolean) {
return useQuery({
queryKey: preferenceKeys.suppressions(active),
queryFn: ({ signal }) =>
getParsed(
SupPage,
"/preference-suppressions",
{ active: active === undefined ? undefined : String(active), limit: 200 },
signal,
fetchAllCursorPages((cursor) =>
getParsed(
SupPage,
"/preference-suppressions",
{ active: active === undefined ? undefined : String(active), limit: 200, cursor },
signal,
),
),
});
}
+6 -2
View File
@@ -15,7 +15,7 @@ import {
PublicOrganizationRecord,
} from "@umt/contracts";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
// 整理记录(W2-C:/organization-records;读 organize.read,发起 organize.execute)
@@ -23,6 +23,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord);
export type RecordFilters = {
status?: OrganizationStatus;
phase?: "active" | "history";
trigger?: OrganizationTrigger;
mediaEntityId?: string;
q?: string;
@@ -145,6 +146,7 @@ export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自
/** 整理方式 / 文件操作(记录的 action) */
export const ACTION_LABEL = {
copy: "复制",
in_place: "原地入库",
move: "移动",
rename: "重命名",
relocate: "目录调整",
@@ -174,7 +176,9 @@ export function useLibraryRoots() {
queryKey: ["records", "library-roots"],
staleTime: 5 * 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200 }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200, cursor }, signal }),
),
});
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type ManualAcquisitionRequest,
ManualAcquisitionResult,
type ResolutionStatus,
WorkbenchReleaseDetail,
@@ -81,7 +82,7 @@ export function useRefreshReleaseDetail() {
export function useSubmitRelease() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { sourceReleaseId: string; slotId?: string; entityId?: string; note?: string }) =>
mutationFn: (body: ManualAcquisitionRequest) =>
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => ManualAcquisitionResult.parse(d)),
onSuccess: (r) => {
if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
+7 -3
View File
@@ -22,6 +22,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。
// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。
@@ -53,7 +54,8 @@ const SupPage = cursorPage(PreferenceSuppression);
export function useRuleGroups(filters: RuleGroupFilters = {}) {
return useQuery({
queryKey: ruleKeys.groups(filters),
queryFn: ({ signal }) => getParsed(GroupPage, "/rules", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(GroupPage, "/rules", { ...filters, limit: 200, cursor }, signal)),
placeholderData: keepPreviousData,
});
}
@@ -78,7 +80,8 @@ export function useRuleKeywords() {
export function useRuleAtoms() {
return useQuery({
queryKey: ruleKeys.atoms,
queryFn: ({ signal }) => getParsed(AtomPage, "/rules/atoms", { limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(AtomPage, "/rules/atoms", { limit: 200, cursor }, signal)),
});
}
@@ -92,7 +95,8 @@ export function useRuleDefaults() {
export function useRuleSuppressions() {
return useQuery({
queryKey: ruleKeys.suppressions,
queryFn: ({ signal }) => getParsed(SupPage, "/rules/suppressions", { limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(SupPage, "/rules/suppressions", { limit: 200, cursor }, signal)),
});
}
+8 -4
View File
@@ -19,7 +19,7 @@ import {
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed } from "./parse.ts";
import { apiMutate, apiParsed, fetchAllCursorPages } from "./parse.ts";
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
// 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除
@@ -41,12 +41,14 @@ export const siteKeys = {
loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const,
};
/** 站点列表(附账号与整体账号状态,站点页一次请求) */
/** 站点列表(附账号与整体账号状态,站点页按游标取齐配置) */
export function useSites() {
return useQuery({
queryKey: siteKeys.list(),
queryFn: ({ signal }) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts", cursor }, signal }),
),
});
}
@@ -62,7 +64,9 @@ export function useSiteAccounts(siteId?: string) {
return useQuery({
queryKey: siteKeys.accounts(siteId),
queryFn: ({ signal }) =>
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId, cursor }, signal }),
),
});
}
+5 -1
View File
@@ -8,6 +8,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 收录目标 Slot:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件(REQ §6.1)
@@ -24,7 +25,10 @@ export function useSlots(filters: SlotFilters, enabled = true) {
return useQuery({
queryKey: slotKeys.list(filters),
enabled,
queryFn: ({ signal }) => getParsed(Page, "/acquisition-slots", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
getParsed(Page, "/acquisition-slots", { ...filters, limit: 200, cursor }, signal),
),
});
}