diff --git a/apps/web/app/api/decisions.ts b/apps/web/app/api/decisions.ts new file mode 100644 index 00000000..41042c28 --- /dev/null +++ b/apps/web/app/api/decisions.ts @@ -0,0 +1,63 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { cursorPage, Decision, DecisionKind, type DecisionResolveInput, type DecisionStatus, Id } from "@umt/contracts"; +import { z } from "zod"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 待处理中心:按作品与问题类型聚合(summary),条目带证据/建议/影响范围,resolve 选项来自 decision.options。 + +export const decisionKeys = { + all: ["decisions"] as const, + list: (f: DecisionFilters) => ["decisions", "list", f] as const, + summary: (status?: DecisionStatus) => ["decisions", "summary", status ?? "open"] as const, +}; + +export type DecisionFilters = { status?: DecisionStatus; kind?: DecisionKind }; + +/** 服务端 /decisions/summary 的形状(路由内定义,未进 contracts) */ +export const DecisionSummary = z.object({ + total: z.number().int(), + groups: z.array( + z.object({ + kind: DecisionKind, + mediaEntityId: Id.nullable(), + mediaEntityTitle: z.string().nullable(), + count: z.number().int(), + maxSeverity: z.enum(["info", "warning", "critical"]), + latestAt: z.string(), + }), + ), +}); +export type DecisionSummary = z.infer; + +const Page = cursorPage(Decision); + +export function useDecisions(filters: DecisionFilters) { + return useInfiniteQuery({ + queryKey: decisionKeys.list(filters), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + getParsed(Page, "/decisions", { ...filters, cursor: pageParam, limit: 100 }, signal), + getNextPageParam: (last) => last.nextCursor ?? undefined, + placeholderData: keepPreviousData, + }); +} + +export function useDecisionSummary(status?: DecisionStatus) { + return useQuery({ + queryKey: decisionKeys.summary(status), + queryFn: ({ signal }) => getParsed(DecisionSummary, "/decisions/summary", { status }, signal), + }); +} + +export function useResolveDecision() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...body }: DecisionResolveInput & { id: string }) => + api.post(`/decisions/${id}/resolve`, body).then((d) => parseOrThrow(Decision, d, "/decisions")), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: decisionKeys.all }); + void qc.invalidateQueries({ queryKey: ["identity"] }); + }, + }); +} diff --git a/apps/web/app/api/identity.ts b/apps/web/app/api/identity.ts new file mode 100644 index 00000000..5833a9ba --- /dev/null +++ b/apps/web/app/api/identity.ts @@ -0,0 +1,154 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + cursorPage, + ExternalIdentity, + type IdentityCandidateReason, + type IdentityCandidateStatus, + IdentityPoolItem, + IdentityRevision, + type IdentityStatus, + type ReleaseIdentityAssign, + TitleAlias, + type TitleAliasCreate, +} from "@umt/contracts"; +import { z } from "zod"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 身份与元数据:待识别池、外部身份与修订历史、别名(REQ §7.2、§7.5) + +export const identityKeys = { + all: ["identity"] as const, + pool: (f: PoolFilters) => ["identity", "pool", f] as const, + identities: (f: IdentityFilters) => ["identity", "external", f] as const, + revisions: (id: string) => ["identity", "revisions", id] as const, + aliases: (f: AliasFilters) => ["identity", "aliases", f] as const, +}; + +export type PoolFilters = { status?: IdentityCandidateStatus; reason?: IdentityCandidateReason; q?: string }; +export type IdentityFilters = { mediaEntityId?: string; provider?: string; status?: IdentityStatus }; +export type AliasFilters = { mediaEntityId?: string; q?: string }; + +const PoolPage = cursorPage(IdentityPoolItem); +const IdentityPage = cursorPage(ExternalIdentity); +const AliasPage = cursorPage(TitleAlias); +/** 服务端返回 { items }(无 nextCursor);兼容数组与分页两种形状 */ +const RevisionList = z.union([z.array(IdentityRevision), z.object({ items: z.array(IdentityRevision) })]); + +export function useIdentityPool(filters: PoolFilters) { + return useInfiniteQuery({ + queryKey: identityKeys.pool(filters), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + getParsed(PoolPage, "/identity-pool", { ...filters, cursor: pageParam, limit: 50 }, signal), + getNextPageParam: (last) => last.nextCursor ?? undefined, + placeholderData: keepPreviousData, + }); +} + +export function useExternalIdentities(filters: IdentityFilters) { + return useInfiniteQuery({ + queryKey: identityKeys.identities(filters), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + getParsed(IdentityPage, "/external-identities", { ...filters, cursor: pageParam, limit: 50 }, signal), + getNextPageParam: (last) => last.nextCursor ?? undefined, + placeholderData: keepPreviousData, + }); +} + +export function useIdentityRevisions(id: string | undefined) { + return useQuery({ + queryKey: identityKeys.revisions(id ?? ""), + enabled: !!id, + queryFn: async ({ signal }) => { + const r = await getParsed(RevisionList, `/external-identities/${id}/revisions`, undefined, signal); + return Array.isArray(r) ? r : r.items; + }, + }); +} + +export function useAliases(filters: AliasFilters, enabled = true) { + return useInfiniteQuery({ + queryKey: identityKeys.aliases(filters), + enabled, + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + getParsed(AliasPage, "/title-aliases", { ...filters, cursor: pageParam, limit: 50 }, signal), + getNextPageParam: (last) => last.nextCursor ?? undefined, + placeholderData: keepPreviousData, + }); +} + +function useInvalidate() { + const qc = useQueryClient(); + return () => { + void qc.invalidateQueries({ queryKey: identityKeys.all }); + void qc.invalidateQueries({ queryKey: ["library"] }); + void qc.invalidateQueries({ queryKey: ["decisions"] }); + }; +} + +/** 指定候选 / 本地作品 / 外部身份(三选一),默认加人工锁 */ +export function useAssignPool() { + const done = useInvalidate(); + return useMutation({ + mutationFn: ({ id, ...body }: ReleaseIdentityAssign & { id: string }) => + api.post(`/identity-pool/${id}/assign`, body), + onSuccess: done, + }); +} + +export function useDismissPool() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (v: { id: string; note?: string }) => + api.post(`/identity-pool/${v.id}/dismiss`, { note: v.note }), + onSuccess: done, + }); +} + +export function useRetryPool() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.post(`/identity-pool/${id}/retry`), + onSuccess: done, + }); +} + +/** 外部身份修订(锁定/解锁/改状态),乐观并发 */ +export function useUpdateIdentity() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (v: { + id: string; + expectedRevision: number; + locked?: boolean; + status?: IdentityStatus; + reason?: string; + }) => { + const { id, ...body } = v; + return api + .patch(`/external-identities/${id}`, body) + .then((d) => parseOrThrow(ExternalIdentity, d, "/external-identities")); + }, + onSuccess: done, + }); +} + +export function useCreateAlias() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: TitleAliasCreate) => + api.post("/title-aliases", body).then((d) => parseOrThrow(TitleAlias, d, "/title-aliases")), + onSuccess: done, + }); +} + +export function useDeleteAlias() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.del(`/title-aliases/${id}`), + onSuccess: done, + }); +} diff --git a/apps/web/app/api/library.ts b/apps/web/app/api/library.ts new file mode 100644 index 00000000..33aeb65a --- /dev/null +++ b/apps/web/app/api/library.ts @@ -0,0 +1,163 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + cursorPage, + type EntityIdentitySet, + type EntityMetadataField, + LibraryDetail, + LibraryItem, + type LibraryListQuery, + LibraryRoot, + MediaEntity, + MediaEntityDetail, + type MediaKind, +} from "@umt/contracts"; +import type { z } from "zod"; +import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts"; + +// 媒体库:列表 / 详情 / 作品详情(内容单元、别名、字段来源)与作品级操作。 +// 所有响应都按 contracts 的 zod schema 校验,结构不符按错误处理(不静默渲染半截数据)。 + +/** 取 data 并用 zod 校验;不符合 contracts 时抛 ApiRequestError("internal") */ +export async function getParsed( + schema: S, + path: string, + params?: QueryParams, + signal?: AbortSignal, +): Promise> { + return parseOrThrow(schema, await api.get(path, params, signal), path); +} + +export function parseOrThrow(schema: S, data: unknown, path: string): z.infer { + const r = schema.safeParse(data); + if (!r.success) { + const first = r.error.issues[0]; + console.warn(`[api] ${path} 响应结构不符`, r.error.issues); + throw new ApiRequestError( + "internal", + `接口 ${path} 返回的数据结构不符(${first?.path.join(".") || "根"}: ${first?.message ?? "未知"})`, + ); + } + return r.data; +} + +export const libraryKeys = { + all: ["library"] as const, + list: (q: LibraryFilters) => ["library", "list", q] as const, + detail: (id: string) => ["library", "detail", id] as const, + entity: (id: string) => ["library", "entity", id] as const, + roots: ["library", "roots"] as const, + search: (q: string) => ["library", "search", q] as const, +}; + +export type LibraryFilters = { kind?: MediaKind; q?: string; filter?: LibraryListQuery["filter"] }; + +const LibraryPage = cursorPage(LibraryItem); +export type LibraryPage = z.infer; + +/** 媒体库列表:游标分页(加载更多) */ +export function useLibraryList(filters: LibraryFilters) { + return useInfiniteQuery({ + queryKey: libraryKeys.list(filters), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + getParsed(LibraryPage, "/library", { ...filters, cursor: pageParam, limit: 30 }, signal), + getNextPageParam: (last) => last.nextCursor ?? undefined, + placeholderData: keepPreviousData, + }); +} + +export function useLibraryDetail(id: string | undefined) { + return useQuery({ + queryKey: libraryKeys.detail(id ?? ""), + enabled: !!id, + queryFn: ({ signal }) => getParsed(LibraryDetail, `/library/${id}`, undefined, signal), + }); +} + +/** 作品详情(内容单元、别名、字段级元数据与覆盖),旧 ID 自动跳转 */ +export function useEntityDetail(id: string | undefined) { + return useQuery({ + queryKey: libraryKeys.entity(id ?? ""), + enabled: !!id, + queryFn: ({ signal }) => getParsed(MediaEntityDetail, `/media-entities/${id}/detail`, undefined, signal), + }); +} + +const RootPage = cursorPage(LibraryRoot); +/** 库根(把 asset_location 的 libraryRootId 翻成名称与本地路径) */ +export function useLibraryRoots() { + return useQuery({ + queryKey: libraryKeys.roots, + queryFn: ({ signal }) => getParsed(RootPage, "/library-roots", { limit: 200 }, signal), + staleTime: 5 * 60_000, + }); +} + +const EntityPage = cursorPage(MediaEntity); +/** 作品检索(订阅作品选择、合并目标选择) */ +export function useEntitySearch(q: string, opts: { enabled?: boolean; kind?: MediaKind } = {}) { + const term = q.trim(); + return useQuery({ + queryKey: [...libraryKeys.search(term), opts.kind ?? ""], + enabled: opts.enabled ?? true, + queryFn: ({ signal }) => + getParsed(EntityPage, "/media-entities", { q: term || undefined, kind: opts.kind, limit: 20 }, signal), + placeholderData: keepPreviousData, + }); +} + +function useInvalidateEntity(id: string) { + const qc = useQueryClient(); + return () => { + void qc.invalidateQueries({ queryKey: libraryKeys.detail(id) }); + void qc.invalidateQueries({ queryKey: libraryKeys.entity(id) }); + void qc.invalidateQueries({ queryKey: ["library", "list"] }); + void qc.invalidateQueries({ queryKey: ["identity"] }); + }; +} + +/** 改外部映射:旧映射退役(留修订),新映射 accepted + 人工锁 */ +export function useSetEntityIdentity(id: string) { + const done = useInvalidateEntity(id); + return useMutation({ + mutationFn: (body: EntityIdentitySet) => api.post(`/media-entities/${id}/identities`, body), + onSuccess: done, + }); +} + +/** 字段覆盖并锁定 */ +export function useLockField(id: string) { + const done = useInvalidateEntity(id); + return useMutation({ + mutationFn: (v: { field: EntityMetadataField; value: unknown; reason?: string }) => + api.put(`/media-entities/${id}/fields/${v.field}`, { value: v.value, lock: true, reason: v.reason }), + onSuccess: done, + }); +} + +/** 取消字段覆盖与锁定 */ +export function useUnlockField(id: string) { + const done = useInvalidateEntity(id); + return useMutation({ + mutationFn: (field: EntityMetadataField) => api.del(`/media-entities/${id}/fields/${field}`), + onSuccess: done, + }); +} + +/** 刷新元数据(字段级;用户锁优先) */ +export function useRefreshMetadata(id: string) { + const done = useInvalidateEntity(id); + return useMutation({ + mutationFn: () => api.post(`/media-entities/${id}/refresh`), + onSuccess: done, + }); +} + +/** 合并到另一作品(保留旧 ID 跳转) */ +export function useMergeEntity(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (v: { intoId: string; reason?: string }) => api.post(`/media-entities/${id}/merge`, v), + onSuccess: () => void qc.invalidateQueries({ queryKey: libraryKeys.all }), + }); +} diff --git a/apps/web/app/api/policies.ts b/apps/web/app/api/policies.ts new file mode 100644 index 00000000..e156e40e --- /dev/null +++ b/apps/web/app/api/policies.ts @@ -0,0 +1,64 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + cursorPage, + PolicyRevision, + type PolicyRevisionCreate, + type PolicyRevisionUpdate, + type PolicyStatus, +} from "@umt/contracts"; +import { z } from "zod"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 策略版本:草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4) + +export const policyKeys = { + all: ["policies"] as const, + list: (f: PolicyFilters) => ["policies", "list", f] as const, +}; + +export type PolicyFilters = { policyKey?: string; status?: PolicyStatus }; + +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), + }); +} + +/** 预览结果(服务端 previewPolicy 的形状;contracts 里是 JsonObject) */ +export const PolicyPreview = z.object({ + evaluatedAt: z.string(), + sampleSize: z.number(), + templates: z.number(), + matched: z.number(), + excluded: z.number(), + samples: z.array(z.object({ sourceReleaseId: z.string(), template: z.string(), decision: z.string() })), + valid: z.boolean(), +}); +export type PolicyPreview = z.infer; + +const parse = (d: unknown) => parseOrThrow(PolicyRevision, d, "/policy-revisions"); + +function useAction(fn: (v: V) => Promise) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (v: V) => fn(v).then(parse), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: policyKeys.all }); + void qc.invalidateQueries({ queryKey: ["subscriptions"] }); + }, + }); +} + +export const useCreatePolicy = () => useAction((body: PolicyRevisionCreate) => api.post("/policy-revisions", body)); +export const useUpdatePolicy = () => + useAction(({ id, ...body }: PolicyRevisionUpdate & { id: string }) => api.patch(`/policy-revisions/${id}`, body)); +export const usePreviewPolicy = () => useAction((id: string) => api.post(`/policy-revisions/${id}/preview`)); +export const usePublishPolicy = () => + useAction((v: { id: string; expectedRevision: number }) => + api.post(`/policy-revisions/${v.id}/publish`, { expectedRevision: v.expectedRevision }), + ); +export const useRollbackPolicy = () => useAction((id: string) => api.post(`/policy-revisions/${id}/rollback`)); diff --git a/apps/web/app/api/preferences.ts b/apps/web/app/api/preferences.ts new file mode 100644 index 00000000..2cd4ab62 --- /dev/null +++ b/apps/web/app/api/preferences.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + cursorPage, + type PreferenceKind, + type PreferenceStatus, + PreferenceSuppression, + PreferenceTag, + type PreferenceTagCreate, + type PreferenceTagUpdate, +} from "@umt/contracts"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 偏好标签与抑制记录(REQ §6.7):停用/删除/语义变更都会留抑制记录,Agent 不得据旧证据自动加回。 + +export const preferenceKeys = { + all: ["preferences"] as const, + tags: (f: TagFilters) => ["preferences", "tags", f] as const, + suppressions: (active?: boolean) => ["preferences", "suppressions", active ?? "all"] as const, +}; + +export type TagFilters = { status?: PreferenceStatus; kind?: PreferenceKind }; + +const TagPage = cursorPage(PreferenceTag); +const SupPage = cursorPage(PreferenceSuppression); + +/** 缺省不含已删除;status=deleted 单独取(历史) */ +export function usePreferenceTags(filters: TagFilters = {}) { + return useQuery({ + queryKey: preferenceKeys.tags(filters), + queryFn: ({ signal }) => getParsed(TagPage, "/preference-tags", { ...filters, limit: 200 }, signal), + }); +} + +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, + ), + }); +} + +function useInvalidate() { + const qc = useQueryClient(); + return () => void qc.invalidateQueries({ queryKey: preferenceKeys.all }); +} + +const parseTag = (d: unknown) => parseOrThrow(PreferenceTag, d, "/preference-tags"); + +export function useCreateTag() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: PreferenceTagCreate) => api.post("/preference-tags", body).then(parseTag), + onSuccess: done, + }); +} + +/** 编辑 / 停用(status=disabled)/ 重新启用(status=active,同时解除同语义抑制) */ +export function useUpdateTag() { + const done = useInvalidate(); + return useMutation({ + mutationFn: ({ id, ...body }: PreferenceTagUpdate & { id: string }) => + api.patch(`/preference-tags/${id}`, body).then(parseTag), + onSuccess: done, + }); +} + +/** 删除:标记 deleted 并留抑制记录 */ +export function useDeleteTag() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.del(`/preference-tags/${id}`), + onSuccess: done, + }); +} + +/** 解除抑制(明确授权后 Agent 才能再次提出同一偏好) */ +export function useLiftSuppression() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (v: { id: string; reason?: string }) => + api + .patch(`/preference-suppressions/${v.id}`, { active: false, reason: v.reason }) + .then((d) => parseOrThrow(PreferenceSuppression, d, "/preference-suppressions")), + onSuccess: done, + }); +} diff --git a/apps/web/app/api/slots.ts b/apps/web/app/api/slots.ts new file mode 100644 index 00000000..1360604f --- /dev/null +++ b/apps/web/app/api/slots.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AcquisitionSlot, + type AcquisitionSlotCreate, + type AcquisitionSlotUpdate, + cursorPage, + type SlotStatus, +} from "@umt/contracts"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 收录目标 Slot:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件(REQ §6.1) + +export const slotKeys = { + all: ["slots"] as const, + list: (f: SlotFilters) => ["slots", "list", f] as const, +}; + +export type SlotFilters = { mediaEntityId?: string; status?: SlotStatus }; + +const Page = cursorPage(AcquisitionSlot); + +export function useSlots(filters: SlotFilters, enabled = true) { + return useQuery({ + queryKey: slotKeys.list(filters), + enabled, + queryFn: ({ signal }) => getParsed(Page, "/acquisition-slots", { ...filters, limit: 200 }, signal), + }); +} + +function useInvalidate() { + const qc = useQueryClient(); + return () => { + void qc.invalidateQueries({ queryKey: slotKeys.all }); + void qc.invalidateQueries({ queryKey: ["library"] }); + }; +} + +export function useCreateSlot() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: AcquisitionSlotCreate) => + api.post("/acquisition-slots", body).then((d) => parseOrThrow(AcquisitionSlot, d, "/acquisition-slots")), + onSuccess: done, + }); +} + +/** 规则修改带 expectedRevision(乐观并发);status 只由状态机推进 */ +export function useUpdateSlot() { + const done = useInvalidate(); + return useMutation({ + mutationFn: ({ id, ...body }: AcquisitionSlotUpdate & { id: string }) => + api + .patch(`/acquisition-slots/${id}`, body) + .then((d) => parseOrThrow(AcquisitionSlot, d, "/acquisition-slots")), + onSuccess: done, + }); +} diff --git a/apps/web/app/api/subscriptions.ts b/apps/web/app/api/subscriptions.ts new file mode 100644 index 00000000..9fb4688b --- /dev/null +++ b/apps/web/app/api/subscriptions.ts @@ -0,0 +1,65 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + cursorPage, + MediaEntity, + Subscription, + type SubscriptionCreate, + type SubscriptionStatus, + type SubscriptionUpdate, +} from "@umt/contracts"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 订阅:单次收录 / 持续追踪,仅本季 / 接续后续季(REQ §6.5) + +export const subscriptionKeys = { + all: ["subscriptions"] as const, + list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const, +}; + +export type SubscriptionFilters = { status?: SubscriptionStatus; mediaEntityId?: string }; + +const Page = cursorPage(Subscription); + +export function useSubscriptions(filters: SubscriptionFilters) { + return useInfiniteQuery({ + queryKey: subscriptionKeys.list(filters), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + getParsed(Page, "/subscriptions", { ...filters, cursor: pageParam, limit: 50 }, signal), + getNextPageParam: (last) => last.nextCursor ?? undefined, + placeholderData: keepPreviousData, + }); +} + +export function useCreateSubscription() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: SubscriptionCreate) => + api.post("/subscriptions", body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: subscriptionKeys.all }); + void qc.invalidateQueries({ queryKey: ["slots"] }); + void qc.invalidateQueries({ queryKey: ["library"] }); + }, + }); +} + +export function useUpdateSubscription() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...body }: SubscriptionUpdate & { id: string }) => + api.patch(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")), + onSuccess: () => void qc.invalidateQueries({ queryKey: subscriptionKeys.all }), + }); +} + +/** 订阅行上显示作品标题(旧 ID 自动跳转);按 ID 缓存 */ +export function useEntityTitle(id: string | null | undefined) { + return useQuery({ + queryKey: ["library", "entity-row", id ?? ""], + enabled: !!id, + staleTime: 5 * 60_000, + queryFn: ({ signal }) => getParsed(MediaEntity, `/media-entities/${id}`, undefined, signal), + }); +} diff --git a/apps/web/app/components/library/entity-dialogs.tsx b/apps/web/app/components/library/entity-dialogs.tsx new file mode 100644 index 00000000..f66511e7 --- /dev/null +++ b/apps/web/app/components/library/entity-dialogs.tsx @@ -0,0 +1,439 @@ +import { + EntityIdentitySet, + type EntityMetadataField, + type ExternalIdentity, + type MediaEntity, + type MediaKind, +} from "@umt/contracts"; +import { Check, LoaderCircle, Search } from "lucide-react"; +import { type FormEvent, useId, useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { z } from "zod"; +import { useEntitySearch, useLockField, useMergeEntity, useSetEntityIdentity } from "~/api/library.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { Textarea } from "~/components/ui/textarea.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; +import { ENTITY_FIELD_LABEL, EVIDENCE_LEVEL, IDENTITY_STATUS, MEDIA_KIND } from "./labels.ts"; +import { Tone } from "./parts.tsx"; + +// 作品详情页的三个操作对话框:改外部映射 / 字段覆盖并锁定 / 合并到另一作品。 + +// ── 改外部映射 ── + +const IdentityForm = EntityIdentitySet.extend({ + externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"), +}); + +export function IdentityDialog({ + entityId, + entityKind, + open, + onOpenChange, + candidates, +}: { + entityId: string; + entityKind: MediaKind; + open: boolean; + onOpenChange: (o: boolean) => void; + /** 可直接选用的候选(proposed / conflicted 映射) */ + candidates: ExternalIdentity[]; +}) { + const id = useId(); + const set = useSetEntityIdentity(entityId); + const [pick, setPick] = useState(null); + const [entityType, setEntityType] = useState<"movie" | "tv">(entityKind === "movie" ? "movie" : "tv"); + const [externalId, setExternalId] = useState(""); + const [lock, setLock] = useState(true); + const [reason, setReason] = useState(""); + const [errors, setErrors] = useState>({}); + + const choose = (c: ExternalIdentity | null) => { + setPick(c?.id ?? null); + if (c && (c.entityType === "movie" || c.entityType === "tv")) { + setEntityType(c.entityType); + setExternalId(c.externalId); + } + }; + + const submit = (e: FormEvent) => { + e.preventDefault(); + const r = IdentityForm.safeParse({ + provider: "tmdb", + entityType, + externalId, + lock, + reason: reason.trim() || undefined, + }); + if (!r.success) { + setErrors(zodFieldErrors(r.error.issues)); + return; + } + setErrors({}); + set.mutate(r.data, { + onSuccess: () => { + toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`); + onOpenChange(false); + }, + onError: (err) => toast.error(errorMessage(err, "改映射失败")), + }); + }; + + const usable = candidates.filter((c) => c.provider === "tmdb" && (c.entityType === "movie" || c.entityType === "tv")); + + return ( + !set.isPending && onOpenChange(o)}> + + + 改外部映射 + + 旧的同类型 TMDB 映射会退役并留下修订记录;新映射直接确认。加人工锁后自动匹配与刷新不再改动它。 + + +
+ {usable.length ? ( +
+

从候选中选

+
    + {usable.map((c) => ( +
  • + +
  • + ))} +
+
+ ) : null} +
+ + + + + { + setExternalId(e.target.value); + setPick(null); + }} + placeholder="如 693134" + /> + +
+ +