feat(web): W3-2 library, subscriptions and rules, preferences, decisions inbox, identity and metadata
- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons, content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions - subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion), policy revision draft -> preview -> publish (grant warning) -> rollback - preferences: tag CRUD, disable/enable, suppression records and lift, history timeline - inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh - identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management - all responses validated against contracts zod schemas Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e5e42b1f0d
commit
ef73abe3df
25 files changed
+6670
-19
No files matched your search
@@ -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<typeof DecisionSummary>;
|
||||
|
||||
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<unknown>(`/decisions/${id}/resolve`, body).then((d) => parseOrThrow(Decision, d, "/decisions")),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: ["identity"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<unknown>(`/identity-pool/${id}/assign`, body),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissPool() {
|
||||
const done = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (v: { id: string; note?: string }) =>
|
||||
api.post<unknown>(`/identity-pool/${v.id}/dismiss`, { note: v.note }),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetryPool() {
|
||||
const done = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.post<unknown>(`/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<unknown>(`/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<unknown>("/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<unknown>(`/title-aliases/${id}`),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
@@ -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<S extends z.ZodType>(
|
||||
schema: S,
|
||||
path: string,
|
||||
params?: QueryParams,
|
||||
signal?: AbortSignal,
|
||||
): Promise<z.infer<S>> {
|
||||
return parseOrThrow(schema, await api.get<unknown>(path, params, signal), path);
|
||||
}
|
||||
|
||||
export function parseOrThrow<S extends z.ZodType>(schema: S, data: unknown, path: string): z.infer<S> {
|
||||
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<typeof LibraryPage>;
|
||||
|
||||
/** 媒体库列表:游标分页(加载更多) */
|
||||
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<unknown>(`/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<unknown>(`/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<unknown>(`/media-entities/${id}/fields/${field}`),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新元数据(字段级;用户锁优先) */
|
||||
export function useRefreshMetadata(id: string) {
|
||||
const done = useInvalidateEntity(id);
|
||||
return useMutation({
|
||||
mutationFn: () => api.post<unknown>(`/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<unknown>(`/media-entities/${id}/merge`, v),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: libraryKeys.all }),
|
||||
});
|
||||
}
|
||||
@@ -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<typeof PolicyPreview>;
|
||||
|
||||
const parse = (d: unknown) => parseOrThrow(PolicyRevision, d, "/policy-revisions");
|
||||
|
||||
function useAction<V>(fn: (v: V) => Promise<unknown>) {
|
||||
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`));
|
||||
@@ -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<unknown>("/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<unknown>(`/preference-tags/${id}`, body).then(parseTag),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除:标记 deleted 并留抑制记录 */
|
||||
export function useDeleteTag() {
|
||||
const done = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.del<unknown>(`/preference-tags/${id}`),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
/** 解除抑制(明确授权后 Agent 才能再次提出同一偏好) */
|
||||
export function useLiftSuppression() {
|
||||
const done = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (v: { id: string; reason?: string }) =>
|
||||
api
|
||||
.patch<unknown>(`/preference-suppressions/${v.id}`, { active: false, reason: v.reason })
|
||||
.then((d) => parseOrThrow(PreferenceSuppression, d, "/preference-suppressions")),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
@@ -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<unknown>("/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<unknown>(`/acquisition-slots/${id}`, body)
|
||||
.then((d) => parseOrThrow(AcquisitionSlot, d, "/acquisition-slots")),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
@@ -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<unknown>("/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<unknown>(`/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),
|
||||
});
|
||||
}
|
||||
@@ -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<string | null>(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<Record<string, string>>({});
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(o) => !set.isPending && onOpenChange(o)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>改外部映射</DialogTitle>
|
||||
<DialogDescription>
|
||||
旧的同类型 TMDB 映射会退役并留下修订记录;新映射直接确认。加人工锁后自动匹配与刷新不再改动它。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
{usable.length ? (
|
||||
<div className="grid gap-1.5">
|
||||
<p className="text-caption font-medium text-foreground">从候选中选</p>
|
||||
<ul className="grid gap-1.5">
|
||||
{usable.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose(pick === c.id ? null : c)}
|
||||
aria-pressed={pick === c.id}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors duration-micro",
|
||||
pick === c.id ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
{pick === c.id ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
|
||||
<span className="min-w-0 flex-1 font-mono text-caption break-all">
|
||||
tmdb:{c.entityType}:{c.externalId}
|
||||
</span>
|
||||
<Tone v={IDENTITY_STATUS[c.status]} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-[8rem_1fr]">
|
||||
<FormField label="类型" htmlFor={`${id}-type`}>
|
||||
<Select value={entityType} onValueChange={(v) => setEntityType(v as "movie" | "tv")}>
|
||||
<SelectTrigger id={`${id}-type`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="movie">movie</SelectItem>
|
||||
<SelectItem value="tv">tv</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="TMDB id" htmlFor={`${id}-ext`} error={errors.externalId} required>
|
||||
<Input
|
||||
id={`${id}-ext`}
|
||||
inputMode="numeric"
|
||||
value={externalId}
|
||||
aria-invalid={!!errors.externalId}
|
||||
onChange={(e) => {
|
||||
setExternalId(e.target.value);
|
||||
setPick(null);
|
||||
}}
|
||||
placeholder="如 693134"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="原因" htmlFor={`${id}-reason`} error={errors.reason} hint="写进修订历史">
|
||||
<Textarea id={`${id}-reason`} value={reason} onChange={(e) => setReason(e.target.value)} rows={2} />
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">加人工锁</p>
|
||||
<p className="text-meta text-muted-foreground">自动匹配不得覆盖人工锁定的关系</p>
|
||||
</div>
|
||||
<Switch checked={lock} onCheckedChange={setLock} aria-label="加人工锁" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={set.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={set.isPending}>
|
||||
{set.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存映射
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 字段覆盖并锁定 ──
|
||||
|
||||
const FieldForm = z.object({
|
||||
value: z.string().trim().min(1, "请填写值"),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export function FieldLockDialog({
|
||||
entityId,
|
||||
field,
|
||||
current,
|
||||
onOpenChange,
|
||||
}: {
|
||||
entityId: string;
|
||||
/** null 表示关闭 */
|
||||
field: EntityMetadataField | null;
|
||||
current: unknown;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const lock = useLockField(entityId);
|
||||
const [value, setValue] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [lastField, setLastField] = useState<string | null>(null);
|
||||
if (field !== lastField) {
|
||||
setLastField(field);
|
||||
setValue(current === null || current === undefined ? "" : String(current));
|
||||
setReason("");
|
||||
setErrors({});
|
||||
}
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!field) return;
|
||||
const r = FieldForm.safeParse({ value, reason: reason.trim() || undefined });
|
||||
if (!r.success) {
|
||||
setErrors(zodFieldErrors(r.error.issues));
|
||||
return;
|
||||
}
|
||||
let v: unknown = r.data.value;
|
||||
if (field === "year") {
|
||||
const n = Number(r.data.value);
|
||||
if (!Number.isInteger(n) || n < 1870 || n > 2200) {
|
||||
setErrors({ value: "年份需为 1870–2200 的整数" });
|
||||
return;
|
||||
}
|
||||
v = n;
|
||||
}
|
||||
lock.mutate(
|
||||
{ field, value: v, reason: r.data.reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`已锁定「${ENTITY_FIELD_LABEL[field] ?? field}」`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, "锁定失败")),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const long = field === "overview";
|
||||
return (
|
||||
<Dialog open={!!field} onOpenChange={(o) => !lock.isPending && onOpenChange(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
|
||||
<DialogDescription>保存为人工值并加锁,之后刷新元数据不会改写这个字段;不影响已绑定的路径。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-3">
|
||||
<FormField label="值" htmlFor={`${id}-v`} error={errors.value} required>
|
||||
{long ? (
|
||||
<Textarea
|
||||
id={`${id}-v`}
|
||||
rows={4}
|
||||
value={value}
|
||||
aria-invalid={!!errors.value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`${id}-v`}
|
||||
value={value}
|
||||
inputMode={field === "year" ? "numeric" : undefined}
|
||||
aria-invalid={!!errors.value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="原因" htmlFor={`${id}-r`} error={errors.reason}>
|
||||
<Input id={`${id}-r`} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={lock.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={lock.isPending}>
|
||||
{lock.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存并锁定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 合并到另一作品 ──
|
||||
|
||||
/** 作品检索选择(合并目标等):输入即搜,点选一项 */
|
||||
export function EntityPicker({
|
||||
value,
|
||||
onChange,
|
||||
excludeId,
|
||||
kind,
|
||||
}: {
|
||||
value: MediaEntity | null;
|
||||
onChange: (e: MediaEntity | null) => void;
|
||||
excludeId?: string;
|
||||
kind?: MediaKind;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const search = useEntitySearch(q, { kind });
|
||||
const items = (search.data?.items ?? []).filter((e) => e.id !== excludeId && e.status !== "merged");
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索作品标题"
|
||||
className="pl-9"
|
||||
aria-label="搜索作品"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto rounded-lg border border-border">
|
||||
{search.isPending ? (
|
||||
<p className="px-3 py-3 text-meta text-muted-foreground">搜索中…</p>
|
||||
) : search.isError ? (
|
||||
<p className="px-3 py-3 text-meta text-destructive">{errorMessage(search.error, "搜索失败")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-3 py-3 text-meta text-muted-foreground">没有匹配的作品</p>
|
||||
) : (
|
||||
<ul>
|
||||
{items.map((e) => {
|
||||
const on = value?.id === e.id;
|
||||
return (
|
||||
<li key={e.id} className="border-b border-border/60 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onChange(on ? null : e)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro",
|
||||
on ? "bg-foreground/[0.06]" : "hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
{on ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-foreground">{e.title}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">
|
||||
{MEDIA_KIND[e.kind]}
|
||||
{e.year ? ` · ${e.year}` : ""}
|
||||
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MergeDialog({
|
||||
entity,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
entity: MediaEntity;
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const merge = useMergeEntity(entity.id);
|
||||
const [target, setTarget] = useState<MediaEntity | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open && !confirm} onOpenChange={(o) => !o && onOpenChange(false)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>合并到另一作品</DialogTitle>
|
||||
<DialogDescription>
|
||||
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品;订阅、资产与历史引用一并转到目标。错误合并可以再修订。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
|
||||
<FormField label="原因" htmlFor="merge-reason" hint="写入审计与跳转记录">
|
||||
<Input
|
||||
id="merge-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="重复建档"
|
||||
/>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!target} onClick={() => setConfirm(true)}>
|
||||
下一步
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirm}
|
||||
onOpenChange={(o) => {
|
||||
setConfirm(o);
|
||||
if (!o && !merge.isSuccess) return;
|
||||
}}
|
||||
title="确认合并?"
|
||||
description={
|
||||
target
|
||||
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」。合并后本页将跳转到目标作品。`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="合并"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!target) return;
|
||||
try {
|
||||
await merge.mutateAsync({ intoId: target.id, reason: reason.trim() || "控制台合并" });
|
||||
toast.success("已合并");
|
||||
onOpenChange(false);
|
||||
navigate(consolePath("library", target.id));
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "合并失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const evidenceLabel = (l: string) => EVIDENCE_LEVEL[l] ?? l;
|
||||
@@ -0,0 +1,217 @@
|
||||
import { AliasKind, type TitleAlias } from "@umt/contracts";
|
||||
import { ExternalLink, Lock, Plus, Trash2 } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useAliases, useCreateAlias, useDeleteAlias } from "~/api/identity.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar } from "~/components/console/filter-bar.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 { 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 { errorMessage } from "~/lib/api.ts";
|
||||
import { EntityPicker, type PickedEntity } from "./identity-entity-picker.tsx";
|
||||
import { METADATA_SOURCE } from "./labels.ts";
|
||||
import { Chip, Panel } from "./parts.tsx";
|
||||
|
||||
// 别名管理:检索尽量广(原名/译名/简称/站点标题),确认关联仍严格;用户新增的别名 source=user
|
||||
|
||||
const ALIAS_KIND: Record<z.infer<typeof AliasKind>, string> = {
|
||||
original: "原名",
|
||||
translated: "译名",
|
||||
short: "简称",
|
||||
site_title: "站点标题",
|
||||
romanized: "罗马字",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const AliasForm = z.object({
|
||||
alias: z.string().trim().min(1, "请填写别名").max(200, "最多 200 字"),
|
||||
kind: AliasKind,
|
||||
language: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(16, "语言代码过长")
|
||||
.regex(/^$|^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, "如 zh-CN、en"),
|
||||
locked: z.boolean(),
|
||||
});
|
||||
|
||||
function AddAlias({ entity }: { entity: PickedEntity }) {
|
||||
const create = useCreateAlias();
|
||||
const [f, setF] = useState({ alias: "", kind: "short" as z.infer<typeof AliasKind>, language: "", locked: true });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const r = AliasForm.safeParse(f);
|
||||
if (!r.success) {
|
||||
setErrors(zodFieldErrors(r.error.issues));
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
mediaEntityId: entity.id,
|
||||
alias: r.data.alias,
|
||||
kind: r.data.kind,
|
||||
language: r.data.language || null,
|
||||
locked: r.data.locked,
|
||||
source: "user",
|
||||
});
|
||||
toast.success(`已为「${entity.title}」添加别名`);
|
||||
setF((x) => ({ ...x, alias: "" }));
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
className="grid gap-2 border-t border-border/60 pt-3 sm:grid-cols-[1fr_9rem_7rem_auto_auto] sm:items-start"
|
||||
>
|
||||
<FormField label="新别名" htmlFor="alias-new" error={errors.alias}>
|
||||
<Input
|
||||
id="alias-new"
|
||||
value={f.alias}
|
||||
aria-invalid={!!errors.alias}
|
||||
onChange={(e) => setF((x) => ({ ...x, alias: e.target.value }))}
|
||||
placeholder="如:沙丘2"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="类型" htmlFor="alias-kind">
|
||||
<Select value={f.kind} onValueChange={(v) => setF((x) => ({ ...x, kind: v as typeof f.kind }))}>
|
||||
<SelectTrigger id="alias-kind" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(Object.keys(ALIAS_KIND) as (keyof typeof ALIAS_KIND)[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ALIAS_KIND[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="语言" htmlFor="alias-lang" error={errors.language}>
|
||||
<Input
|
||||
id="alias-lang"
|
||||
value={f.language}
|
||||
aria-invalid={!!errors.language}
|
||||
onChange={(e) => setF((x) => ({ ...x, language: e.target.value }))}
|
||||
placeholder="zh-CN"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="锁定" htmlFor="alias-lock" hint="自动刷新不删">
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch id="alias-lock" checked={f.locked} onCheckedChange={(v) => setF((x) => ({ ...x, locked: v }))} />
|
||||
</div>
|
||||
</FormField>
|
||||
<Button type="submit" className="sm:mt-6" disabled={create.isPending}>
|
||||
<Plus />
|
||||
添加
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function AliasesPanel() {
|
||||
const [entity, setEntity] = useState<PickedEntity | null>(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [dq, setDq] = useState("");
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDq(q.trim()), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
const list = useAliases({ mediaEntityId: entity?.id, q: dq || undefined });
|
||||
const del = useDeleteAlias();
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
const columns: DataColumn<TitleAlias>[] = [
|
||||
{
|
||||
key: "alias",
|
||||
header: "别名",
|
||||
cell: (a) => (
|
||||
<span className="inline-flex items-center gap-1.5 break-all text-foreground">
|
||||
{a.locked ? <Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-label="已锁定" /> : null}
|
||||
{a.alias}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "kind", header: "类型", cell: (a) => <Chip>{ALIAS_KIND[a.kind]}</Chip> },
|
||||
{ key: "lang", header: "语言", hideBelow: "md", cell: (a) => a.language ?? "—" },
|
||||
{ key: "source", header: "来源", hideBelow: "sm", cell: (a) => METADATA_SOURCE[a.source] ?? a.source },
|
||||
{
|
||||
key: "entity",
|
||||
header: "作品",
|
||||
hideBelow: "md",
|
||||
cell: (a) => (
|
||||
<Link
|
||||
to={consolePath("library", a.mediaEntityId)}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
查看
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "act",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (a) => (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`删除别名 ${a.alias}`}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除别名「${a.alias}」?`}
|
||||
description="删除后该别名不再参与模糊召回;已确认的身份映射不受影响。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await del.mutateAsync(a.id);
|
||||
toast.success("别名已删除");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Panel title="按作品管理" description="选中作品后可新增别名;不选则按关键字检索全部别名" bodyClassName="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
<FilterBar search={{ value: q, onChange: setQ, placeholder: "按别名关键字过滤" }} className="mb-0" />
|
||||
</div>
|
||||
{entity ? <AddAlias key={entity.id} entity={entity} /> : null}
|
||||
</Panel>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(a) => a.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
empty={{
|
||||
title: entity ? `「${entity.title}」还没有别名` : "没有匹配的别名",
|
||||
description: "别名用于从原始标题、译名、简称找回作品。",
|
||||
}}
|
||||
hasMore={list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { MediaKind } from "@umt/contracts";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEntitySearch } from "~/api/library.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { MEDIA_KIND } from "./labels.ts";
|
||||
|
||||
export type PickedEntity = { id: string; title: string; year: number | null; kind: MediaKind };
|
||||
|
||||
/** 本地作品选择:输入关键字检索 media_entity,点选一项 */
|
||||
export function EntityPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "搜索本地作品(标题/原名)",
|
||||
id,
|
||||
}: {
|
||||
value: PickedEntity | null;
|
||||
onChange: (v: PickedEntity | null) => void;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(q), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
const search = useEntitySearch(debounced, { enabled: !value && debounced.trim().length > 0 });
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-2">
|
||||
<Check className="h-4 w-4 shrink-0 text-success" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{value.title}
|
||||
{value.year ? ` (${value.year})` : ""} · {MEDIA_KIND[value.kind]}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="清除选择" onClick={() => onChange(null)}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const items = search.data?.items ?? [];
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input id={id} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="pl-9" />
|
||||
</div>
|
||||
{debounced.trim() ? (
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-border">
|
||||
{search.isPending ? (
|
||||
<LoadingState rows={2} className="p-2" />
|
||||
) : search.isError ? (
|
||||
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="p-3 text-meta text-muted-foreground">没有匹配的本地作品</p>
|
||||
) : (
|
||||
<ul>
|
||||
{items.map((e) => (
|
||||
<li key={e.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ id: e.id, title: e.title, year: e.year, kind: e.kind });
|
||||
setQ("");
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-micro hover:bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">
|
||||
{e.title}
|
||||
{e.year ? ` (${e.year})` : ""}
|
||||
</span>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { ExternalIdentity, IdentityRevision } from "@umt/contracts";
|
||||
import { ExternalLink, Lock, LockOpen } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useIdentityRevisions, useUpdateIdentity } from "~/api/identity.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Timeline } from "~/components/console/timeline.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { EVIDENCE_LEVEL, IDENTITY_STATUS, VERIFY_STATUS } from "./labels.ts";
|
||||
import { Tone } from "./parts.tsx";
|
||||
|
||||
const ACTION: Record<IdentityRevision["action"], string> = {
|
||||
created: "创建",
|
||||
status_changed: "状态变更",
|
||||
relinked: "重新关联",
|
||||
evidence_updated: "证据更新",
|
||||
locked: "加人工锁",
|
||||
unlocked: "解除人工锁",
|
||||
};
|
||||
const ACTOR: Record<string, string> = { user: "用户", agent: "Agent", system: "系统", api_key: "API Key" };
|
||||
|
||||
export const identityRef = (i: Pick<ExternalIdentity, "provider" | "entityType" | "externalId">) =>
|
||||
`${i.provider}:${i.entityType}:${i.externalId}`;
|
||||
|
||||
function Revisions({ id }: { id: string }) {
|
||||
const q = useIdentityRevisions(id);
|
||||
if (q.isPending) return <LoadingState rows={2} />;
|
||||
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
|
||||
if (q.data.length === 0) return <EmptyState plain title="没有修订记录" className="py-6" />;
|
||||
const items = [...q.data].sort((a, b) => b.revision - a.revision);
|
||||
return (
|
||||
<Timeline
|
||||
items={items.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.createdAt,
|
||||
tone: r.toStatus ? IDENTITY_STATUS[r.toStatus].tone : "neutral",
|
||||
title: `r${r.revision} · ${ACTION[r.action]}${
|
||||
r.fromStatus || r.toStatus
|
||||
? `:${r.fromStatus ? IDENTITY_STATUS[r.fromStatus].label : "无"} → ${r.toStatus ? IDENTITY_STATUS[r.toStatus].label : "无"}`
|
||||
: ""
|
||||
}`,
|
||||
meta: `${ACTOR[r.actorType] ?? r.actorType}${r.actorId ? ` ${r.actorId}` : ""}`,
|
||||
description: r.reason ?? undefined,
|
||||
children: (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<JsonView label="修订前" value={r.before} defaultOpen={false} maxHeight="12rem" />
|
||||
<JsonView label="修订后" value={r.after} defaultOpen={false} maxHeight="12rem" />
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalIdentitySheet({ item, onClose }: { item: ExternalIdentity | null; onClose: () => void }) {
|
||||
const update = useUpdateIdentity();
|
||||
if (!item) return null;
|
||||
const toggleLock = async () => {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: item.id,
|
||||
expectedRevision: item.revision,
|
||||
locked: !item.locked,
|
||||
reason: item.locked ? "控制台解除人工锁" : "控制台加人工锁",
|
||||
});
|
||||
toast.success(item.locked ? "已解除人工锁,自动匹配可再次修订" : "已加人工锁,自动匹配不会改动");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
};
|
||||
const target = item.mediaEntityId;
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
width="lg"
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={identityRef(item)}
|
||||
description="外部身份是辅助映射;本地 UUID 才是主身份"
|
||||
footer={
|
||||
<Button variant="outline" onClick={toggleLock} disabled={update.isPending || item.status === "retired"}>
|
||||
{item.locked ? <LockOpen /> : <Lock />}
|
||||
{item.locked ? "解除人工锁" : "加人工锁"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: <Tone v={IDENTITY_STATUS[item.status]} /> },
|
||||
{ label: "证据等级", value: EVIDENCE_LEVEL[item.evidenceLevel] ?? item.evidenceLevel },
|
||||
{ label: "人工锁", value: item.locked ? "已锁定" : "未锁定" },
|
||||
{ label: "外部校验", value: <Tone v={VERIFY_STATUS[item.verifyStatus]} /> },
|
||||
{ label: "校验时间", value: formatDateTime(item.verifiedAt) },
|
||||
{ label: "修订号", value: `r${item.revision}` },
|
||||
{
|
||||
label: "关联",
|
||||
value: target ? (
|
||||
<Link
|
||||
to={consolePath("library", target)}
|
||||
className="inline-flex items-center gap-1 underline-offset-4 hover:underline"
|
||||
>
|
||||
查看作品
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
`内容单元 ${item.contentUnitId ?? "—"}`
|
||||
),
|
||||
},
|
||||
{ label: "有效期", value: item.validUntil ? `至 ${formatDateTime(item.validUntil)}` : "长期" },
|
||||
]}
|
||||
/>
|
||||
<JsonView label="证据" value={Object.keys(item.evidence).length ? item.evidence : null} />
|
||||
<DetailSection title="修订历史">
|
||||
<Revisions id={item.id} />
|
||||
</DetailSection>
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { IdentityPoolItem, PoolCandidate } from "@umt/contracts";
|
||||
import { ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useAssignPool, useDismissPool, useRetryPool } from "~/api/identity.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { EntityPicker, type PickedEntity } from "./identity-entity-picker.tsx";
|
||||
import { EVIDENCE_LEVEL, POOL_REASON, POOL_STATUS } from "./labels.ts";
|
||||
import { Chip, Tone } from "./parts.tsx";
|
||||
|
||||
// 待识别条目详情:候选证据 + 三种人工指定方式(候选 / 手动 TMDB id / 本地作品)+ 忽略 / 重试。指定后默认加人工锁。
|
||||
|
||||
const TmdbForm = z.object({
|
||||
entityType: z.enum(["movie", "tv"]),
|
||||
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
|
||||
});
|
||||
|
||||
/** query 摘要:标题 / 年份 / 季集 */
|
||||
export function querySummary(q: Record<string, unknown>): string {
|
||||
const parts: string[] = [];
|
||||
if (q.title) parts.push(String(q.title));
|
||||
if (q.year) parts.push(String(q.year));
|
||||
if (q.season !== undefined && q.season !== null) parts.push(`S${String(q.season).padStart(2, "0")}`);
|
||||
if (q.episode !== undefined && q.episode !== null) parts.push(`E${String(q.episode).padStart(2, "0")}`);
|
||||
if (q.segment) parts.push(String(q.segment));
|
||||
if (q.cut) parts.push(String(q.cut));
|
||||
return parts.join(" · ") || "—";
|
||||
}
|
||||
|
||||
function CandidateRow({
|
||||
c,
|
||||
index,
|
||||
onPick,
|
||||
disabled,
|
||||
}: {
|
||||
c: PoolCandidate;
|
||||
index: number;
|
||||
onPick: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="min-w-0 rounded-lg border border-border/80 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium break-words text-foreground">
|
||||
{index + 1}. {c.title}
|
||||
{c.year ? ` (${c.year})` : ""}
|
||||
</p>
|
||||
<p className="font-mono text-meta break-all text-muted-foreground">
|
||||
{c.provider}:{c.entityType}:{c.externalId}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onPick} disabled={disabled}>
|
||||
指定此候选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<Chip>{EVIDENCE_LEVEL[c.level] ?? c.level}</Chip>
|
||||
{c.localEntityId ? (
|
||||
<Link
|
||||
to={consolePath("library", c.localEntityId)}
|
||||
className="inline-flex items-center gap-1 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
本地作品
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-meta text-muted-foreground">本地未建档,指定后按外部详情建档</span>
|
||||
)}
|
||||
</div>
|
||||
{c.evidence.length ? (
|
||||
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-meta text-muted-foreground">
|
||||
{c.evidence.map((e) => (
|
||||
<li key={e} className="break-words">
|
||||
{e}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; onClose: () => void }) {
|
||||
const assign = useAssignPool();
|
||||
const dismiss = useDismissPool();
|
||||
const retry = useRetryPool();
|
||||
const [tmdb, setTmdb] = useState({ entityType: "movie" as "movie" | "tv", externalId: "" });
|
||||
const [tmdbErr, setTmdbErr] = useState<Record<string, string>>({});
|
||||
const [entity, setEntity] = useState<PickedEntity | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
if (!item) return null;
|
||||
const pending = item.status === "pending";
|
||||
const busy = assign.isPending || dismiss.isPending || retry.isPending;
|
||||
|
||||
const doAssign = async (body: Parameters<typeof assign.mutateAsync>[0], ok: string) => {
|
||||
try {
|
||||
await assign.mutateAsync(body);
|
||||
toast.success(ok);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const submitTmdb = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const r = TmdbForm.safeParse(tmdb);
|
||||
if (!r.success) {
|
||||
setTmdbErr(zodFieldErrors(r.error.issues));
|
||||
return;
|
||||
}
|
||||
setTmdbErr({});
|
||||
void doAssign(
|
||||
{ id: item.id, external: { provider: "tmdb", ...r.data }, lock: true },
|
||||
`已指定 TMDB ${r.data.entityType}:${r.data.externalId} 并加人工锁`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
width="lg"
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item.release.title}
|
||||
description={item.release.subtitle ?? undefined}
|
||||
footer={
|
||||
<>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" disabled={!pending || busy}>
|
||||
忽略
|
||||
</Button>
|
||||
}
|
||||
title="忽略这条待识别资源?"
|
||||
description="忽略后不再自动复查;资源仍可在待识别入口按原始标题找回。"
|
||||
confirmLabel="忽略"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await dismiss.mutateAsync({ id: item.id, note: note.trim() || undefined });
|
||||
toast.success("已忽略");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="备注(可选):为什么忽略"
|
||||
rows={2}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await retry.mutateAsync(item.id);
|
||||
toast.success("已重新识别");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
|
||||
立即重试
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: <Tone v={POOL_STATUS[item.status]} /> },
|
||||
{ label: "原因", value: <Tone v={POOL_REASON[item.reason]} /> },
|
||||
{ label: "解析结果", value: querySummary(item.query), wide: true },
|
||||
{ label: "站点分类", value: item.release.categoryRaw },
|
||||
{ label: "首次发现", value: formatDateTime(item.release.firstSeenAt) },
|
||||
{ label: "尝试次数", value: String(item.attempts) },
|
||||
{ label: "上次尝试", value: formatDateTime(item.lastAttemptAt) },
|
||||
{ label: "下次复查", value: item.nextReviewAt ? formatDateTime(item.nextReviewAt) : "不再自动复查" },
|
||||
{ label: "证据版本", value: item.evidenceVersion, mono: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailSection title={`候选(${item.candidates.length})`}>
|
||||
{item.candidates.length ? (
|
||||
<ul className="space-y-2">
|
||||
{item.candidates.map((c, i) => (
|
||||
<CandidateRow
|
||||
key={`${c.provider}:${c.entityType}:${c.externalId}`}
|
||||
c={c}
|
||||
index={i}
|
||||
disabled={!pending || busy}
|
||||
onPick={() =>
|
||||
doAssign({ id: item.id, candidateIndex: i, lock: true }, `已归入「${c.title}」并加人工锁`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">没有候选。可以手动指定 TMDB id 或本地作品。</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{pending ? (
|
||||
<>
|
||||
<DetailSection title="手动指定 TMDB">
|
||||
<form onSubmit={submitTmdb} className="grid gap-2 sm:grid-cols-[8rem_1fr_auto] sm:items-start" noValidate>
|
||||
<FormField label="类型" htmlFor="pool-tmdb-type">
|
||||
<Select
|
||||
value={tmdb.entityType}
|
||||
onValueChange={(v) => setTmdb((t) => ({ ...t, entityType: v as "movie" | "tv" }))}
|
||||
>
|
||||
<SelectTrigger id="pool-tmdb-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="movie">电影 movie</SelectItem>
|
||||
<SelectItem value="tv">剧集 tv</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="TMDB id" htmlFor="pool-tmdb-id" error={tmdbErr.externalId}>
|
||||
<Input
|
||||
id="pool-tmdb-id"
|
||||
inputMode="numeric"
|
||||
value={tmdb.externalId}
|
||||
aria-invalid={!!tmdbErr.externalId}
|
||||
onChange={(e) => setTmdb((t) => ({ ...t, externalId: e.target.value }))}
|
||||
placeholder="如 693134"
|
||||
/>
|
||||
</FormField>
|
||||
<Button type="submit" variant="outline" className="sm:mt-6" disabled={busy}>
|
||||
指定
|
||||
</Button>
|
||||
</form>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="指定本地作品">
|
||||
<div className="grid gap-2">
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!entity || busy}
|
||||
onClick={() =>
|
||||
entity &&
|
||||
doAssign(
|
||||
{ id: item.id, mediaEntityId: entity.id, lock: true },
|
||||
`已归入「${entity.title}」并加人工锁`,
|
||||
)
|
||||
}
|
||||
>
|
||||
归入该作品
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type {
|
||||
AcquisitionPriority,
|
||||
DecisionKind,
|
||||
DecisionStatus,
|
||||
DownloadStatus,
|
||||
IdentityCandidateReason,
|
||||
IdentityStatus,
|
||||
LocationStatus,
|
||||
MediaEntityStatus,
|
||||
MediaKind,
|
||||
OrganizationStatus,
|
||||
PolicyStatus,
|
||||
PreferenceKind,
|
||||
PreferenceOrigin,
|
||||
PreferenceStatus,
|
||||
SeasonScope,
|
||||
Severity,
|
||||
SlotStatus,
|
||||
SubscriptionMode,
|
||||
SubscriptionStatus,
|
||||
} from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// W3-2 各页共用的取值 → 中文文案 / 状态语气。状态色只表示状态,文案必填。
|
||||
|
||||
type ToneLabel = { label: string; tone: StatusTone };
|
||||
|
||||
export const MEDIA_KIND: Record<MediaKind, string> = {
|
||||
movie: "电影",
|
||||
series: "剧集",
|
||||
variety: "综艺",
|
||||
collection: "合集",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export const ENTITY_STATUS: Record<MediaEntityStatus, ToneLabel> = {
|
||||
provisional: { label: "临时建档", tone: "warning" },
|
||||
active: { label: "正常", tone: "success" },
|
||||
merged: { label: "已合并", tone: "neutral" },
|
||||
archived: { label: "已归档", tone: "neutral" },
|
||||
};
|
||||
|
||||
/** Slot 状态(REQ §6.1):每个状态有明确文本,不只靠颜色 */
|
||||
export const SLOT_STATUS: Record<SlotStatus, ToneLabel> = {
|
||||
empty: { label: "空缺", tone: "neutral" },
|
||||
reserved: { label: "已预留", tone: "neutral" },
|
||||
downloading: { label: "下载中", tone: "neutral" },
|
||||
verifying: { label: "核验中", tone: "neutral" },
|
||||
satisfied: { label: "已满足", tone: "success" },
|
||||
upgrade_pending: { label: "等待升级", tone: "warning" },
|
||||
attention: { label: "需处理", tone: "destructive" },
|
||||
};
|
||||
export const SLOT_STATUS_ORDER: SlotStatus[] = [
|
||||
"attention",
|
||||
"empty",
|
||||
"upgrade_pending",
|
||||
"reserved",
|
||||
"downloading",
|
||||
"verifying",
|
||||
"satisfied",
|
||||
];
|
||||
|
||||
export const IDENTITY_STATUS: Record<IdentityStatus, ToneLabel> = {
|
||||
accepted: { label: "已确认", tone: "success" },
|
||||
proposed: { label: "待确认", tone: "warning" },
|
||||
conflicted: { label: "冲突", tone: "destructive" },
|
||||
retired: { label: "已退役", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const EVIDENCE_LEVEL: Record<string, string> = {
|
||||
manual: "人工确认",
|
||||
rule_confirmed: "规则确认",
|
||||
multi_evidence: "多证据",
|
||||
needs_review: "待复核",
|
||||
needs_confirm: "待确认",
|
||||
rejected: "已排除",
|
||||
};
|
||||
|
||||
export const VERIFY_STATUS: Record<string, ToneLabel> = {
|
||||
unverified: { label: "未校验", tone: "neutral" },
|
||||
ok: { label: "校验通过", tone: "success" },
|
||||
not_found: { label: "外部不存在", tone: "warning" },
|
||||
type_mismatch: { label: "类型不符", tone: "destructive" },
|
||||
error: { label: "校验出错", tone: "warning" },
|
||||
};
|
||||
|
||||
export const LOCATION_STATUS: Record<LocationStatus, ToneLabel> = {
|
||||
available: { label: "可访问", tone: "success" },
|
||||
unavailable: { label: "暂不可访问", tone: "warning" },
|
||||
suspected_missing: { label: "疑似缺失", tone: "warning" },
|
||||
confirmed_missing: { label: "已确认缺失", tone: "destructive" },
|
||||
removed: { label: "已移除", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const LOCATION_KIND: Record<string, string> = {
|
||||
library: "媒体库",
|
||||
download_cache: "下载缓存",
|
||||
hardlink: "硬链接",
|
||||
external: "外部",
|
||||
};
|
||||
|
||||
export const DOWNLOAD_STATUS: Record<DownloadStatus, ToneLabel> = {
|
||||
queued: { label: "排队", tone: "neutral" },
|
||||
submitting: { label: "提交中", tone: "neutral" },
|
||||
downloading: { label: "下载中", tone: "neutral" },
|
||||
paused: { label: "已暂停", tone: "warning" },
|
||||
stalled: { label: "无进展", tone: "warning" },
|
||||
completed: { label: "下载完成", tone: "neutral" },
|
||||
importing: { label: "入库中", tone: "neutral" },
|
||||
imported: { label: "已入库", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
removed: { label: "已移除", tone: "neutral" },
|
||||
missing: { label: "下载器中缺失", tone: "warning" },
|
||||
};
|
||||
|
||||
export const ORG_STATUS: Record<OrganizationStatus, ToneLabel> = {
|
||||
queued: { label: "排队", tone: "neutral" },
|
||||
running: { label: "整理中", tone: "neutral" },
|
||||
succeeded: { label: "核验通过", tone: "success" },
|
||||
partial: { label: "部分成功", tone: "warning" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
|
||||
accept: { label: "接受", tone: "success" },
|
||||
upgrade: { label: "可升级", tone: "success" },
|
||||
reject: { label: "拒绝", tone: "destructive" },
|
||||
unknown_field: { label: "字段未知", tone: "warning" },
|
||||
skipped: { label: "跳过", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
identity_ambiguous: "身份歧义",
|
||||
download_stalled: "下载无进展",
|
||||
download_failed: "下载失败",
|
||||
path_conflict: "路径冲突",
|
||||
space_insufficient: "空间不足",
|
||||
approval_required: "需要授权",
|
||||
metadata_conflict: "元数据冲突",
|
||||
source_unreachable: "来源不可达",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export const DECISION_STATUS: Record<DecisionStatus, ToneLabel> = {
|
||||
open: { label: "待处理", tone: "warning" },
|
||||
investigating: { label: "Agent 调查中", tone: "neutral" },
|
||||
awaiting_user: { label: "等你决定", tone: "warning" },
|
||||
resolved: { label: "已处理", tone: "success" },
|
||||
dismissed: { label: "已忽略", tone: "neutral" },
|
||||
expired: { label: "已过期", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const SEVERITY: Record<Severity, ToneLabel> = {
|
||||
info: { label: "提示", tone: "neutral" },
|
||||
warning: { label: "警告", tone: "warning" },
|
||||
critical: { label: "严重", tone: "destructive" },
|
||||
};
|
||||
|
||||
export const POOL_REASON: Record<IdentityCandidateReason, ToneLabel> = {
|
||||
ambiguous: { label: "多个候选", tone: "warning" },
|
||||
no_match: { label: "没有匹配", tone: "neutral" },
|
||||
proposed: { label: "待确认", tone: "warning" },
|
||||
lookup_failed: { label: "查询失败", tone: "destructive" },
|
||||
};
|
||||
|
||||
export const POOL_STATUS: Record<string, ToneLabel> = {
|
||||
pending: { label: "待识别", tone: "warning" },
|
||||
resolved: { label: "已识别", tone: "success" },
|
||||
dismissed: { label: "已忽略", tone: "neutral" },
|
||||
archived: { label: "已归档", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }> = {
|
||||
soft: { label: "软偏好", hint: "影响排序,不排除" },
|
||||
hard: { label: "硬规则", hint: "命中即排除" },
|
||||
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
|
||||
target: { label: "独立目标", hint: "为作品建立独立的收录目标" },
|
||||
};
|
||||
|
||||
export const PREF_ORIGIN: Record<PreferenceOrigin, string> = {
|
||||
manual: "手动",
|
||||
conversation: "对话确认",
|
||||
agent_inferred: "Agent 推断",
|
||||
};
|
||||
|
||||
export const PREF_STATUS: Record<PreferenceStatus, ToneLabel> = {
|
||||
active: { label: "生效中", tone: "success" },
|
||||
disabled: { label: "已停用", tone: "neutral" },
|
||||
deleted: { label: "已删除", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const POLICY_STATUS: Record<PolicyStatus, ToneLabel> = {
|
||||
draft: { label: "草案", tone: "warning" },
|
||||
published: { label: "已发布", tone: "success" },
|
||||
superseded: { label: "已被替代", tone: "neutral" },
|
||||
rolled_back: { label: "已回滚", tone: "neutral" },
|
||||
archived: { label: "已归档", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const SUB_MODE: Record<SubscriptionMode, string> = { once: "单次收录", continuous: "持续追踪" };
|
||||
export const SEASON_SCOPE: Record<SeasonScope, string> = {
|
||||
current_season: "仅本季",
|
||||
following_seasons: "接续后续季",
|
||||
};
|
||||
export const PRIORITY: Record<AcquisitionPriority, string> = {
|
||||
immediate: "立即",
|
||||
tracking: "追更",
|
||||
new_work: "新作品",
|
||||
upgrade: "升级",
|
||||
};
|
||||
export const SUB_STATUS: Record<SubscriptionStatus, ToneLabel> = {
|
||||
active: { label: "进行中", tone: "success" },
|
||||
paused: { label: "已暂停", tone: "warning" },
|
||||
completed: { label: "已完成", tone: "neutral" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
};
|
||||
export const SUB_ORIGIN: Record<string, string> = { user: "用户", agent: "Agent", policy: "策略", import: "导入" };
|
||||
|
||||
export const METADATA_SOURCE: Record<string, string> = {
|
||||
tmdb: "TMDB",
|
||||
user: "人工",
|
||||
agent: "Agent",
|
||||
nfo: "NFO",
|
||||
site: "站点",
|
||||
import: "导入",
|
||||
};
|
||||
|
||||
/** 可覆盖/锁定的作品字段(contracts EntityMetadataField) */
|
||||
export const ENTITY_FIELD_LABEL: Record<string, string> = {
|
||||
title: "标题",
|
||||
originalTitle: "原名",
|
||||
year: "年份",
|
||||
overview: "简介",
|
||||
releaseDate: "上映/首播日期",
|
||||
originalLanguage: "原始语言",
|
||||
originCountry: "出品地区",
|
||||
posterPath: "海报",
|
||||
};
|
||||
|
||||
export const UNIT_KIND: Record<string, string> = { season: "季", episode: "集", special: "特别篇", part: "分段" };
|
||||
export const SEGMENT: Record<string, string> = {
|
||||
full: "",
|
||||
upper: "上",
|
||||
middle: "中",
|
||||
lower: "下",
|
||||
unknown: "分段未知",
|
||||
};
|
||||
export const CONTENT_TYPE: Record<string, string> = {
|
||||
main: "正片",
|
||||
extra: "花絮",
|
||||
behind_the_scenes: "幕后",
|
||||
performance_only: "纯享",
|
||||
preview: "预告",
|
||||
reaction: "reaction",
|
||||
unknown: "类型未知",
|
||||
};
|
||||
|
||||
export const opts = <K extends string>(rec: Record<K, string | { label: string }>) =>
|
||||
(Object.keys(rec) as K[]).map((value) => {
|
||||
const v = rec[value];
|
||||
return { value, label: typeof v === "string" ? v : v.label };
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Clapperboard, Film, Layers, Tv } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// W3-2 页面内的小零件:区块卡片、标签胶囊、多选胶囊、封面占位。
|
||||
|
||||
/** 内容卡:pg-slab + 标题行。卡内子块用 border 分隔,不再叠玻璃 */
|
||||
export function Panel({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("pg-slab min-w-0 rounded-xl", className)}>
|
||||
{title || actions ? (
|
||||
<header className="flex flex-wrap items-start justify-between gap-2 border-b border-border/60 px-4 py-3 sm:px-5">
|
||||
<div className="min-w-0">
|
||||
{title ? <h2 className="text-body font-semibold text-foreground">{title}</h2> : null}
|
||||
{description ? <p className="mt-0.5 text-meta text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
<div className={cn("px-4 py-4 sm:px-5", bodyClassName)}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 中性小标签(规则摘要、别名等),不是状态 */
|
||||
export function Chip({ children, className, mono }: { children: ReactNode; className?: string; mono?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-foreground/[0.03] px-2 py-0.5 text-meta break-all text-foreground",
|
||||
mono && "font-mono",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChipList({ items, empty = "未设置" }: { items: ReactNode[]; empty?: string }) {
|
||||
if (items.length === 0) return <span className="text-meta text-muted-foreground">{empty}</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((it, i) => (
|
||||
<Chip key={i}>{it}</Chip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 语气 + 文案的状态徽章(label 映射表里的 {label,tone}) */
|
||||
export function Tone({ v, className }: { v: { label: string; tone: StatusTone } | undefined; className?: string }) {
|
||||
if (!v) return <StatusBadge className={className}>未知</StatusBadge>;
|
||||
return (
|
||||
<StatusBadge tone={v.tone} className={className}>
|
||||
{v.label}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
/** 多选胶囊:选中用静态 pill-selected;ordered 时显示选择顺序(软偏好"越靠前越好") */
|
||||
export function ChipToggleGroup<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ordered = false,
|
||||
label,
|
||||
}: {
|
||||
options: readonly { value: T; label: string }[];
|
||||
value: readonly T[];
|
||||
onChange: (v: T[]) => void;
|
||||
ordered?: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<fieldset aria-label={label} className="m-0 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
|
||||
{options.map((o) => {
|
||||
const idx = value.indexOf(o.value);
|
||||
const on = idx >= 0;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])}
|
||||
className={cn(
|
||||
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-meta font-medium transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
|
||||
on
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{ordered && on ? <span className="tabular-nums text-muted-foreground">{idx + 1}.</span> : null}
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<string, LucideIcon> = { movie: Film, series: Tv, variety: Clapperboard, collection: Layers };
|
||||
|
||||
/** 封面占位:无彩材质 + 类型图标 + 首字(不引入海报色彩) */
|
||||
export function CoverPlaceholder({
|
||||
title,
|
||||
kind,
|
||||
small = false,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
kind: string;
|
||||
/** 列表缩略:只显示首字,不叠类型图标 */
|
||||
small?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = KIND_ICON[kind] ?? Film;
|
||||
const glyph = title.replace(/^w32-/, "").trim().charAt(0) || "?";
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"relative grid shrink-0 place-items-center overflow-hidden rounded-lg border border-border bg-foreground/[0.05] text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn("font-semibold text-foreground/35", small ? "text-lead" : "text-title")}>{glyph}</span>
|
||||
{small ? null : <Icon className="absolute right-1.5 bottom-1.5 h-3.5 w-3.5 opacity-60" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 细线进度(不做实心填充) */
|
||||
export function ProgressLine({ value, className }: { value: number; className?: string }) {
|
||||
const pct = Math.max(0, Math.min(1, value)) * 100;
|
||||
return (
|
||||
<div className={cn("h-1 w-full overflow-hidden rounded-full bg-foreground/[0.08]", className)}>
|
||||
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 相对时间("3 小时前"),给列表用;详情仍用 formatDateTime */
|
||||
export function relTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return iso;
|
||||
const s = Math.round((Date.now() - t) / 1000);
|
||||
const abs = Math.abs(s);
|
||||
const suffix = s >= 0 ? "前" : "后";
|
||||
if (abs < 60) return "刚刚";
|
||||
if (abs < 3600) return `${Math.round(abs / 60)} 分钟${suffix}`;
|
||||
if (abs < 86400) return `${Math.round(abs / 3600)} 小时${suffix}`;
|
||||
if (abs < 86400 * 30) return `${Math.round(abs / 86400)} 天${suffix}`;
|
||||
return new Date(iso).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
/** 逗号/顿号/空白分隔的列表 */
|
||||
export const splitList = (s: string) =>
|
||||
s
|
||||
.split(/[,,、\s]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
@@ -0,0 +1,183 @@
|
||||
import { type PreferenceKind, PreferenceKind as PreferenceKindSchema, type PreferenceTag } from "@umt/contracts";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useCreateTag, useUpdateTag } from "~/api/preferences.ts";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
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 { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { PREF_KIND } from "./labels.ts";
|
||||
|
||||
// 偏好标签新增/编辑表单:scope / rule 是结构化 JSON(复杂条件放详情),不是自由文本 tag
|
||||
|
||||
const jsonObject = (name: string) =>
|
||||
z.string().transform((s, ctx) => {
|
||||
if (!s.trim()) return {} as Record<string, unknown>;
|
||||
try {
|
||||
const v: unknown = JSON.parse(s);
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
|
||||
} catch {
|
||||
// 落到下面的错误
|
||||
}
|
||||
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
|
||||
return z.NEVER;
|
||||
});
|
||||
|
||||
const FormSchema = z.object({
|
||||
label: z.string().trim().min(1, "请填写标签名").max(80, "最多 80 字"),
|
||||
kind: PreferenceKindSchema,
|
||||
description: z.string().trim().max(2000, "最多 2000 字"),
|
||||
scope: jsonObject("作用范围"),
|
||||
rule: jsonObject("规则"),
|
||||
});
|
||||
|
||||
type Draft = { label: string; kind: PreferenceKind; description: string; scope: string; rule: string };
|
||||
|
||||
const toDraft = (t?: PreferenceTag | null): Draft => ({
|
||||
label: t?.label ?? "",
|
||||
kind: t?.kind ?? "soft",
|
||||
description: t?.description ?? "",
|
||||
scope: t ? JSON.stringify(t.scope, null, 2) : "{}",
|
||||
rule: t ? JSON.stringify(t.rule, null, 2) : "{}",
|
||||
});
|
||||
|
||||
export function PreferenceFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tag,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
/** 有值为编辑 */
|
||||
tag?: PreferenceTag | null;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(toDraft(tag));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const create = useCreateTag();
|
||||
const update = useUpdateTag();
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(toDraft(tag));
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, tag]);
|
||||
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setDraft((d) => ({ ...d, [k]: v }));
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const r = FormSchema.safeParse(draft);
|
||||
if (!r.success) {
|
||||
setErrors(zodFieldErrors(r.error.issues));
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
const body = { ...r.data, description: r.data.description || null };
|
||||
try {
|
||||
if (tag) {
|
||||
await update.mutateAsync({ id: tag.id, ...body });
|
||||
toast.success("偏好已更新");
|
||||
} else {
|
||||
await create.mutateAsync({ ...body, origin: "manual" });
|
||||
toast.success("偏好已添加");
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !pending && onOpenChange(o)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tag
|
||||
? "修改作用范围或规则会改变语义:旧语义会留下抑制记录,Agent 不会据旧证据加回。"
|
||||
: "手动添加的偏好与对话确认等价;若之前被取消过,重新添加即解除对应抑制。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-2" noValidate>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_12rem]">
|
||||
<FormField label="标签" htmlFor="pref-label" required error={errors.label}>
|
||||
<Input
|
||||
id="pref-label"
|
||||
value={draft.label}
|
||||
aria-invalid={!!errors.label}
|
||||
onChange={(e) => set("label", e.target.value)}
|
||||
placeholder="如:偏爱科幻"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="类型" htmlFor="pref-kind" hint={PREF_KIND[draft.kind].hint}>
|
||||
<Select value={draft.kind} onValueChange={(v) => set("kind", v as PreferenceKind)}>
|
||||
<SelectTrigger id="pref-kind" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(Object.keys(PREF_KIND) as PreferenceKind[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{PREF_KIND[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="说明" htmlFor="pref-desc" error={errors.description}>
|
||||
<Textarea
|
||||
id="pref-desc"
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
placeholder="为什么有这个偏好、适用边界"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="作用范围(JSON)" htmlFor="pref-scope" error={errors.scope} hint="{} 表示全局">
|
||||
<Textarea
|
||||
id="pref-scope"
|
||||
rows={3}
|
||||
className="font-mono text-meta"
|
||||
value={draft.scope}
|
||||
aria-invalid={!!errors.scope}
|
||||
onChange={(e) => set("scope", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
|
||||
<Textarea
|
||||
id="pref-rule"
|
||||
rows={4}
|
||||
className="font-mono text-meta"
|
||||
value={draft.rule}
|
||||
aria-invalid={!!errors.rule}
|
||||
onChange={(e) => set("rule", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{tag ? "保存" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { AcquisitionSlot, CandidateEvaluation } from "@umt/contracts";
|
||||
import { Link2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { CANDIDATE_DECISION, SLOT_STATUS } from "./labels.ts";
|
||||
import { Chip, ChipList, Tone } from "./parts.tsx";
|
||||
import { summarizeCompletion, summarizeHard, summarizeSoft, summarizeUpgrade } from "./slot-rules.tsx";
|
||||
|
||||
/** 一个 Slot 的卡片(卡内子块,不叠玻璃):状态、规则摘要、当前资产、候选拒绝理由 */
|
||||
export function SlotCard({
|
||||
slot,
|
||||
currentAsset,
|
||||
unitLabel,
|
||||
candidates = [],
|
||||
actions,
|
||||
}: {
|
||||
slot: AcquisitionSlot;
|
||||
/** 当前资产的一行说明(交付特征) */
|
||||
currentAsset?: ReactNode;
|
||||
/** 绑定到某个内容单元时的说明 */
|
||||
unitLabel?: string;
|
||||
candidates?: CandidateEvaluation[];
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const st = SLOT_STATUS[slot.status];
|
||||
const hard = summarizeHard(slot.hardConditions, slot.fallback);
|
||||
const soft = summarizeSoft(slot.softPreferences);
|
||||
const upgrade = summarizeUpgrade(slot.upgradeThreshold);
|
||||
return (
|
||||
<article className="min-w-0 rounded-xl border border-border/80 bg-foreground/[0.02] p-4">
|
||||
<header className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate font-mono text-body font-semibold text-foreground">{slot.name}</h3>
|
||||
<Tone v={st} />
|
||||
{unitLabel ? <Chip>{unitLabel}</Chip> : null}
|
||||
{slot.requireDistinctAsset ? (
|
||||
<Chip>
|
||||
<Link2 className="h-3 w-3" />
|
||||
需不同物理资产{slot.distinctGroup ? ` · ${slot.distinctGroup}` : ""}
|
||||
</Chip>
|
||||
) : null}
|
||||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">r{slot.revision}</span>
|
||||
{actions}
|
||||
</header>
|
||||
{slot.statusReason ? <p className="mt-2 text-caption text-muted-foreground">{slot.statusReason}</p> : null}
|
||||
<dl className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-meta text-muted-foreground">硬条件</dt>
|
||||
<dd>
|
||||
<ChipList items={hard} empty="不限" />
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-meta text-muted-foreground">软偏好</dt>
|
||||
<dd>
|
||||
<ChipList items={soft} empty="无" />
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-meta text-muted-foreground">升级</dt>
|
||||
<dd>
|
||||
<ChipList items={upgrade} empty="未设置" />
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-meta text-muted-foreground">完成条件</dt>
|
||||
<dd className="text-caption text-foreground">{summarizeCompletion(slot.completion)}</dd>
|
||||
</div>
|
||||
<div className="min-w-0 sm:col-span-2">
|
||||
<dt className="mb-1 text-meta text-muted-foreground">当前资产</dt>
|
||||
<dd className="text-caption break-words text-foreground">{currentAsset ?? "无"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{candidates.length ? (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<p className="mb-2 text-meta text-muted-foreground">最近候选 · 为什么选 / 不选</p>
|
||||
<ul className="space-y-2">
|
||||
{candidates.map((c) => (
|
||||
<li key={`${c.sourceReleaseId}-${c.evaluatedAt}`} className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tone v={CANDIDATE_DECISION[c.decision]} />
|
||||
<span className="min-w-0 font-mono text-meta break-all text-foreground">{c.title}</span>
|
||||
</div>
|
||||
{c.rejected.length ? (
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-meta text-muted-foreground">
|
||||
{c.rejected.map((r) => (
|
||||
<li key={r.code} className="break-words">
|
||||
{r.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{c.reasons.length ? (
|
||||
<p className="mt-1 text-meta break-words text-muted-foreground">{c.reasons.join(";")}</p>
|
||||
) : null}
|
||||
<p className="text-micro text-muted-foreground tabular-nums">{formatDateTime(c.evaluatedAt)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-3 text-micro text-muted-foreground tabular-nums">
|
||||
最近评估 {formatDateTime(slot.lastEvaluatedAt)}
|
||||
{slot.nextEvaluateAt ? ` · 下次 ${formatDateTime(slot.nextEvaluateAt)}` : ""}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import type { JsonObject } from "@umt/contracts";
|
||||
import { useId } from "react";
|
||||
import { z } from "zod";
|
||||
import { FormField } from "~/components/console/form-field.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 { formatBytes } from "~/lib/utils.ts";
|
||||
import { ChipToggleGroup, splitList } from "./parts.tsx";
|
||||
|
||||
// Slot 规则:硬条件 / 软偏好 / 回退(字段未知时) / 升级阈值 / 完成条件。
|
||||
// 结构对应 domain SlotSpec(packages/domain/src/slot/types.ts),落在 acquisition_slot 的 JSON 列与
|
||||
// AcquisitionPolicyRules 的 SlotTemplate 上;这里只做结构化表单,不生成脚本。
|
||||
|
||||
export const RESOLUTIONS = ["2160p", "1080p", "1080i", "720p", "576p", "480p", "4320p", "1440p"] as const;
|
||||
export const SOURCES = [
|
||||
"REMUX",
|
||||
"BluRay",
|
||||
"BluRay-Disc",
|
||||
"WEB-DL",
|
||||
"WEBRip",
|
||||
"HDTV",
|
||||
"HDRip",
|
||||
"DVD",
|
||||
"DVDRip",
|
||||
] as const;
|
||||
export const HDRS = ["DV", "HDR10", "HDR10+", "HLG", "HDR Vivid"] as const;
|
||||
export const CODECS = ["H.265", "H.264", "AV1", "VC-1", "MPEG-2"] as const;
|
||||
export const CUTS = [
|
||||
"default",
|
||||
"Theatrical",
|
||||
"Director's Cut",
|
||||
"Extended",
|
||||
"IMAX",
|
||||
"Unrated",
|
||||
"Uncut",
|
||||
"Remastered",
|
||||
"Final Cut",
|
||||
"Criterion",
|
||||
"Ultimate",
|
||||
"Special Edition",
|
||||
] as const;
|
||||
export const CONTENT_TYPES = ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction"] as const;
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
|
||||
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
|
||||
const CT_LABEL: Record<string, string> = {
|
||||
main: "正片",
|
||||
extra: "花絮",
|
||||
behind_the_scenes: "幕后",
|
||||
performance_only: "纯享",
|
||||
preview: "预告",
|
||||
reaction: "reaction",
|
||||
};
|
||||
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
|
||||
list.map((v) => ({ value: v, label: labels[v] ?? v }));
|
||||
|
||||
// ── 规则结构(zod:读 JSON 列时宽松解析,写回前严格校验) ──
|
||||
|
||||
const strArr = z.array(z.string()).optional();
|
||||
export const HardConditions = z.object({
|
||||
resolutions: strArr,
|
||||
sources: strArr,
|
||||
requireEncode: z.boolean().optional(),
|
||||
maxSizeBytes: z.number().positive().optional(),
|
||||
minSizeBytes: z.number().nonnegative().optional(),
|
||||
requiredSubtitles: strArr,
|
||||
cuts: strArr,
|
||||
excludeGroups: strArr,
|
||||
contentTypes: strArr,
|
||||
});
|
||||
export const SoftPreferences = z.object({
|
||||
hdr: strArr,
|
||||
groups: strArr,
|
||||
sources: strArr,
|
||||
codecs: strArr,
|
||||
preferredSizeBytes: z.object({ min: z.number().optional(), max: z.number().optional() }).optional(),
|
||||
});
|
||||
export const UpgradePolicy = z.object({
|
||||
enabled: z.boolean(),
|
||||
minTierSteps: z.number().int().min(0).max(5).optional(),
|
||||
allowResolutionUpgrade: z.boolean().optional(),
|
||||
encodeGroupAllowlist: strArr,
|
||||
ceilingTier: z.number().int().min(0).max(5).optional(),
|
||||
});
|
||||
export const UnknownPolicy = z.enum(["hold", "reject", "accept"]);
|
||||
export const Completion = z.object({ kind: z.enum(["movie", "units"]) });
|
||||
|
||||
export type HardConditions = z.infer<typeof HardConditions>;
|
||||
export type SoftPreferences = z.infer<typeof SoftPreferences>;
|
||||
export type UpgradePolicy = z.infer<typeof UpgradePolicy>;
|
||||
|
||||
/** 编辑器状态(字符串形态,提交时转换) */
|
||||
export type SlotRulesDraft = {
|
||||
resolutions: string[];
|
||||
sources: string[];
|
||||
requireEncode: boolean;
|
||||
minSizeGb: string;
|
||||
maxSizeGb: string;
|
||||
requiredSubtitles: string;
|
||||
cuts: string[];
|
||||
excludeGroups: string;
|
||||
contentTypes: string[];
|
||||
unknownPolicy: "" | "hold" | "reject" | "accept";
|
||||
softHdr: string[];
|
||||
softGroups: string;
|
||||
softSources: string[];
|
||||
softCodecs: string[];
|
||||
upgradeEnabled: boolean;
|
||||
minTierSteps: string;
|
||||
allowResolutionUpgrade: boolean;
|
||||
encodeGroupAllowlist: string;
|
||||
ceilingTier: string;
|
||||
completion: "" | "movie" | "units";
|
||||
};
|
||||
|
||||
export type SlotRulesJson = {
|
||||
hardConditions: JsonObject;
|
||||
softPreferences: JsonObject;
|
||||
fallback: JsonObject;
|
||||
upgradeThreshold: JsonObject;
|
||||
completion: JsonObject;
|
||||
};
|
||||
|
||||
const numStr = (n: unknown, div = 1) => (typeof n === "number" ? String(Math.round((n / div) * 100) / 100) : "");
|
||||
|
||||
export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
|
||||
const h = HardConditions.safeParse(j.hardConditions ?? {}).data ?? {};
|
||||
const s = SoftPreferences.safeParse(j.softPreferences ?? {}).data ?? {};
|
||||
const u = UpgradePolicy.safeParse(j.upgradeThreshold ?? {}).data;
|
||||
const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
|
||||
const c = Completion.safeParse(j.completion ?? {}).data;
|
||||
return {
|
||||
resolutions: h.resolutions ?? [],
|
||||
sources: h.sources ?? [],
|
||||
requireEncode: h.requireEncode ?? false,
|
||||
minSizeGb: numStr(h.minSizeBytes, GB),
|
||||
maxSizeGb: numStr(h.maxSizeBytes, GB),
|
||||
requiredSubtitles: (h.requiredSubtitles ?? []).join(", "),
|
||||
cuts: h.cuts ?? [],
|
||||
excludeGroups: (h.excludeGroups ?? []).join(", "),
|
||||
contentTypes: h.contentTypes ?? [],
|
||||
unknownPolicy: up ?? "",
|
||||
softHdr: s.hdr ?? [],
|
||||
softGroups: (s.groups ?? []).join(", "),
|
||||
softSources: s.sources ?? [],
|
||||
softCodecs: s.codecs ?? [],
|
||||
upgradeEnabled: u?.enabled ?? false,
|
||||
minTierSteps: numStr(u?.minTierSteps),
|
||||
allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false,
|
||||
encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "),
|
||||
ceilingTier: numStr(u?.ceilingTier),
|
||||
completion: c?.kind ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export const emptyDraft = (): SlotRulesDraft => draftFromJson({});
|
||||
|
||||
/** 草稿 → JSON 列;返回字段级错误(键同草稿字段名) */
|
||||
export function draftToJson(
|
||||
d: SlotRulesDraft,
|
||||
): { ok: true; value: SlotRulesJson } | { ok: false; errors: Record<string, string> } {
|
||||
const errors: Record<string, string> = {};
|
||||
const num = (key: keyof SlotRulesDraft, s: string, opts: { int?: boolean; min?: number; max?: number } = {}) => {
|
||||
if (s.trim() === "") return undefined;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || (opts.int && !Number.isInteger(n))) {
|
||||
errors[key] = opts.int ? "请输入整数" : "请输入数字";
|
||||
return undefined;
|
||||
}
|
||||
if (opts.min !== undefined && n < opts.min) errors[key] = `不能小于 ${opts.min}`;
|
||||
if (opts.max !== undefined && n > opts.max) errors[key] = `不能大于 ${opts.max}`;
|
||||
return n;
|
||||
};
|
||||
const minGb = num("minSizeGb", d.minSizeGb, { min: 0 });
|
||||
const maxGb = num("maxSizeGb", d.maxSizeGb, { min: 0.01 });
|
||||
if (minGb !== undefined && maxGb !== undefined && minGb > maxGb) errors.maxSizeGb = "上限需大于下限";
|
||||
const minTier = num("minTierSteps", d.minTierSteps, { int: true, min: 0, max: 5 });
|
||||
const ceiling = num("ceilingTier", d.ceilingTier, { int: true, min: 0, max: 5 });
|
||||
if (Object.keys(errors).length) return { ok: false, errors };
|
||||
|
||||
const arr = (v: string[]) => (v.length ? v : undefined);
|
||||
const hard: HardConditions = {
|
||||
resolutions: arr(d.resolutions),
|
||||
sources: arr(d.sources),
|
||||
requireEncode: d.requireEncode || undefined,
|
||||
minSizeBytes: minGb !== undefined ? Math.round(minGb * GB) : undefined,
|
||||
maxSizeBytes: maxGb !== undefined ? Math.round(maxGb * GB) : undefined,
|
||||
requiredSubtitles: arr(splitList(d.requiredSubtitles)),
|
||||
cuts: arr(d.cuts),
|
||||
excludeGroups: arr(splitList(d.excludeGroups)),
|
||||
contentTypes: arr(d.contentTypes),
|
||||
};
|
||||
const soft: SoftPreferences = {
|
||||
hdr: arr(d.softHdr),
|
||||
groups: arr(splitList(d.softGroups)),
|
||||
sources: arr(d.softSources),
|
||||
codecs: arr(d.softCodecs),
|
||||
};
|
||||
const upgrade: UpgradePolicy | undefined =
|
||||
d.upgradeEnabled || minTier !== undefined || d.encodeGroupAllowlist.trim()
|
||||
? {
|
||||
enabled: d.upgradeEnabled,
|
||||
minTierSteps: minTier,
|
||||
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
|
||||
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
|
||||
ceilingTier: ceiling,
|
||||
}
|
||||
: undefined;
|
||||
const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject;
|
||||
// 严格校验一遍(与 domain 类型对齐)
|
||||
const hv = HardConditions.safeParse(clean(hard));
|
||||
const sv = SoftPreferences.safeParse(clean(soft));
|
||||
if (!hv.success || !sv.success) return { ok: false, errors: { form: "规则结构不合法" } };
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
hardConditions: clean(hard),
|
||||
softPreferences: clean(soft),
|
||||
fallback: d.unknownPolicy ? { unknownPolicy: d.unknownPolicy } : {},
|
||||
upgradeThreshold: upgrade ? clean(upgrade) : {},
|
||||
completion: d.completion ? { kind: d.completion } : {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 摘要(卡片/列表上的一行说明) ──
|
||||
|
||||
export function summarizeHard(j: JsonObject | undefined, fallback?: JsonObject): string[] {
|
||||
const h = HardConditions.safeParse(j ?? {}).data;
|
||||
const out: string[] = [];
|
||||
if (!h) return ["规则无法解析"];
|
||||
if (h.resolutions?.length) out.push(`分辨率 ${h.resolutions.join(" / ")}`);
|
||||
if (h.sources?.length) out.push(`来源 ${h.sources.map((s) => SOURCE_LABEL[s] ?? s).join(" / ")}`);
|
||||
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
|
||||
if (h.minSizeBytes !== undefined || h.maxSizeBytes !== undefined)
|
||||
out.push(
|
||||
`体积 ${h.minSizeBytes !== undefined ? formatBytes(h.minSizeBytes, 1) : "0"} – ${
|
||||
h.maxSizeBytes !== undefined ? formatBytes(h.maxSizeBytes, 1) : "不限"
|
||||
}`,
|
||||
);
|
||||
if (h.requiredSubtitles?.length) out.push(`需要字幕 ${h.requiredSubtitles.join(" / ")}`);
|
||||
if (h.cuts?.length) out.push(`内容版本 ${h.cuts.map((c) => CUT_LABEL[c] ?? c).join(" / ")}`);
|
||||
if (h.excludeGroups?.length) out.push(`排除组 ${h.excludeGroups.join(" / ")}`);
|
||||
if (h.contentTypes?.length) out.push(`内容 ${h.contentTypes.map((c) => CT_LABEL[c] ?? c).join(" / ")}`);
|
||||
const up = UnknownPolicy.safeParse((fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
|
||||
if (up) out.push(`字段未知时${UNKNOWN_LABEL[up]}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function summarizeSoft(j: JsonObject | undefined): string[] {
|
||||
const s = SoftPreferences.safeParse(j ?? {}).data;
|
||||
if (!s) return ["规则无法解析"];
|
||||
const out: string[] = [];
|
||||
if (s.hdr?.length) out.push(`HDR ${s.hdr.join(" > ")}`);
|
||||
if (s.sources?.length) out.push(`来源 ${s.sources.join(" > ")}`);
|
||||
if (s.codecs?.length) out.push(`编码 ${s.codecs.join(" > ")}`);
|
||||
if (s.groups?.length) out.push(`发布组 ${s.groups.join(" > ")}`);
|
||||
if (s.preferredSizeBytes)
|
||||
out.push(
|
||||
`体积 ${s.preferredSizeBytes.min ? formatBytes(s.preferredSizeBytes.min, 1) : "0"} – ${
|
||||
s.preferredSizeBytes.max ? formatBytes(s.preferredSizeBytes.max, 1) : "不限"
|
||||
}`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function summarizeUpgrade(j: JsonObject | undefined): string[] {
|
||||
const u = UpgradePolicy.safeParse(j ?? {}).data;
|
||||
if (!u) return Object.keys(j ?? {}).length ? ["规则无法解析"] : [];
|
||||
if (!u.enabled) return ["不自动升级"];
|
||||
const out = [`来源等级至少提升 ${u.minTierSteps ?? 1} 级`];
|
||||
if (u.allowResolutionUpgrade) out.push("允许仅因分辨率升级");
|
||||
if (u.encodeGroupAllowlist?.length) out.push(`合格压制组 ${u.encodeGroupAllowlist.join(" / ")}`);
|
||||
if (u.ceilingTier !== undefined) out.push(`达到等级 ${u.ceilingTier} 后不再升级`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function summarizeCompletion(j: JsonObject | undefined): string {
|
||||
const c = Completion.safeParse(j ?? {}).data;
|
||||
if (!c) return "未设置";
|
||||
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
|
||||
}
|
||||
|
||||
// ── 编辑器 ──
|
||||
|
||||
export function SlotRulesEditor({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
}: {
|
||||
value: SlotRulesDraft;
|
||||
onChange: (v: SlotRulesDraft) => void;
|
||||
errors?: Record<string, string>;
|
||||
}) {
|
||||
const id = useId();
|
||||
const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v });
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
硬条件 · 已知不满足即拒绝
|
||||
</legend>
|
||||
<FormField label="分辨率">
|
||||
<ChipToggleGroup
|
||||
label="分辨率"
|
||||
options={o(RESOLUTIONS)}
|
||||
value={value.resolutions}
|
||||
onChange={(v) => set("resolutions", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
|
||||
<ChipToggleGroup
|
||||
label="交付来源"
|
||||
options={o(SOURCES, SOURCE_LABEL)}
|
||||
value={value.sources}
|
||||
onChange={(v) => set("sources", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">只接受蓝光二次压制</p>
|
||||
<p className="text-meta text-muted-foreground">不含原盘与 Remux;压制不等于"越大越好"</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.requireEncode}
|
||||
onCheckedChange={(v) => set("requireEncode", v)}
|
||||
aria-label="只接受蓝光二次压制"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="体积下限(GB)" htmlFor={`${id}-min`} error={errors.minSizeGb}>
|
||||
<Input
|
||||
id={`${id}-min`}
|
||||
inputMode="decimal"
|
||||
value={value.minSizeGb}
|
||||
aria-invalid={!!errors.minSizeGb}
|
||||
onChange={(e) => set("minSizeGb", e.target.value)}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="体积上限(GB)" htmlFor={`${id}-max`} error={errors.maxSizeGb}>
|
||||
<Input
|
||||
id={`${id}-max`}
|
||||
inputMode="decimal"
|
||||
value={value.maxSizeGb}
|
||||
aria-invalid={!!errors.maxSizeGb}
|
||||
onChange={(e) => set("maxSizeGb", e.target.value)}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
|
||||
<Input
|
||||
id={`${id}-subs`}
|
||||
value={value.requiredSubtitles}
|
||||
onChange={(e) => set("requiredSubtitles", e.target.value)}
|
||||
placeholder="zh"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="排除发布组" htmlFor={`${id}-exg`} hint="逗号分隔">
|
||||
<Input
|
||||
id={`${id}-exg`}
|
||||
value={value.excludeGroups}
|
||||
onChange={(e) => set("excludeGroups", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="内容版本(剪辑)" hint="与分辨率等交付特征分开;不选即不限">
|
||||
<ChipToggleGroup
|
||||
label="内容版本"
|
||||
options={o(CUTS, CUT_LABEL)}
|
||||
value={value.cuts}
|
||||
onChange={(v) => set("cuts", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="内容类型" hint="综艺正片 / 花絮等">
|
||||
<ChipToggleGroup
|
||||
label="内容类型"
|
||||
options={o(CONTENT_TYPES, CT_LABEL)}
|
||||
value={value.contentTypes}
|
||||
onChange={(v) => set("contentTypes", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="字段未知时" hint="资源缺少判断所需字段(如字幕)时的处理">
|
||||
<Select
|
||||
value={value.unknownPolicy || "__none"}
|
||||
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-64" aria-label="字段未知时">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="__none">按系统默认</SelectItem>
|
||||
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{UNKNOWN_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-3 border-t border-border/60 pt-4">
|
||||
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
软偏好 · 硬条件通过后排序,按点选顺序越靠前越好
|
||||
</legend>
|
||||
<FormField label="HDR">
|
||||
<ChipToggleGroup
|
||||
ordered
|
||||
label="HDR 偏好"
|
||||
options={o(HDRS)}
|
||||
value={value.softHdr}
|
||||
onChange={(v) => set("softHdr", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="来源">
|
||||
<ChipToggleGroup
|
||||
ordered
|
||||
label="来源偏好"
|
||||
options={o(SOURCES, SOURCE_LABEL)}
|
||||
value={value.softSources}
|
||||
onChange={(v) => set("softSources", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="编码">
|
||||
<ChipToggleGroup
|
||||
ordered
|
||||
label="编码偏好"
|
||||
options={o(CODECS)}
|
||||
value={value.softCodecs}
|
||||
onChange={(v) => set("softCodecs", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
|
||||
<Input id={`${id}-grp`} value={value.softGroups} onChange={(e) => set("softGroups", e.target.value)} />
|
||||
</FormField>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-3 border-t border-border/60 pt-4">
|
||||
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
升级阈值 · 创建替换计划,不直接覆盖旧文件
|
||||
</legend>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-caption font-medium text-foreground">允许自动升级</p>
|
||||
<Switch
|
||||
checked={value.upgradeEnabled}
|
||||
onCheckedChange={(v) => set("upgradeEnabled", v)}
|
||||
aria-label="允许自动升级"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="最小来源等级提升"
|
||||
htmlFor={`${id}-tier`}
|
||||
error={errors.minTierSteps}
|
||||
hint="默认 1;同等级不反复更换"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-tier`}
|
||||
inputMode="numeric"
|
||||
value={value.minTierSteps}
|
||||
aria-invalid={!!errors.minTierSteps}
|
||||
onChange={(e) => set("minTierSteps", e.target.value)}
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="封顶等级"
|
||||
htmlFor={`${id}-ceil`}
|
||||
error={errors.ceilingTier}
|
||||
hint="当前资产达到后不再升级(0–5)"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-ceil`}
|
||||
inputMode="numeric"
|
||||
value={value.ceilingTier}
|
||||
aria-invalid={!!errors.ceilingTier}
|
||||
onChange={(e) => set("ceilingTier", e.target.value)}
|
||||
placeholder="不封顶"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="合格压制组名单"
|
||||
htmlFor={`${id}-allow`}
|
||||
hint="设置后名单外的压制不自动升级,低劣压制不会只因 BluRay 标签替换"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-allow`}
|
||||
value={value.encodeGroupAllowlist}
|
||||
onChange={(e) => set("encodeGroupAllowlist", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-caption font-medium text-foreground">允许仅因分辨率提升而升级</p>
|
||||
<Switch
|
||||
checked={value.allowResolutionUpgrade}
|
||||
onCheckedChange={(v) => set("allowResolutionUpgrade", v)}
|
||||
aria-label="允许仅因分辨率提升而升级"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-3 border-t border-border/60 pt-4">
|
||||
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
完成条件
|
||||
</legend>
|
||||
<Select
|
||||
value={value.completion || "__none"}
|
||||
onValueChange={(v) => set("completion", v === "__none" ? "" : (v as "movie" | "units"))}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72" aria-label="完成条件">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="__none">未设置</SelectItem>
|
||||
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
|
||||
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.form ? <p className="text-meta text-destructive">{errors.form}</p> : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
import {
|
||||
AcquisitionPolicyRules,
|
||||
type JsonObject,
|
||||
type PolicyRevision,
|
||||
type SlotTemplate,
|
||||
SubscriptionMediaKind,
|
||||
} from "@umt/contracts";
|
||||
import { ChevronRight, Eye, FilePlus2, History, Pencil, Plus, ScrollText, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PolicyPreview,
|
||||
useCreatePolicy,
|
||||
usePolicyRevisions,
|
||||
usePreviewPolicy,
|
||||
usePublishPolicy,
|
||||
useRollbackPolicy,
|
||||
useUpdatePolicy,
|
||||
} from "~/api/policies.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts";
|
||||
import { ChipList, ChipToggleGroup, Panel, Tone } from "./parts.tsx";
|
||||
import {
|
||||
draftFromJson,
|
||||
draftToJson,
|
||||
emptyDraft,
|
||||
type SlotRulesDraft,
|
||||
SlotRulesEditor,
|
||||
summarizeHard,
|
||||
} from "./slot-rules.tsx";
|
||||
|
||||
// 收录策略(kind=acquisition 的 policy_revision):草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4)。
|
||||
// 规则只是结构化数据(AcquisitionPolicyRules),不是脚本;发布即对 scope 内正常下载/追更/整理生成持续授权。
|
||||
|
||||
const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.options.map((v) => ({
|
||||
value: v,
|
||||
label: MEDIA_KIND[v],
|
||||
}));
|
||||
|
||||
export const PUBLISH_NOTICE =
|
||||
"发布即授权:scope 范围内的正常下载、追更与整理将按此策略持续自动执行,不再逐次审批;升级替换、媒体清理与下载缓存清理另行授权。旧的已发布版本会被替代,其授权随之吊销。";
|
||||
|
||||
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
|
||||
type PolicyDraft = {
|
||||
policyKey: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
scopeKinds: string[];
|
||||
defaultSlots: TemplateDraft[];
|
||||
newTitleEnabled: boolean;
|
||||
windowDays: string;
|
||||
newTitleKinds: string[];
|
||||
newTitleSlots: TemplateDraft[];
|
||||
organizeMode: "" | "copy" | "move";
|
||||
allowCopyWhenHnrPending: boolean;
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
const tpl = (t?: SlotTemplate, open = false): TemplateDraft => ({
|
||||
key: `t${++seq}`,
|
||||
name: t?.name ?? "",
|
||||
rules: t ? draftFromJson(t) : emptyDraft(),
|
||||
open,
|
||||
});
|
||||
|
||||
function draftFromRevision(r: PolicyRevision | null): PolicyDraft {
|
||||
const rules = r ? AcquisitionPolicyRules.safeParse(r.rules).data : undefined;
|
||||
const kinds = r ? z.array(z.string()).safeParse((r.scope as { mediaKinds?: unknown }).mediaKinds).data : undefined;
|
||||
return {
|
||||
policyKey: r?.policyKey ?? "",
|
||||
title: r?.title ?? "",
|
||||
summary: r?.summary ?? "",
|
||||
scopeKinds: kinds ?? [],
|
||||
defaultSlots: (rules?.defaultSlots ?? []).map((t) => tpl(t)),
|
||||
newTitleEnabled: rules?.newTitle?.enabled ?? false,
|
||||
windowDays: rules?.newTitle?.windowDays ? String(rules.newTitle.windowDays) : "",
|
||||
newTitleKinds: rules?.newTitle?.mediaKinds ?? [],
|
||||
newTitleSlots: (rules?.newTitle?.slots ?? []).map((t) => tpl(t)),
|
||||
organizeMode: rules?.organizeMode ?? "",
|
||||
allowCopyWhenHnrPending: rules?.allowCopyWhenHnrPending ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
const Meta = z.object({
|
||||
policyKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写策略键")
|
||||
.max(64, "最多 64 个字符")
|
||||
.regex(/^[a-z0-9][a-z0-9_.-]*$/, "只用小写字母、数字和 - _ ."),
|
||||
title: z.string().trim().min(1, "请填写标题").max(120, "最多 120 个字符"),
|
||||
summary: z.string().trim().max(500, "最多 500 个字符"),
|
||||
});
|
||||
|
||||
const stripEmpty = (o: JsonObject) => (Object.keys(o).length ? o : undefined);
|
||||
|
||||
/** 草稿 → rules/scope;错误键:meta 字段名、`${key}.name` / `${key}.${field}`、windowDays、rules */
|
||||
function buildPolicy(d: PolicyDraft):
|
||||
| {
|
||||
ok: true;
|
||||
body: { policyKey: string; title: string; summary: string | null; scope: JsonObject; rules: JsonObject };
|
||||
}
|
||||
| { ok: false; errors: Record<string, string> } {
|
||||
const errors: Record<string, string> = {};
|
||||
const meta = Meta.safeParse(d);
|
||||
if (!meta.success) for (const i of meta.error.issues) errors[String(i.path[0])] ??= i.message;
|
||||
const templates = (list: TemplateDraft[]) =>
|
||||
list.map((t) => {
|
||||
if (!t.name.trim()) errors[`${t.key}.name`] = "请填写模板名称";
|
||||
const r = draftToJson(t.rules);
|
||||
if (!r.ok) {
|
||||
for (const [k, v] of Object.entries(r.errors)) errors[`${t.key}.${k}`] = v;
|
||||
return { name: t.name.trim() };
|
||||
}
|
||||
return {
|
||||
name: t.name.trim(),
|
||||
hardConditions: stripEmpty(r.value.hardConditions),
|
||||
softPreferences: stripEmpty(r.value.softPreferences),
|
||||
fallback: stripEmpty(r.value.fallback),
|
||||
upgradeThreshold: stripEmpty(r.value.upgradeThreshold),
|
||||
completion: stripEmpty(r.value.completion),
|
||||
};
|
||||
});
|
||||
const defaultSlots = templates(d.defaultSlots);
|
||||
const newTitleSlots = templates(d.newTitleSlots);
|
||||
let windowDays: number | undefined;
|
||||
if (d.windowDays.trim()) {
|
||||
windowDays = Number(d.windowDays);
|
||||
if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 3650) errors.windowDays = "1–3650 的整数";
|
||||
}
|
||||
if (d.newTitleEnabled && newTitleSlots.length === 0) errors.newTitleSlots = "开启新作品收录时至少要有一个目标模板";
|
||||
const rules = {
|
||||
...(defaultSlots.length ? { defaultSlots } : {}),
|
||||
...(d.newTitleEnabled || newTitleSlots.length
|
||||
? {
|
||||
newTitle: {
|
||||
enabled: d.newTitleEnabled,
|
||||
...(windowDays ? { windowDays } : {}),
|
||||
...(d.newTitleKinds.length ? { mediaKinds: d.newTitleKinds } : {}),
|
||||
slots: newTitleSlots,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(d.organizeMode ? { organizeMode: d.organizeMode } : {}),
|
||||
...(d.allowCopyWhenHnrPending ? { allowCopyWhenHnrPending: true } : {}),
|
||||
};
|
||||
// 与服务端同一份 schema 严格校验
|
||||
const strict = AcquisitionPolicyRules.strict().safeParse(rules);
|
||||
if (!strict.success && !Object.keys(errors).length) {
|
||||
const i = strict.error.issues[0];
|
||||
errors.rules = `规则结构不合法:${i?.path.join(".")} ${i?.message}`;
|
||||
}
|
||||
if (Object.keys(errors).length || !meta.success) return { ok: false, errors };
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
policyKey: meta.data.policyKey,
|
||||
title: meta.data.title,
|
||||
summary: meta.data.summary || null,
|
||||
scope: d.scopeKinds.length ? { mediaKinds: d.scopeKinds } : {},
|
||||
rules: rules as JsonObject,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 列表 ──
|
||||
|
||||
export function PolicyWorkspace({
|
||||
creating,
|
||||
onCreatingChange,
|
||||
}: {
|
||||
creating: boolean;
|
||||
onCreatingChange: (v: boolean) => void;
|
||||
}) {
|
||||
const q = usePolicyRevisions();
|
||||
const [editor, setEditor] = useState<{ base: PolicyRevision | null; editId: string | null } | null>(null);
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, PolicyRevision[]>();
|
||||
for (const r of q.data?.items ?? []) {
|
||||
if (r.kind !== "acquisition") continue;
|
||||
m.set(r.policyKey, [...(m.get(r.policyKey) ?? []), r]);
|
||||
}
|
||||
return [...m.entries()].map(([key, revs]) => ({ key, revs: revs.sort((a, b) => b.revision - a.revision) }));
|
||||
}, [q.data]);
|
||||
const all = q.data?.items ?? [];
|
||||
const viewing = all.find((r) => r.id === viewId) ?? null;
|
||||
const editorOpen = creating || !!editor;
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditor(null);
|
||||
onCreatingChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
策略只保存结构化条件。草案可以反复预览,发布才生效;回滚会以旧版本内容发布一个新版本,历史只增不改。
|
||||
</p>
|
||||
{q.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={4} />
|
||||
</div>
|
||||
) : q.isError ? (
|
||||
<ErrorState error={q.error} onRetry={() => q.refetch()} />
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="还没有收录策略"
|
||||
description="新建一份草案,设置缺省目标模板与新作品收录条件,预览后再发布。"
|
||||
action={
|
||||
<Button onClick={() => onCreatingChange(true)}>
|
||||
<FilePlus2 />
|
||||
新建策略草案
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<PolicyGroup
|
||||
key={g.key}
|
||||
policyKey={g.key}
|
||||
revs={g.revs}
|
||||
onView={setViewId}
|
||||
onEdit={(r) => setEditor({ base: r, editId: r.id })}
|
||||
onDraftFrom={(r) => setEditor({ base: r, editId: null })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{editorOpen ? (
|
||||
<PolicyEditorSheet
|
||||
key={editor ? `${editor.base?.id}-${editor.editId}` : "new"}
|
||||
base={editor?.base ?? null}
|
||||
editId={editor?.editId ?? null}
|
||||
onClose={closeEditor}
|
||||
onSaved={(r) => {
|
||||
closeEditor();
|
||||
setViewId(r.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<PolicyDetailSheet
|
||||
rev={viewing}
|
||||
onClose={() => setViewId(null)}
|
||||
onEdit={(r) => {
|
||||
setViewId(null);
|
||||
setEditor({ base: r, editId: r.id });
|
||||
}}
|
||||
onDraftFrom={(r) => {
|
||||
setViewId(null);
|
||||
setEditor({ base: r, editId: null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyGroup({
|
||||
policyKey,
|
||||
revs,
|
||||
onView,
|
||||
onEdit,
|
||||
onDraftFrom,
|
||||
}: {
|
||||
policyKey: string;
|
||||
revs: PolicyRevision[];
|
||||
onView: (id: string) => void;
|
||||
onEdit: (r: PolicyRevision) => void;
|
||||
onDraftFrom: (r: PolicyRevision) => void;
|
||||
}) {
|
||||
const published = revs.find((r) => r.status === "published");
|
||||
const draft = revs.find((r) => r.status === "draft");
|
||||
const head = published ?? revs[0]!;
|
||||
const rules = AcquisitionPolicyRules.safeParse(head.rules).data;
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 break-all">{head.title}</span>
|
||||
<span className="font-mono text-meta font-normal text-muted-foreground">{policyKey}</span>
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
published
|
||||
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${published.publishedBy ?? "—"} 发布`
|
||||
: "尚未发布,不产生任何自动操作"
|
||||
}
|
||||
actions={
|
||||
draft ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(draft)}>
|
||||
<Pencil />
|
||||
继续编辑草案 r{draft.revision}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => onDraftFrom(head)}>
|
||||
<FilePlus2 />
|
||||
基于 r{head.revision} 新建草案
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="text-meta text-muted-foreground">r{head.revision} 规则摘要</p>
|
||||
<RulesSummary rules={rules} valid={!!rules} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-meta text-muted-foreground">版本历史</p>
|
||||
<ol className="divide-y divide-border/60 rounded-lg border border-border/70">
|
||||
{revs.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(r.id)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<span className="w-8 shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
|
||||
r{r.revision}
|
||||
</span>
|
||||
<Tone v={POLICY_STATUS[r.status]} />
|
||||
<span className="min-w-0 flex-1 truncate text-caption text-foreground">{r.summary ?? r.title}</span>
|
||||
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
|
||||
{formatDateTime(r.publishedAt ?? r.createdAt)}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) {
|
||||
if (!valid || !rules) return <p className="text-caption text-destructive">规则结构无法解析(不会被执行)</p>;
|
||||
const tplLine = (t: SlotTemplate) => `${t.name}:${summarizeHard(t.hardConditions, t.fallback).join(",") || "不限"}`;
|
||||
return (
|
||||
<div className="space-y-2 text-caption">
|
||||
<div>
|
||||
<span className="text-muted-foreground">缺省目标 </span>
|
||||
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有目标时不自动建)" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">新作品收录 </span>
|
||||
{rules.newTitle?.enabled ? (
|
||||
<ChipList
|
||||
items={[
|
||||
`窗口 ${rules.newTitle.windowDays ?? "默认"} 天`,
|
||||
rules.newTitle.mediaKinds?.length
|
||||
? rules.newTitle.mediaKinds.map((k) => MEDIA_KIND[k]).join(" / ")
|
||||
: "全部类型",
|
||||
...rules.newTitle.slots.map(tplLine),
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground">关闭</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
整理方式{" "}
|
||||
<span className="text-foreground">
|
||||
{rules.organizeMode === "move" ? "Move" : rules.organizeMode === "copy" ? "Copy" : "按系统设置"}
|
||||
</span>
|
||||
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用 Copy" : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 详情:预览 / 发布 / 回滚 ──
|
||||
|
||||
function PolicyDetailSheet({
|
||||
rev,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDraftFrom,
|
||||
}: {
|
||||
rev: PolicyRevision | null;
|
||||
onClose: () => void;
|
||||
onEdit: (r: PolicyRevision) => void;
|
||||
onDraftFrom: (r: PolicyRevision) => void;
|
||||
}) {
|
||||
const preview = usePreviewPolicy();
|
||||
const publish = usePublishPolicy();
|
||||
const rollback = useRollbackPolicy();
|
||||
const rules = rev ? AcquisitionPolicyRules.safeParse(rev.rules).data : undefined;
|
||||
const pv = rev?.preview ? PolicyPreview.safeParse(rev.preview) : null;
|
||||
|
||||
const runPreview = (r: PolicyRevision) =>
|
||||
preview.mutate(r.id, {
|
||||
onSuccess: () => toast.success(`r${r.revision} 预览已更新`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!rev}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
width="lg"
|
||||
title={rev ? `${rev.title} · r${rev.revision}` : ""}
|
||||
description={rev ? <span className="font-mono">{rev.policyKey}</span> : null}
|
||||
footer={
|
||||
rev ? (
|
||||
<>
|
||||
{rev.status === "draft" ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onEdit(rev)}>
|
||||
<Pencil />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => runPreview(rev)} disabled={preview.isPending}>
|
||||
<Eye />
|
||||
{preview.isPending ? "预览中…" : "预览"}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button disabled={!rules}>
|
||||
<Upload />
|
||||
发布
|
||||
</Button>
|
||||
}
|
||||
title={`发布 ${rev.policyKey} r${rev.revision}?`}
|
||||
description={PUBLISH_NOTICE}
|
||||
confirmLabel="授权并发布"
|
||||
onConfirm={() =>
|
||||
publish
|
||||
.mutateAsync({ id: rev.id, expectedRevision: rev.revision })
|
||||
.then(() => toast.success(`r${rev.revision} 已发布并生效`))
|
||||
.catch((e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
})
|
||||
}
|
||||
>
|
||||
{!pv?.success ? (
|
||||
<p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
这个草案还没有预览结果,建议先预览确认影响范围。
|
||||
</p>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onDraftFrom(rev)}>
|
||||
<FilePlus2 />
|
||||
基于此版本新建草案
|
||||
</Button>
|
||||
{rev.status === "superseded" || rev.status === "rolled_back" ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Undo2 />
|
||||
回滚到此版本
|
||||
</Button>
|
||||
}
|
||||
title={`回滚到 r${rev.revision}?`}
|
||||
description={`将以 r${rev.revision} 的内容发布一个新版本,当前已发布版本标记为已回滚。${PUBLISH_NOTICE}`}
|
||||
confirmLabel="回滚并发布"
|
||||
onConfirm={() =>
|
||||
rollback
|
||||
.mutateAsync(rev.id)
|
||||
.then((n) => toast.success(`已回滚,新版本 r${n.revision} 生效`))
|
||||
.then(onClose)
|
||||
.catch((e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{rev ? (
|
||||
<>
|
||||
<DetailSection title="版本">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: <Tone v={POLICY_STATUS[rev.status]} /> },
|
||||
{ label: "作用范围", value: scopeText(rev.scope) },
|
||||
{ label: "创建", value: `${formatDateTime(rev.createdAt)} · ${rev.createdBy ?? "—"}` },
|
||||
{
|
||||
label: "发布",
|
||||
value: rev.publishedAt ? `${formatDateTime(rev.publishedAt)} · ${rev.publishedBy ?? "—"}` : "未发布",
|
||||
},
|
||||
{ label: "说明", value: rev.summary, wide: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="规则">
|
||||
<RulesSummary rules={rules} valid={!!rules} />
|
||||
<JsonView className="mt-3" label="原始规则" value={rev.rules} defaultOpen={false} />
|
||||
</DetailSection>
|
||||
<DetailSection
|
||||
title="历史回放预览"
|
||||
actions={
|
||||
rev.status !== "draft" ? (
|
||||
<Button variant="ghost" size="xs" onClick={() => runPreview(rev)} disabled={preview.isPending}>
|
||||
<History />
|
||||
重新预览
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<PreviewBlock value={rev.preview} />
|
||||
</DetailSection>
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const scopeText = (scope: JsonObject) => {
|
||||
const k = z.array(z.string()).safeParse((scope as { mediaKinds?: unknown }).mediaKinds).data;
|
||||
return k?.length ? k.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? x).join(" / ") : "全部作品";
|
||||
};
|
||||
|
||||
function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
if (!value)
|
||||
return (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有预览。预览用最近已识别的发布评估模板,不产生任何副作用。
|
||||
</p>
|
||||
);
|
||||
const p = PolicyPreview.safeParse(value);
|
||||
if (!p.success) return <JsonView value={value} />;
|
||||
const v = p.data;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!v.valid ? (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
||||
规则结构未通过校验,预览按空模板计算;发布会被拒绝。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
["样本发布", v.sampleSize],
|
||||
["模板数", v.templates],
|
||||
["会接受", v.matched],
|
||||
["会排除", v.excluded],
|
||||
].map(([l, n]) => (
|
||||
<div key={String(l)} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<p className="text-meta text-muted-foreground">{l}</p>
|
||||
<p className="text-title-xs font-semibold text-foreground tabular-nums">{n}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"发布 × 模板"计数。
|
||||
</p>
|
||||
{v.samples.length ? (
|
||||
<ul className="divide-y divide-border/60 rounded-lg border border-border/70">
|
||||
{v.samples.map((s, i) => (
|
||||
<li
|
||||
key={`${s.sourceReleaseId}-${s.template}-${i}`}
|
||||
className="flex flex-wrap items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Tone v={CANDIDATE_DECISION[s.decision] ?? { label: s.decision, tone: "neutral" }} />
|
||||
<span className="font-mono text-meta text-foreground">{s.template}</span>
|
||||
<span className="min-w-0 truncate font-mono text-micro text-muted-foreground">{s.sourceReleaseId}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">没有可用于回放的已识别发布。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 草案编辑 ──
|
||||
|
||||
function PolicyEditorSheet({
|
||||
base,
|
||||
editId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
base: PolicyRevision | null;
|
||||
editId: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: (r: PolicyRevision) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const create = useCreatePolicy();
|
||||
const update = useUpdatePolicy();
|
||||
const [d, setD] = useState<PolicyDraft>(() => {
|
||||
const x = draftFromRevision(base);
|
||||
if (!base) x.defaultSlots = [tpl(undefined, true)];
|
||||
if (base && !editId) x.summary = "";
|
||||
return x;
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const pending = create.isPending || update.isPending;
|
||||
const set = <K extends keyof PolicyDraft>(k: K, v: PolicyDraft[K]) => setD((p) => ({ ...p, [k]: v }));
|
||||
|
||||
const submit = () => {
|
||||
const r = buildPolicy(d);
|
||||
if (!r.ok) {
|
||||
setErrors(r.errors);
|
||||
// 展开有错的模板
|
||||
setD((p) => ({
|
||||
...p,
|
||||
defaultSlots: p.defaultSlots.map((t) => ({
|
||||
...t,
|
||||
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
|
||||
})),
|
||||
newTitleSlots: p.newTitleSlots.map((t) => ({
|
||||
...t,
|
||||
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
|
||||
})),
|
||||
}));
|
||||
toast.error("请先修正表单中的错误");
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
const done = (rev: PolicyRevision) => {
|
||||
toast.success(`草案 r${rev.revision} 已保存,预览确认后再发布`);
|
||||
onSaved(rev);
|
||||
};
|
||||
if (editId) {
|
||||
const { policyKey: _k, ...body } = r.body;
|
||||
update.mutate({ id: editId, ...body }, { onSuccess: done, onError: (e) => toast.error(errorMessage(e)) });
|
||||
} else {
|
||||
create.mutate(
|
||||
{ ...r.body, kind: "acquisition", basedOnRevisionId: base?.id ?? null },
|
||||
{ onSuccess: done, onError: (e) => toast.error(errorMessage(e)) },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
onOpenChange={(o) => !o && !pending && onClose()}
|
||||
width="lg"
|
||||
title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"}
|
||||
description="保存只生成草案,不会生效;发布时才授权自动执行。"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={pending}>
|
||||
{pending ? "保存中…" : "保存草案"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DetailSection title="基本">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="策略键"
|
||||
htmlFor={`${id}-key`}
|
||||
required
|
||||
error={errors.policyKey}
|
||||
hint="同一键下版本递增,只有一个生效"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-key`}
|
||||
value={d.policyKey}
|
||||
disabled={!!base}
|
||||
aria-invalid={!!errors.policyKey}
|
||||
onChange={(e) => set("policyKey", e.target.value)}
|
||||
placeholder="default"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="标题" htmlFor={`${id}-title`} required error={errors.title}>
|
||||
<Input
|
||||
id={`${id}-title`}
|
||||
value={d.title}
|
||||
aria-invalid={!!errors.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="本版说明"
|
||||
htmlFor={`${id}-sum`}
|
||||
error={errors.summary}
|
||||
hint="写清这一版改了什么,出现在版本历史里"
|
||||
>
|
||||
<Textarea id={`${id}-sum`} rows={2} value={d.summary} onChange={(e) => set("summary", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="作用范围" hint="不选即全部作品类型">
|
||||
<ChipToggleGroup
|
||||
label="作用范围"
|
||||
options={KIND_OPTS}
|
||||
value={d.scopeKinds}
|
||||
onChange={(v) => set("scopeKinds", v)}
|
||||
/>
|
||||
</FormField>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="缺省目标模板"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => set("defaultSlots", [...d.defaultSlots, tpl(undefined, true)])}
|
||||
>
|
||||
<Plus />
|
||||
添加模板
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-meta text-muted-foreground">订阅对应的作品还没有目标时,按这些模板建 Slot。</p>
|
||||
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="新作品收录">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">开启新作品收录</p>
|
||||
<p className="text-meta text-muted-foreground">按发行/首播日期判断,站点今天上传的旧片不算新作品</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={d.newTitleEnabled}
|
||||
onCheckedChange={(v) => set("newTitleEnabled", v)}
|
||||
aria-label="开启新作品收录"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="时间窗口(天)" htmlFor={`${id}-win`} error={errors.windowDays} hint="发行/首播在窗口内">
|
||||
<Input
|
||||
id={`${id}-win`}
|
||||
inputMode="numeric"
|
||||
value={d.windowDays}
|
||||
aria-invalid={!!errors.windowDays}
|
||||
onChange={(e) => set("windowDays", e.target.value)}
|
||||
placeholder="默认"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="作品类型" hint="不选即全部">
|
||||
<ChipToggleGroup
|
||||
label="新作品类型"
|
||||
options={KIND_OPTS}
|
||||
value={d.newTitleKinds}
|
||||
onChange={(v) => set("newTitleKinds", v)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-meta text-muted-foreground">新作品的目标模板</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => set("newTitleSlots", [...d.newTitleSlots, tpl(undefined, true)])}
|
||||
>
|
||||
<Plus />
|
||||
添加模板
|
||||
</Button>
|
||||
</div>
|
||||
{errors.newTitleSlots ? <p className="text-meta text-destructive">{errors.newTitleSlots}</p> : null}
|
||||
<TemplateList list={d.newTitleSlots} onChange={(v) => set("newTitleSlots", v)} errors={errors} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="整理">
|
||||
<div className="space-y-3">
|
||||
<FormField label="整理方式">
|
||||
<Select
|
||||
value={d.organizeMode || "__default"}
|
||||
onValueChange={(v) => set("organizeMode", v === "__default" ? "" : (v as "copy" | "move"))}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-64" aria-label="整理方式">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="__default">按系统设置</SelectItem>
|
||||
<SelectItem value="copy">Copy(保留源文件做种)</SelectItem>
|
||||
<SelectItem value="move">Move</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<p className="min-w-0 text-caption font-medium text-foreground">H&R 未满足时经授权改用 Copy</p>
|
||||
<Switch
|
||||
checked={d.allowCopyWhenHnrPending}
|
||||
onCheckedChange={(v) => set("allowCopyWhenHnrPending", v)}
|
||||
aria-label="H&R 未满足时改用 Copy"
|
||||
/>
|
||||
</div>
|
||||
{errors.rules ? <p className="text-meta text-destructive">{errors.rules}</p> : null}
|
||||
</div>
|
||||
</DetailSection>
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateList({
|
||||
list,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
list: TemplateDraft[];
|
||||
onChange: (v: TemplateDraft[]) => void;
|
||||
errors: Record<string, string>;
|
||||
}) {
|
||||
const id = useId();
|
||||
if (list.length === 0) return <p className="text-caption text-muted-foreground">没有模板。</p>;
|
||||
const patch = (key: string, p: Partial<TemplateDraft>) =>
|
||||
onChange(list.map((t) => (t.key === key ? { ...t, ...p } : t)));
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{list.map((t) => {
|
||||
const prefix = `${t.key}.`;
|
||||
const errs = Object.fromEntries(
|
||||
Object.entries(errors)
|
||||
.filter(([k]) => k.startsWith(prefix))
|
||||
.map(([k, v]) => [k.slice(prefix.length), v]),
|
||||
);
|
||||
const hasErr = Object.keys(errs).length > 0;
|
||||
return (
|
||||
<div key={t.key} className={cn("rounded-lg border", hasErr ? "border-destructive/40" : "border-border/80")}>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={t.open}
|
||||
aria-label={t.open ? "收起模板" : "展开模板"}
|
||||
onClick={() => patch(t.key, { open: !t.open })}
|
||||
className="grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className={cn("h-4 w-4 transition-transform duration-base", t.open && "rotate-90")} />
|
||||
</button>
|
||||
<Input
|
||||
id={`${id}-${t.key}`}
|
||||
aria-label="模板名称"
|
||||
aria-invalid={!!errs.name}
|
||||
value={t.name}
|
||||
placeholder="模板名称,如 archival"
|
||||
onChange={(e) => patch(t.key, { name: e.target.value })}
|
||||
className="h-8 min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="删除模板"
|
||||
onClick={() => onChange(list.filter((x) => x.key !== t.key))}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
{errs.name ? <p className="px-3 pb-2 text-meta text-destructive">{errs.name}</p> : null}
|
||||
{!t.open ? (
|
||||
<div className="px-3 pb-2">
|
||||
<ChipList items={summarizeHard(draftToJsonSafe(t.rules))} empty="硬条件不限" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border/60 px-3 py-3">
|
||||
<SlotRulesEditor value={t.rules} onChange={(v) => patch(t.key, { rules: v })} errors={errs} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const draftToJsonSafe = (d: SlotRulesDraft): JsonObject => {
|
||||
const r = draftToJson(d);
|
||||
return r.ok ? r.value.hardConditions : {};
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { AcquisitionSlot } from "@umt/contracts";
|
||||
import { Pencil, Plus, Target } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useCreateSlot, useSlots, useUpdateSlot } from "~/api/slots.ts";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
||||
import { SlotCard } from "./slot-card.tsx";
|
||||
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
|
||||
|
||||
// 订阅页的 Slot 编辑:列出作品的收录目标,新建 / 编辑规则(乐观并发 expectedRevision)。
|
||||
|
||||
const SlotMeta = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写目标名称")
|
||||
.max(64, "最多 64 个字符")
|
||||
.regex(/^[\w\-.+ ]+$/u, "只用字母、数字、空格和 - _ . +"),
|
||||
distinctGroup: z.string().trim().max(64, "最多 64 个字符"),
|
||||
});
|
||||
|
||||
/** 409:别人(或状态机)已改过这个 Slot */
|
||||
export function conflictToast(err: unknown, onRefresh: () => void) {
|
||||
if (err instanceof ApiRequestError && (err.code === "conflict" || err.code === "precondition_failed")) {
|
||||
toast.error("规则已被修改(版本不一致),已刷新,请在最新版本上重新编辑", { duration: 4000 });
|
||||
onRefresh();
|
||||
return;
|
||||
}
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
|
||||
export function SlotsPanel({
|
||||
mediaEntityId,
|
||||
subscriptionId,
|
||||
}: {
|
||||
mediaEntityId: string;
|
||||
subscriptionId?: string | null;
|
||||
}) {
|
||||
const q = useSlots({ mediaEntityId });
|
||||
const [editing, setEditing] = useState<AcquisitionSlot | "new" | null>(null);
|
||||
const slots = q.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-meta text-muted-foreground">每个目标独立判定与升级;状态只由状态机推进,这里只改规则。</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
新建目标
|
||||
</Button>
|
||||
</div>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : slots.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Target}
|
||||
title="还没有收录目标"
|
||||
description="没有目标时按已发布收录策略的缺省模板建;也可以在这里手动建。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{slots.map((s) => (
|
||||
<SlotCard
|
||||
key={s.id}
|
||||
slot={s}
|
||||
currentAsset={
|
||||
s.currentAssetId ? <span className="font-mono text-meta">{s.currentAssetId}</span> : undefined
|
||||
}
|
||||
actions={
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<SlotEditorSheet
|
||||
key={editing === "new" ? "new" : `${editing.id}-${editing.revision}`}
|
||||
slot={editing === "new" ? null : editing}
|
||||
mediaEntityId={mediaEntityId}
|
||||
subscriptionId={subscriptionId ?? null}
|
||||
onClose={() => setEditing(null)}
|
||||
onConflict={() => q.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotEditorSheet({
|
||||
slot,
|
||||
mediaEntityId,
|
||||
subscriptionId,
|
||||
onClose,
|
||||
onConflict,
|
||||
}: {
|
||||
slot: AcquisitionSlot | null;
|
||||
mediaEntityId: string;
|
||||
subscriptionId: string | null;
|
||||
onClose: () => void;
|
||||
onConflict: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const create = useCreateSlot();
|
||||
const update = useUpdateSlot();
|
||||
const [name, setName] = useState(slot?.name ?? "");
|
||||
const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? "");
|
||||
const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false);
|
||||
const [draft, setDraft] = useState<SlotRulesDraft>(() => (slot ? draftFromJson(slot) : emptyDraft()));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
const submit = () => {
|
||||
const meta = SlotMeta.safeParse({ name, distinctGroup });
|
||||
const rules = draftToJson(draft);
|
||||
const errs: Record<string, string> = {};
|
||||
if (!meta.success) for (const i of meta.error.issues) errs[String(i.path[0])] ??= i.message;
|
||||
if (!rules.ok) Object.assign(errs, rules.errors);
|
||||
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "需要不同物理资产时请填写分组名";
|
||||
setErrors(errs);
|
||||
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
|
||||
const common = {
|
||||
...rules.value,
|
||||
distinctGroup: meta.data.distinctGroup || null,
|
||||
requireDistinctAsset: requireDistinct,
|
||||
};
|
||||
if (slot) {
|
||||
update.mutate(
|
||||
{ id: slot.id, expectedRevision: slot.revision, ...common },
|
||||
{
|
||||
onSuccess: (s) => {
|
||||
toast.success(`已保存目标 ${s.name}(r${s.revision})`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => conflictToast(e, onConflict),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
create.mutate(
|
||||
{ mediaEntityId, subscriptionId, name: meta.data.name, ...common },
|
||||
{
|
||||
onSuccess: (s) => {
|
||||
toast.success(`已新建目标 ${s.name}`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
onOpenChange={(o) => !o && !pending && onClose()}
|
||||
width="lg"
|
||||
title={slot ? `编辑目标 ${slot.name}` : "新建收录目标"}
|
||||
description={
|
||||
slot
|
||||
? `当前版本 r${slot.revision};保存时校验版本,期间被修改会提示刷新。`
|
||||
: "硬条件决定能不能收,软偏好决定先收哪个。"
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={pending}>
|
||||
{pending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="目标名称"
|
||||
htmlFor={`${id}-name`}
|
||||
required
|
||||
error={errors.name}
|
||||
hint={slot ? "名称创建后不可改" : "如 archival / streaming"}
|
||||
>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
value={name}
|
||||
disabled={!!slot}
|
||||
aria-invalid={!!errors.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="组合分组"
|
||||
htmlFor={`${id}-grp`}
|
||||
error={errors.distinctGroup}
|
||||
hint="同组目标可要求由不同文件满足"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-grp`}
|
||||
value={distinctGroup}
|
||||
aria-invalid={!!errors.distinctGroup}
|
||||
onChange={(e) => setDistinctGroup(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium text-foreground">要求不同物理资产</p>
|
||||
<p className="text-meta text-muted-foreground">真正的双版本:一个文件不能同时算两个目标</p>
|
||||
</div>
|
||||
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求不同物理资产" />
|
||||
</div>
|
||||
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,277 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type {
|
||||
ExternalIdentity,
|
||||
IdentityCandidateReason,
|
||||
IdentityCandidateStatus,
|
||||
IdentityPoolItem,
|
||||
IdentityStatus,
|
||||
} from "@umt/contracts";
|
||||
import { Fingerprint, Lock, SearchX } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useExternalIdentities, useIdentityPool } from "~/api/identity.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 { AliasesPanel } from "~/components/library/identity-aliases.tsx";
|
||||
import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx";
|
||||
import { PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
|
||||
import {
|
||||
EVIDENCE_LEVEL,
|
||||
IDENTITY_STATUS,
|
||||
opts,
|
||||
POOL_REASON,
|
||||
POOL_STATUS,
|
||||
VERIFY_STATUS,
|
||||
} from "~/components/library/labels.ts";
|
||||
import { relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
// 身份与元数据(REQ §7.2、§7.5):待识别池、外部身份映射与修订、别名。
|
||||
|
||||
function useDebounced(v: string, ms = 250) {
|
||||
const [d, setD] = useState(v);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setD(v), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [v, ms]);
|
||||
return d;
|
||||
}
|
||||
|
||||
function PoolTab() {
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState("pending");
|
||||
const [reason, setReason] = useState("");
|
||||
const dq = useDebounced(q.trim());
|
||||
const pool = useIdentityPool({
|
||||
q: dq || undefined,
|
||||
status: (status || undefined) as IdentityCandidateStatus | undefined,
|
||||
reason: (reason || undefined) as IdentityCandidateReason | undefined,
|
||||
});
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const rows = pool.data?.pages.flatMap((p) => p.items);
|
||||
const open = rows?.find((r) => r.id === openId) ?? null;
|
||||
|
||||
const columns: DataColumn<IdentityPoolItem>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: "原始标题",
|
||||
className: "max-w-0 w-full",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-caption break-all text-foreground">{r.release.title}</p>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground">
|
||||
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "reason", header: "原因", cell: (r) => <Tone v={POOL_REASON[r.reason]} /> },
|
||||
{
|
||||
key: "cands",
|
||||
header: "候选",
|
||||
hideBelow: "sm",
|
||||
align: "right",
|
||||
cell: (r) => <span className="tabular-nums">{r.candidates.length}</span>,
|
||||
},
|
||||
{
|
||||
key: "attempts",
|
||||
header: "尝试",
|
||||
hideBelow: "md",
|
||||
cell: (r) => (
|
||||
<span className="text-meta whitespace-nowrap text-muted-foreground">
|
||||
{r.attempts} 次 · {relTime(r.lastAttemptAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "next",
|
||||
header: "下次复查",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => (
|
||||
<span className="text-meta whitespace-nowrap text-muted-foreground">
|
||||
{r.status === "pending" ? (
|
||||
r.nextReviewAt ? (
|
||||
relTime(r.nextReviewAt)
|
||||
) : (
|
||||
"不自动复查"
|
||||
)
|
||||
) : (
|
||||
<Tone v={POOL_STATUS[r.status]} />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = !!q || status !== "pending" || !!reason;
|
||||
return (
|
||||
<>
|
||||
<FilterBar
|
||||
search={{ value: q, onChange: setQ, placeholder: "按原始标题 / 解析标题检索" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setQ("");
|
||||
setStatus("pending");
|
||||
setReason("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(POOL_STATUS)} />
|
||||
<FilterSelect label="原因" value={reason} onChange={setReason} options={opts(POOL_REASON)} />
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={pool.isPending}
|
||||
error={pool.error}
|
||||
onRetry={() => pool.refetch()}
|
||||
onRowClick={(r) => setOpenId(r.id)}
|
||||
selectedKey={openId}
|
||||
empty={{
|
||||
icon: SearchX,
|
||||
title: filtered ? "没有符合条件的待识别资源" : "待识别池是空的",
|
||||
description: "识别证据不足或冲突的资源会留在这里,可按原始标题找回;新证据到来时会自动重试。",
|
||||
}}
|
||||
hasMore={pool.hasNextPage}
|
||||
onLoadMore={() => pool.fetchNextPage()}
|
||||
loadingMore={pool.isFetchingNextPage}
|
||||
/>
|
||||
<PoolSheet key={open?.id ?? "none"} item={open} onClose={() => setOpenId(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalTab() {
|
||||
const [provider, setProvider] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const list = useExternalIdentities({
|
||||
provider: provider || undefined,
|
||||
status: (status || undefined) as IdentityStatus | undefined,
|
||||
});
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
const open = rows?.find((r) => r.id === openId) ?? null;
|
||||
|
||||
const columns: DataColumn<ExternalIdentity>[] = [
|
||||
{
|
||||
key: "ref",
|
||||
header: "外部身份",
|
||||
cell: (r) => (
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-caption break-all text-foreground">
|
||||
{r.locked ? <Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-label="人工锁" /> : null}
|
||||
{identityRef(r)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (r) => <Tone v={IDENTITY_STATUS[r.status]} /> },
|
||||
{
|
||||
key: "level",
|
||||
header: "证据",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="text-meta whitespace-nowrap">{EVIDENCE_LEVEL[r.evidenceLevel]}</span>,
|
||||
},
|
||||
{ key: "verify", header: "外部校验", hideBelow: "md", cell: (r) => <Tone v={VERIFY_STATUS[r.verifyStatus]} /> },
|
||||
{
|
||||
key: "rev",
|
||||
header: "修订",
|
||||
hideBelow: "lg",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<span className="text-meta whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
r{r.revision} · {relTime(r.updatedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
const filtered = !!provider || !!status;
|
||||
return (
|
||||
<>
|
||||
<FilterBar
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setProvider("");
|
||||
setStatus("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={provider}
|
||||
onChange={setProvider}
|
||||
options={[
|
||||
{ value: "tmdb", label: "TMDB" },
|
||||
{ value: "imdb", label: "IMDb" },
|
||||
{ value: "tvdb", label: "TVDB" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(IDENTITY_STATUS)} />
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
onRowClick={(r) => setOpenId(r.id)}
|
||||
selectedKey={openId}
|
||||
empty={{
|
||||
icon: Fingerprint,
|
||||
title: "没有外部身份",
|
||||
description: "TMDB 等外部 ID 只是辅助映射;没有记录时作品仍可先建本地档案入库。",
|
||||
}}
|
||||
hasMore={list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
/>
|
||||
<ExternalIdentitySheet item={open} onClose={() => setOpenId(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS = ["pool", "external", "aliases"] as const;
|
||||
|
||||
export default function IdentityPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = (TABS as readonly string[]).includes(params.get("tab") ?? "") ? params.get("tab")! : "pool";
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="身份与元数据"
|
||||
description="本地 UUID 是主身份,外部 ID 是辅助映射。自动模糊匹配不会覆盖人工锁定的关系。"
|
||||
/>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
p.set("tab", v);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="pool">待识别池</TabsTrigger>
|
||||
<TabsTrigger value="external">外部身份</TabsTrigger>
|
||||
<TabsTrigger value="aliases">别名</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="pool">
|
||||
<PoolTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="external">
|
||||
<ExternalTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="aliases">
|
||||
<AliasesPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,398 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
|
||||
import { BellOff, Bot, CheckCheck, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
|
||||
import { type ReactNode, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { decisionKeys, useDecisionSummary, useDecisions, useResolveDecision } from "~/api/decisions.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.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 { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
|
||||
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
// 待处理中心(REQ §13.1):按作品与问题类型合并;每项给证据、建议、影响范围与预览;
|
||||
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
|
||||
|
||||
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"];
|
||||
const STATUS_FILTER = [
|
||||
{ value: "resolved", label: "已处理" },
|
||||
{ value: "dismissed", label: "已忽略" },
|
||||
{ value: "expired", label: "已过期" },
|
||||
];
|
||||
const SEV_RANK = { info: 0, warning: 1, critical: 2 } as const;
|
||||
|
||||
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
|
||||
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
|
||||
function entityOf(d: Decision): string | null {
|
||||
const m = d.evidence.mediaEntityId;
|
||||
if (typeof m === "string" && /^[0-9a-f-]{36}$/.test(m)) return m;
|
||||
return d.subjectType === "media_entity" ? d.subjectId : null;
|
||||
}
|
||||
|
||||
const IMPACT_KEY: Record<string, string> = {
|
||||
slots: "收录目标",
|
||||
units: "内容单元",
|
||||
files: "文件",
|
||||
releases: "来源资源",
|
||||
tasks: "任务",
|
||||
entities: "作品",
|
||||
};
|
||||
|
||||
function describe(v: unknown): ReactNode {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
if (Array.isArray(v)) return v.map((x) => (typeof x === "object" ? JSON.stringify(x) : String(x))).join("、");
|
||||
return Object.entries(v as Record<string, unknown>)
|
||||
.map(
|
||||
([k, x]) =>
|
||||
`${IMPACT_KEY[k] ?? k} ${Array.isArray(x) ? x.join("、") : typeof x === "object" ? JSON.stringify(x) : String(x)}`,
|
||||
)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function Evidence({ d }: { d: Decision }) {
|
||||
const e = d.evidence;
|
||||
const suggestion = str(e.suggestion);
|
||||
const impact = describe(e.impact);
|
||||
const preview = describe(e.preview);
|
||||
const checked = Array.isArray(e.checked) ? (e.checked as unknown[]).map(String) : [];
|
||||
const rows: [string, ReactNode][] = [];
|
||||
if (suggestion) rows.push(["建议", suggestion]);
|
||||
if (impact) rows.push(["影响范围", impact]);
|
||||
if (preview) rows.push(["预览", preview]);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.length ? (
|
||||
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted-foreground">{k}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{checked.length ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 text-meta text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Agent 已检查
|
||||
</span>
|
||||
{checked.map((c) => (
|
||||
<Chip key={c}>{c}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<JsonView label="原始证据" value={e} defaultOpen={false} maxHeight="14rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||
const resolve = useResolveDecision();
|
||||
const [note, setNote] = useState("");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
{option.label}
|
||||
</Button>
|
||||
}
|
||||
title={option.label}
|
||||
description={
|
||||
<>
|
||||
{option.description ? `${option.description}。` : ""}
|
||||
{option.capabilityId
|
||||
? `将以能力 ${option.capabilityId} 执行,仍经授权校验。`
|
||||
: "处理结果会记录并通知相关任务。"}
|
||||
</>
|
||||
}
|
||||
confirmLabel="确认"
|
||||
onConfirm={() =>
|
||||
resolve.mutateAsync({ id: d.id, optionKey: option.key, note: note.trim() || undefined }).then(
|
||||
() => toast.success(`已处理:${option.label}`),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "处理失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" rows={2} />
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DismissButton({ d }: { d: Decision }) {
|
||||
const resolve = useResolveDecision();
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<BellOff />
|
||||
忽略
|
||||
</Button>
|
||||
}
|
||||
title="忽略这条事项?"
|
||||
description="只关闭提醒,不改变失败本身;同根因的新证据出现时仍会重新提出。"
|
||||
confirmLabel="忽略"
|
||||
onConfirm={() =>
|
||||
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
|
||||
() => toast.success("已忽略"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "操作失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DecisionCard({ d }: { d: Decision }) {
|
||||
const open = OPEN.includes(d.status);
|
||||
const options = d.options.filter((o) => o.key !== "dismiss");
|
||||
const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null;
|
||||
return (
|
||||
<article className="min-w-0 border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tone v={SEVERITY[d.severity]} />
|
||||
<Tone v={DECISION_STATUS[d.status]} />
|
||||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">
|
||||
{relTime(d.createdAt)}
|
||||
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
|
||||
{d.summary ? <p className="mt-1 text-caption break-words text-muted-foreground">{d.summary}</p> : null}
|
||||
<div className="mt-3">
|
||||
<Evidence d={d} />
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{options.map((o) => (
|
||||
<OptionButton key={o.key} d={d} option={o} />
|
||||
))}
|
||||
<DismissButton d={d} />
|
||||
{d.agentRunId ? (
|
||||
<Link
|
||||
to={consolePath("agent", d.agentRunId)}
|
||||
className="ml-auto inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Agent 调查记录
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-meta text-muted-foreground">
|
||||
{d.resolvedBy ? `${d.resolvedBy} ` : ""}于 {formatDateTime(d.resolvedAt)}
|
||||
{resolution?.optionKey
|
||||
? ` 选择「${d.options.find((o) => o.key === resolution.optionKey)?.label ?? resolution.optionKey}」`
|
||||
: " 关闭"}
|
||||
{resolution?.note ? `:${resolution.note}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type Group = { key: string; entityId: string | null; kind: DecisionKind; items: Decision[] };
|
||||
|
||||
export default function InboxPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const status = (sp.get("status") ?? "") as DecisionStatus | "";
|
||||
const kind = (sp.get("kind") ?? "") as DecisionKind | "";
|
||||
const set = (k: string, v: string) =>
|
||||
setSp(
|
||||
(prev) => {
|
||||
const n = new URLSearchParams(prev);
|
||||
if (v) n.set(k, v);
|
||||
else n.delete(k);
|
||||
return n;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const summary = useDecisionSummary(status || undefined);
|
||||
const list = useDecisions({ status: status || undefined, kind: kind || undefined });
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
|
||||
const qc = useQueryClient();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useSse(["decision.created", "decision.updated"], () => {
|
||||
if (timer.current) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||
}, 500);
|
||||
});
|
||||
const live = useSseStatus();
|
||||
|
||||
const titles = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const g of summary.data?.groups ?? [])
|
||||
if (g.mediaEntityId && g.mediaEntityTitle) m.set(g.mediaEntityId, g.mediaEntityTitle);
|
||||
return m;
|
||||
}, [summary.data]);
|
||||
|
||||
const byKind = useMemo(() => {
|
||||
const m = new Map<DecisionKind, { count: number; sev: keyof typeof SEV_RANK }>();
|
||||
for (const g of summary.data?.groups ?? []) {
|
||||
const cur = m.get(g.kind);
|
||||
m.set(g.kind, {
|
||||
count: (cur?.count ?? 0) + g.count,
|
||||
sev: cur && SEV_RANK[cur.sev] > SEV_RANK[g.maxSeverity] ? cur.sev : g.maxSeverity,
|
||||
});
|
||||
}
|
||||
return [...m.entries()].sort((a, b) => SEV_RANK[b[1].sev] - SEV_RANK[a[1].sev] || b[1].count - a[1].count);
|
||||
}, [summary.data]);
|
||||
|
||||
// 按作品 + 问题类型合并;组内按严重度、时间
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, Group>();
|
||||
for (const d of rows ?? []) {
|
||||
const entityId = entityOf(d);
|
||||
const key = `${entityId ?? `subject:${d.subjectType ?? "none"}`}|${d.kind}`;
|
||||
const g = m.get(key) ?? { key, entityId, kind: d.kind, items: [] };
|
||||
g.items.push(d);
|
||||
m.set(key, g);
|
||||
}
|
||||
const maxSev = (g: Group) => Math.max(...g.items.map((d) => SEV_RANK[d.severity]));
|
||||
return [...m.values()].sort((a, b) => maxSev(b) - maxSev(a) || b.items.length - a.items.length);
|
||||
}, [rows]);
|
||||
|
||||
const isOpenView = !status;
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="待处理中心"
|
||||
description="Agent 先在授权内调查与有限重试,解决不了的才进这里;同一作品同类问题合并展示"
|
||||
actions={
|
||||
<>
|
||||
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
{live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void qc.invalidateQueries({ queryKey: decisionKeys.all })}
|
||||
>
|
||||
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{summary.isError ? (
|
||||
<ErrorState className="mb-4" title="汇总加载失败" error={summary.error} onRetry={() => summary.refetch()} />
|
||||
) : (
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatTile
|
||||
label={isOpenView ? "未关闭" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
|
||||
value={summary.isPending ? "…" : (summary.data?.total ?? 0)}
|
||||
hint={isOpenView ? "待处理 + 调查中 + 等你决定" : undefined}
|
||||
icon={Inbox}
|
||||
active={!kind}
|
||||
onClick={() => set("kind", "")}
|
||||
/>
|
||||
{byKind.map(([k, v]) => (
|
||||
<StatTile
|
||||
key={k}
|
||||
label={DECISION_KIND[k]}
|
||||
value={v.count}
|
||||
hint={`最高 ${SEVERITY[v.sev].label}`}
|
||||
tone={SEVERITY[v.sev].tone}
|
||||
active={kind === k}
|
||||
onClick={() => set("kind", kind === k ? "" : k)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar onReset={status || kind ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={(v) => set("status", v)}
|
||||
options={STATUS_FILTER}
|
||||
allLabel="未关闭"
|
||||
/>
|
||||
<FilterSelect label="问题类型" value={kind} onChange={(v) => set("kind", v)} options={opts(DECISION_KIND)} />
|
||||
</FilterBar>
|
||||
|
||||
{list.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={4} />
|
||||
</div>
|
||||
) : list.isError && !rows ? (
|
||||
<ErrorState error={list.error} onRetry={() => list.refetch()} />
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CheckCheck}
|
||||
title={isOpenView ? "没有需要你处理的事项" : "没有符合条件的事项"}
|
||||
description={isOpenView ? "系统在正常推进目标;新的待决定事项会实时出现在这里。" : "换个状态或类型看看。"}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((g) => {
|
||||
const title = g.entityId ? titles.get(g.entityId) : null;
|
||||
const maxSev = g.items.reduce<keyof typeof SEV_RANK>(
|
||||
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
|
||||
"info",
|
||||
);
|
||||
return (
|
||||
<section key={g.key} className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b border-border/60 px-4 py-3 sm:px-5">
|
||||
<StatusBadge tone={SEVERITY[maxSev].tone}>{DECISION_KIND[g.kind]}</StatusBadge>
|
||||
{g.entityId ? (
|
||||
<Link
|
||||
to={consolePath("library", g.entityId)}
|
||||
className="min-w-0 truncate text-body font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{title ?? "关联作品"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-body font-semibold text-muted-foreground">
|
||||
{g.kind === "identity_ambiguous" ? "尚未归属作品" : "系统级"}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
||||
</header>
|
||||
{g.items.map((d) => (
|
||||
<DecisionCard key={d.id} d={d} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{list.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => list.fetchNextPage()}
|
||||
disabled={list.isFetchingNextPage}
|
||||
>
|
||||
{list.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,668 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type {
|
||||
ContentUnit,
|
||||
EntityMetadataField,
|
||||
ExternalIdentity,
|
||||
LibraryAssetView,
|
||||
LibraryDetail,
|
||||
LibraryDownloadView,
|
||||
MediaEntityDetail,
|
||||
} from "@umt/contracts";
|
||||
import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
FolderTree,
|
||||
GitMerge,
|
||||
Lock,
|
||||
LockOpen,
|
||||
PencilLine,
|
||||
RefreshCw,
|
||||
SearchX,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useEntityDetail,
|
||||
useLibraryDetail,
|
||||
useLibraryRoots,
|
||||
useRefreshMetadata,
|
||||
useUnlockField,
|
||||
} from "~/api/library.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
|
||||
import {
|
||||
CONTENT_TYPE,
|
||||
DOWNLOAD_STATUS,
|
||||
ENTITY_FIELD_LABEL,
|
||||
ENTITY_STATUS,
|
||||
IDENTITY_STATUS,
|
||||
LOCATION_KIND,
|
||||
LOCATION_STATUS,
|
||||
MEDIA_KIND,
|
||||
METADATA_SOURCE,
|
||||
ORG_STATUS,
|
||||
SEGMENT,
|
||||
UNIT_KIND,
|
||||
VERIFY_STATUS,
|
||||
} from "~/components/library/labels.ts";
|
||||
import { Chip, ChipList, CoverPlaceholder, Panel, ProgressLine, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { SlotCard } from "~/components/library/slot-card.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
|
||||
// 内容单元、下载历史与校验结果,同一页同时展示。
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function unitCode(u: ContentUnit): string {
|
||||
const seg = SEGMENT[u.segment] ? `(${SEGMENT[u.segment]})` : "";
|
||||
if (u.kind === "season") return u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : "季";
|
||||
if (u.kind === "episode") {
|
||||
const s = u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : "";
|
||||
const e = u.episodeNumber !== null ? `E${pad(u.episodeNumber)}` : "";
|
||||
return `${s}${e}${seg}` || (u.sourceIssue ?? "集");
|
||||
}
|
||||
return `${UNIT_KIND[u.kind] ?? u.kind}${u.episodeNumber !== null ? ` ${u.episodeNumber}` : ""}${seg}`;
|
||||
}
|
||||
|
||||
function deliveryLine(a: LibraryAssetView["asset"]): string {
|
||||
const d = a.delivery;
|
||||
return [
|
||||
d.resolution,
|
||||
d.sourceType,
|
||||
d.videoCodec,
|
||||
d.hdr?.join("/"),
|
||||
d.releaseGroup,
|
||||
a.contentEdition ? `版本 ${a.contentEdition}` : null,
|
||||
a.sizeBytes !== null ? formatBytes(a.sizeBytes, 1) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
// ── 实际路径与库根 ──
|
||||
function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const roots = useLibraryRoots();
|
||||
const rootOf = (id: string | null) => roots.data?.items.find((r) => r.id === id);
|
||||
const bindings = detail.pathBindings.filter((b) => b.status !== "superseded");
|
||||
return (
|
||||
<Panel title="实际路径与库根" description="路径绑定一旦建立不随译名变化;执行进程可访问的绝对路径按库根拼接">
|
||||
{bindings.length === 0 && detail.assets.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={FolderTree}
|
||||
title="还没有入库文件"
|
||||
description="收录目标满足后,文件位置会显示在这里。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{bindings.length ? (
|
||||
<ul className="space-y-2">
|
||||
{bindings.map((b) => {
|
||||
const root = rootOf(b.libraryRootId);
|
||||
return (
|
||||
<li key={b.id} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Chip>{b.bindingKind === "season" ? `第 ${b.seasonNumber} 季目录` : "作品目录"}</Chip>
|
||||
<span className="text-meta text-muted-foreground">库根 {root?.name ?? "未知库根"}</span>
|
||||
{b.status === "conflict" ? <StatusBadge tone="destructive">绑定冲突</StatusBadge> : null}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-caption break-all text-foreground">
|
||||
{root ? `${root.localPath.replace(/\/+$/, "")}/` : ""}
|
||||
{b.relativePath}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
{detail.assets.map((a) => (
|
||||
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
|
||||
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
|
||||
{a.asset.verifiedAt ? (
|
||||
<StatusBadge tone="success">已核验 {relTime(a.asset.verifiedAt)}</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge>未核验</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{a.locations.map((l) => (
|
||||
<li key={l.id} className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
|
||||
{l.isPrimary ? <Chip>主位置</Chip> : null}
|
||||
<Tone v={LOCATION_STATUS[l.status]} />
|
||||
{l.libraryRootId ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{rootOf(l.libraryRootId)?.name ?? "库根"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
|
||||
{l.absolutePath ?? l.relativePath}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 外部身份映射 ──
|
||||
function IdentityPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const list = [...detail.identities].sort((a, b) => order(a) - order(b));
|
||||
const candidates = detail.identities.filter((i) => i.status === "proposed" || i.status === "conflicted");
|
||||
return (
|
||||
<Panel
|
||||
title="外部身份映射"
|
||||
description="本地 UUID 是主身份,TMDB 等外部 ID 是可修订的辅助映射"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<PencilLine />
|
||||
改映射
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{list.length === 0 ? (
|
||||
<EmptyState plain icon={SearchX} title="还没有外部映射" description="可以手动指定 TMDB id。" className="py-8" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{list.map((i) => (
|
||||
<IdentityRow key={i.id} i={i} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<IdentityDialog
|
||||
entityId={detail.entity.id}
|
||||
entityKind={detail.entity.kind}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
candidates={candidates}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
const order = (i: ExternalIdentity) => ["accepted", "conflicted", "proposed", "retired"].indexOf(i.status);
|
||||
|
||||
function IdentityRow({ i }: { i: ExternalIdentity }) {
|
||||
const reason = typeof i.evidence.reason === "string" ? i.evidence.reason : null;
|
||||
return (
|
||||
<li className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-caption break-all text-foreground">
|
||||
{i.provider}:{i.entityType}:{i.externalId}
|
||||
</span>
|
||||
<Tone v={IDENTITY_STATUS[i.status]} />
|
||||
{i.locked ? (
|
||||
<StatusBadge>
|
||||
<Lock className="h-3 w-3" />
|
||||
人工锁
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-meta text-muted-foreground">
|
||||
证据 {evidenceLabel(i.evidenceLevel)} · {VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus}
|
||||
{i.verifiedAt ? `(${relTime(i.verifiedAt)})` : ""} · r{i.revision}
|
||||
</p>
|
||||
{reason ? <p className="mt-0.5 text-meta break-words text-muted-foreground">{reason}</p> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 元数据字段来源与锁 ──
|
||||
function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: MediaEntityDetail | undefined }) {
|
||||
const id = detail.entity.id;
|
||||
const refresh = useRefreshMetadata(id);
|
||||
const unlock = useUnlockField(id);
|
||||
const [editing, setEditing] = useState<EntityMetadataField | null>(null);
|
||||
const locks = new Set(detail.metadataSources.fieldLocks);
|
||||
const fields = detail.metadataSources.fields;
|
||||
const finals = FieldEnum.options.map((f) => {
|
||||
const parsed = FinalFieldValue.safeParse(fields[f]);
|
||||
return { field: f, final: parsed.success ? parsed.data : null, locked: locks.has(f) };
|
||||
});
|
||||
const candidatesOf = (f: string) => (entity?.metadataFields ?? []).filter((m) => m.field === f);
|
||||
const current = editing ? finals.find((x) => x.field === editing)?.final?.value : undefined;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="元数据"
|
||||
description={`字段级来源;锁定的字段刷新时不会被改写 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() =>
|
||||
refresh.mutate(undefined, {
|
||||
onSuccess: () => toast.success("已提交元数据刷新(锁定字段保持不变)"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
刷新元数据
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{finals.map(({ field, final, locked }) => {
|
||||
const cands = candidatesOf(field);
|
||||
return (
|
||||
<li key={field} className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
|
||||
<div className="w-24 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENTITY_FIELD_LABEL[field]}</div>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<p className="text-sm break-words text-foreground">
|
||||
{final ? String(final.value ?? "—") : <span className="text-muted-foreground">未提供</span>}
|
||||
</p>
|
||||
{final ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
来源 {METADATA_SOURCE[final.source] ?? final.source}
|
||||
{final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}
|
||||
</p>
|
||||
) : null}
|
||||
{cands.length ? (
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground">
|
||||
候选:
|
||||
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{locked ? (
|
||||
<>
|
||||
<StatusBadge>
|
||||
<Lock className="h-3 w-3" />
|
||||
已锁定
|
||||
</StatusBadge>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`解锁${ENTITY_FIELD_LABEL[field]}`}>
|
||||
<LockOpen />
|
||||
</Button>
|
||||
}
|
||||
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
|
||||
description="取消人工覆盖与锁定,下次刷新会按来源优先级重新取值。"
|
||||
confirmLabel="解锁"
|
||||
onConfirm={() =>
|
||||
unlock.mutateAsync(field).then(
|
||||
() => toast.success("已解锁"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "解锁失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
|
||||
onClick={() => setEditing(field)}
|
||||
>
|
||||
<PencilLine />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{entity?.aliases.length ? (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
|
||||
<ChipList
|
||||
items={entity.aliases.map((a) => (
|
||||
<span key={a.id}>
|
||||
{a.alias}
|
||||
{a.locked ? <Lock className="ml-1 inline h-3 w-3" /> : null}
|
||||
</span>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<FieldLockDialog entityId={id} field={editing} current={current} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slot ──
|
||||
function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
|
||||
if (detail.slots.length === 0) {
|
||||
return (
|
||||
<Panel title="收录目标(Slot)">
|
||||
<EmptyState
|
||||
plain
|
||||
icon={CalendarClock}
|
||||
title="这部作品还没有收录目标"
|
||||
description="在订阅与收录规则里新建订阅或 Slot,系统会按目标持续补齐与升级。"
|
||||
action={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("subscriptions")}>去订阅与收录规则</Link>
|
||||
</Button>
|
||||
}
|
||||
className="py-8"
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
|
||||
return (
|
||||
<Panel title="收录目标(Slot)" description="每个目标独立判断硬条件、软偏好与升级;状态均配文字">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{detail.slots.map((s) => {
|
||||
const cur = assetOf(s.currentAssetId);
|
||||
const unit = units.find((u) => u.id === s.contentUnitId);
|
||||
return (
|
||||
<SlotCard
|
||||
key={s.id}
|
||||
slot={s}
|
||||
unitLabel={unit ? unitCode(unit) : undefined}
|
||||
currentAsset={cur ? deliveryLine(cur.asset) || "未知规格" : undefined}
|
||||
candidates={detail.candidates.filter((c) => c.slotId === s.id).slice(0, 5)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 内容单元 ──
|
||||
function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDetail>; detail: LibraryDetail }) {
|
||||
const covered = new Set(detail.assets.flatMap((a) => a.contentUnitIds));
|
||||
const columns: DataColumn<ContentUnit>[] = [
|
||||
{
|
||||
key: "code",
|
||||
header: "编号",
|
||||
cell: (u) => <span className="font-mono text-caption whitespace-nowrap">{unitCode(u)}</span>,
|
||||
},
|
||||
{
|
||||
key: "title",
|
||||
header: "标题 / 期号",
|
||||
cell: (u) => (
|
||||
<div className="min-w-0">
|
||||
<p className="max-w-[12rem] truncate text-sm sm:max-w-xs">{u.title ?? u.sourceIssue ?? "—"}</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{UNIT_KIND[u.kind]} · {CONTENT_TYPE[u.contentType]}
|
||||
{u.airDate ? ` · ${u.airDate}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (u) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.status === "provisional" ? (
|
||||
<StatusBadge tone="warning">临时单元</StatusBadge>
|
||||
) : u.status === "retired" ? (
|
||||
<StatusBadge>已退役</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success">已确认</StatusBadge>
|
||||
)}
|
||||
{u.numberingLocked ? (
|
||||
<StatusBadge>
|
||||
<Lock className="h-3 w-3" />
|
||||
编号锁定
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "asset",
|
||||
header: "已有文件",
|
||||
hideBelow: "sm",
|
||||
cell: (u) =>
|
||||
covered.has(u.id) ? (
|
||||
<StatusBadge tone="success">已入库</StatusBadge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
const units = [...(entity.data?.contentUnits ?? [])].sort(
|
||||
(a, b) =>
|
||||
(a.seasonNumber ?? 999) - (b.seasonNumber ?? 999) ||
|
||||
(a.kind === "season" ? -1 : 0) - (b.kind === "season" ? -1 : 0) ||
|
||||
(a.episodeNumber ?? 0) - (b.episodeNumber ?? 0) ||
|
||||
a.segment.localeCompare(b.segment),
|
||||
);
|
||||
return (
|
||||
<Panel
|
||||
title="内容单元"
|
||||
description="季 / 集 / 期与分段;临时单元是按解析结果先建的,确认或改编号后锁定"
|
||||
bodyClassName="p-0 sm:p-0"
|
||||
>
|
||||
<DataTable
|
||||
plain
|
||||
columns={columns}
|
||||
rows={entity.data ? units : undefined}
|
||||
rowKey={(u) => u.id}
|
||||
loading={entity.isPending}
|
||||
error={entity.error}
|
||||
onRetry={() => entity.refetch()}
|
||||
empty={{
|
||||
title: detail.entity.kind === "movie" ? "电影没有分集单元" : "还没有内容单元",
|
||||
description:
|
||||
detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集或期后会自动建临时单元。",
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 下载历史与校验 ──
|
||||
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const columns: DataColumn<LibraryDownloadView>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "下载",
|
||||
cell: (d) => (
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={consolePath("tasks", d.downloadTask.id)}
|
||||
className="block max-w-[14rem] truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
|
||||
title={d.downloadTask.name}
|
||||
>
|
||||
{d.downloadTask.name}
|
||||
</Link>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{d.plan
|
||||
? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · `
|
||||
: ""}
|
||||
{formatDateTime(d.downloadTask.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "下载状态",
|
||||
cell: (d) => (
|
||||
<div className="min-w-24 space-y-1">
|
||||
<Tone v={DOWNLOAD_STATUS[d.downloadTask.status]} />
|
||||
{d.downloadTask.status !== "imported" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ProgressLine value={d.downloadTask.progress} className="w-16" />
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{Math.round(d.downloadTask.progress * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
align: "right",
|
||||
hideBelow: "md",
|
||||
cell: (d) => (
|
||||
<span className="text-meta tabular-nums">
|
||||
{d.downloadTask.sizeBytes !== null ? formatBytes(d.downloadTask.sizeBytes, 1) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "verify",
|
||||
header: "整理与校验",
|
||||
cell: (d) =>
|
||||
d.organizationRecordIds.length ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{d.lastOrganizationStatus ? <Tone v={ORG_STATUS[d.lastOrganizationStatus]} /> : null}
|
||||
{d.organizationRecordIds.map((rid, i) => (
|
||||
<Link
|
||||
key={rid}
|
||||
to={consolePath("organize", rid)}
|
||||
className="inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
记录 {i + 1}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-meta text-muted-foreground">尚未整理</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Panel
|
||||
title="下载历史与校验结果"
|
||||
description="下载与入库串成一个业务过程;整理记录含逐文件核验"
|
||||
bodyClassName="p-0 sm:p-0"
|
||||
>
|
||||
<DataTable
|
||||
plain
|
||||
columns={columns}
|
||||
rows={detail.downloads}
|
||||
rowKey={(d) => d.downloadTask.id}
|
||||
empty={{ title: "还没有下载记录" }}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LibraryDetailPage() {
|
||||
const { id } = useParams();
|
||||
const detail = useLibraryDetail(id);
|
||||
const entity = useEntityDetail(id);
|
||||
const [merging, setMerging] = useState(false);
|
||||
|
||||
const back = (
|
||||
<Link
|
||||
to={consolePath("library")}
|
||||
className="mb-4 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
媒体库
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (detail.isPending) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<LoadingState rows={8} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (detail.isError) {
|
||||
const notFound = (detail.error as { code?: string }).code === "not_found";
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<ErrorState
|
||||
title={notFound ? "作品不存在" : "作品加载失败"}
|
||||
error={notFound ? "这个 ID 没有对应的作品,可能已被删除。" : detail.error}
|
||||
onRetry={notFound ? undefined : () => detail.refetch()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const d = detail.data;
|
||||
const e = d.entity;
|
||||
const redirected = entity.data?.redirectedFrom;
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
{e.status === "merged" && e.mergedIntoId ? (
|
||||
<div className="mb-4 rounded-xl border border-warning/30 bg-warning/10 px-4 py-3 text-caption text-warning">
|
||||
这部作品已合并。
|
||||
<Link to={consolePath("library", e.mergedIntoId)} className="ml-1 underline">
|
||||
查看合并后的作品
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{redirected ? (
|
||||
<p className="mb-4 text-meta text-muted-foreground">旧 ID {redirected} 已跳转到当前作品。</p>
|
||||
) : null}
|
||||
<header className="mb-6 flex flex-wrap items-start gap-4">
|
||||
<CoverPlaceholder title={e.title} kind={e.kind} className="h-28 w-20 sm:h-36 sm:w-24" />
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="min-w-0 text-title-lg font-semibold tracking-tight break-words text-foreground">
|
||||
{e.title}
|
||||
</h1>
|
||||
<Tone v={ENTITY_STATUS[e.status]} />
|
||||
</div>
|
||||
<p className="mt-1 text-body text-muted-foreground">
|
||||
{MEDIA_KIND[e.kind]}
|
||||
{e.year ? ` · ${e.year}` : ""}
|
||||
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
|
||||
<CalendarClock />
|
||||
订阅与目标
|
||||
</Link>
|
||||
</Button>
|
||||
{e.status !== "merged" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
|
||||
<GitMerge />
|
||||
合并到另一作品
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5">
|
||||
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<IdentityPanel detail={d} />
|
||||
<MetadataPanel detail={d} entity={entity.data} />
|
||||
</div>
|
||||
<PathsPanel detail={d} />
|
||||
{e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? (
|
||||
<UnitsPanel entity={entity} detail={d} />
|
||||
) : null}
|
||||
<DownloadsPanel detail={d} />
|
||||
</div>
|
||||
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,169 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { LibraryItem, LibraryListQuery, MediaKind, SlotStatus } from "@umt/contracts";
|
||||
import { Download, Library as LibraryIcon, RotateCw } from "lucide-react";
|
||||
import { useDeferredValue } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { useLibraryList } from "~/api/library.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts";
|
||||
import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
const FILTERS: { value: NonNullable<LibraryListQuery["filter"]>; label: string }[] = [
|
||||
{ value: "gap", label: "有缺口" },
|
||||
{ value: "attention", label: "需处理" },
|
||||
{ value: "downloading", label: "下载中" },
|
||||
{ value: "satisfied", label: "已满足" },
|
||||
];
|
||||
|
||||
/** 各 Slot 状态计数:只列非零项,文字 + 语气 */
|
||||
function SlotSummary({ counts }: { counts: LibraryItem["slotCounts"] }) {
|
||||
const parts = SLOT_STATUS_ORDER.filter((s) => counts[s] > 0);
|
||||
if (parts.length === 0) return <span className="text-meta text-muted-foreground">未设目标</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{parts.map((s: SlotStatus) => (
|
||||
<StatusBadge key={s} tone={SLOT_STATUS[s].tone}>
|
||||
{SLOT_STATUS[s].label} {counts[s]}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LibraryPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const kind = (sp.get("kind") ?? "") as MediaKind | "";
|
||||
const filter = (sp.get("filter") ?? "") as LibraryListQuery["filter"] | "";
|
||||
const q = sp.get("q") ?? "";
|
||||
const deferredQ = useDeferredValue(q.trim());
|
||||
|
||||
const set = (k: string, v: string) =>
|
||||
setSp(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (v) next.set(k, v);
|
||||
else next.delete(k);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const list = useLibraryList({
|
||||
kind: kind || undefined,
|
||||
filter: filter || undefined,
|
||||
q: deferredQ || undefined,
|
||||
});
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = !!(kind || filter || q);
|
||||
|
||||
const columns: DataColumn<LibraryItem>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: "作品",
|
||||
cell: (r) => (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<CoverPlaceholder title={r.entity.title} kind={r.entity.kind} small className="h-14 w-10" />
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={consolePath("library", r.entity.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block max-w-[14rem] truncate font-medium text-foreground hover:underline sm:max-w-xs"
|
||||
>
|
||||
{r.entity.title}
|
||||
</Link>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{MEDIA_KIND[r.entity.kind]}
|
||||
{r.entity.year ? ` · ${r.entity.year}` : ""}
|
||||
{r.entity.originalTitle && r.entity.originalTitle !== r.entity.title
|
||||
? ` · ${r.entity.originalTitle}`
|
||||
: ""}
|
||||
</p>
|
||||
{r.entity.status !== "active" ? <Tone v={ENTITY_STATUS[r.entity.status]} className="mt-1" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "slots", header: "收录目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
|
||||
{
|
||||
key: "assets",
|
||||
header: "资产",
|
||||
align: "right",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="tabular-nums">{r.assetCount}</span>,
|
||||
},
|
||||
{
|
||||
key: "dl",
|
||||
header: "下载中",
|
||||
align: "right",
|
||||
hideBelow: "md",
|
||||
cell: (r) =>
|
||||
r.activeDownloads ? (
|
||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{r.activeDownloads}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "org",
|
||||
header: "最近入库",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => <span className="text-meta text-muted-foreground">{relTime(r.lastOrganizedAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="媒体库"
|
||||
description="已登记资产或正在追踪的作品;每个作品按独立收录目标(Slot)显示满足情况"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => list.refetch()} disabled={list.isFetching}>
|
||||
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<FilterBar
|
||||
search={{ value: q, onChange: (v) => set("q", v), placeholder: "标题 / 原名 / 别名" }}
|
||||
onReset={filtered ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}
|
||||
>
|
||||
<FilterSelect label="类型" value={kind} onChange={(v) => set("kind", v)} options={opts(MEDIA_KIND)} />
|
||||
<FilterSelect label="状态" value={filter ?? ""} onChange={(v) => set("filter", v)} options={FILTERS} />
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.entity.id}
|
||||
onRowClick={(r) => navigate(consolePath("library", r.entity.id))}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
empty={
|
||||
filtered
|
||||
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选试试。", icon: LibraryIcon }
|
||||
: {
|
||||
title: "媒体库还是空的",
|
||||
description: "登记库根并完成首次扫描,或新建订阅后,作品会出现在这里。",
|
||||
icon: LibraryIcon,
|
||||
action: (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("subscriptions")}>去新建订阅</Link>
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
}
|
||||
hasMore={!!list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,364 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import type { PreferenceKind, PreferenceSuppression, PreferenceTag } from "@umt/contracts";
|
||||
import { Ban, History, Plus, ShieldOff, Tags } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useDeleteTag,
|
||||
useLiftSuppression,
|
||||
usePreferenceTags,
|
||||
useSuppressions,
|
||||
useUpdateTag,
|
||||
} from "~/api/preferences.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.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 } from "~/components/console/status-badge.tsx";
|
||||
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
|
||||
import { MEDIA_KIND, opts, PREF_KIND, PREF_ORIGIN, PREF_STATUS } from "~/components/library/labels.ts";
|
||||
import { Chip, Panel, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { PreferenceFormDialog } from "~/components/library/preferences-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
// 偏好标签(REQ §6.7):一个偏好一个标签;停用/删除/改语义留抑制记录,Agent 不据同一旧证据自动加回。
|
||||
|
||||
const SUP_REASON: Record<string, string> = {
|
||||
deleted: "删除",
|
||||
"status:disabled": "停用",
|
||||
"status:deleted": "删除",
|
||||
edited: "修改语义",
|
||||
};
|
||||
const supReason = (r: string | null) => (r ? (SUP_REASON[r] ?? r) : "未注明");
|
||||
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
|
||||
|
||||
/** scope 摘要:{} = 全局;mediaKinds 翻中文;其余键原样 */
|
||||
function scopeSummary(scope: Record<string, unknown>): string {
|
||||
const keys = Object.keys(scope);
|
||||
if (keys.length === 0) return "全局";
|
||||
const parts: string[] = [];
|
||||
for (const k of keys) {
|
||||
const v = scope[k];
|
||||
if (k === "mediaKinds" && Array.isArray(v))
|
||||
parts.push(v.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? String(x)).join("、"));
|
||||
else parts.push(`${k}: ${Array.isArray(v) ? v.join("、") : typeof v === "object" ? "…" : String(v)}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function TagCard({ tag, onOpen }: { tag: PreferenceTag; onOpen: () => void }) {
|
||||
const inactive = tag.status !== "active";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
"min-w-0 cursor-pointer rounded-xl border border-border/80 bg-foreground/[0.02] p-3 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
|
||||
inactive && "opacity-70",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className={cn("min-w-0 font-medium break-words text-foreground", inactive && "line-through")}>
|
||||
{tag.label}
|
||||
</span>
|
||||
<Tone v={PREF_STATUS[tag.status]} className="shrink-0" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<Chip>{PREF_KIND[tag.kind].label}</Chip>
|
||||
<Chip>{scopeSummary(tag.scope)}</Chip>
|
||||
<Chip>{PREF_ORIGIN[tag.origin]}</Chip>
|
||||
</div>
|
||||
{tag.description ? (
|
||||
<p className="mt-2 line-clamp-2 text-meta break-words text-muted-foreground">{tag.description}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-micro text-muted-foreground tabular-nums">
|
||||
r{tag.revision} · 更新于 {relTime(tag.updatedAt)}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreferencesPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [status, setStatus] = useState("");
|
||||
const [kind, setKind] = useState("");
|
||||
const tags = usePreferenceTags({
|
||||
status: (status || undefined) as "active" | "disabled" | undefined,
|
||||
kind: (kind || undefined) as PreferenceKind | undefined,
|
||||
});
|
||||
const deleted = usePreferenceTags({ status: "deleted" });
|
||||
const allTags = usePreferenceTags({});
|
||||
const sups = useSuppressions();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<{ open: boolean; tag: PreferenceTag | null }>({ open: false, tag: null });
|
||||
const update = useUpdateTag();
|
||||
const del = useDeleteTag();
|
||||
const lift = useLiftSuppression();
|
||||
|
||||
const tagById = useMemo(() => {
|
||||
const m = new Map<string, PreferenceTag>();
|
||||
for (const t of [...(allTags.data?.items ?? []), ...(deleted.data?.items ?? [])]) m.set(t.id, t);
|
||||
return m;
|
||||
}, [allTags.data, deleted.data]);
|
||||
const selected = selectedId ? (tagById.get(selectedId) ?? tags.data?.items.find((t) => t.id === selectedId)) : null;
|
||||
|
||||
const toggle = async (t: PreferenceTag) => {
|
||||
const next = t.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
await update.mutateAsync({ id: t.id, status: next });
|
||||
toast.success(next === "active" ? "已重新启用,同语义抑制已解除" : "已停用,留下抑制记录");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const history = useMemo<TimelineItem[]>(() => {
|
||||
const items: (TimelineItem & { ts: number })[] = [];
|
||||
for (const s of sups.data?.items ?? []) {
|
||||
const label = s.preferenceTagId ? (tagById.get(s.preferenceTagId)?.label ?? "未知标签") : "未关联标签";
|
||||
items.push({
|
||||
id: `sup-${s.id}`,
|
||||
at: s.createdAt,
|
||||
ts: Date.parse(s.createdAt),
|
||||
tone: "warning",
|
||||
title: `「${label}」被${supReason(s.reason)},写入抑制记录`,
|
||||
meta: actorName(s.suppressedBy),
|
||||
});
|
||||
if (s.liftedAt)
|
||||
items.push({
|
||||
id: `lift-${s.id}`,
|
||||
at: s.liftedAt,
|
||||
ts: Date.parse(s.liftedAt),
|
||||
tone: "success",
|
||||
title: `「${label}」的抑制已解除`,
|
||||
meta: actorName(s.liftedBy),
|
||||
});
|
||||
}
|
||||
for (const t of deleted.data?.items ?? []) {
|
||||
if ((sups.data?.items ?? []).some((s) => s.preferenceTagId === t.id && s.reason === "deleted")) continue;
|
||||
items.push({
|
||||
id: `del-${t.id}`,
|
||||
at: t.updatedAt,
|
||||
ts: Date.parse(t.updatedAt),
|
||||
tone: "neutral",
|
||||
title: `「${t.label}」已删除`,
|
||||
});
|
||||
}
|
||||
return items.sort((a, b) => b.ts - a.ts).slice(0, 30);
|
||||
}, [sups.data, deleted.data, tagById]);
|
||||
|
||||
const activeSups = (sups.data?.items ?? []).filter((s) => s.active);
|
||||
const filtered = !!status || !!kind;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="偏好标签"
|
||||
description="一个偏好一个标签。软偏好影响排序,硬规则直接排除;手动取消的偏好不会被 Agent 自动加回。"
|
||||
actions={
|
||||
<Button onClick={() => setForm({ open: true, tag: null })}>
|
||||
<Plus />
|
||||
添加偏好
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setStatus("");
|
||||
setKind("");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allLabel="未删除"
|
||||
options={[
|
||||
{ value: "active", label: "生效中" },
|
||||
{ value: "disabled", label: "已停用" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(PREF_KIND)} />
|
||||
</FilterBar>
|
||||
|
||||
<div className="space-y-6">
|
||||
{tags.isPending ? (
|
||||
<div className="pg-slab rounded-xl p-4">
|
||||
<LoadingState rows={3} />
|
||||
</div>
|
||||
) : tags.isError ? (
|
||||
<ErrorState error={tags.error} onRetry={() => tags.refetch()} />
|
||||
) : tags.data.items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={filtered ? "没有符合条件的偏好" : "还没有偏好标签"}
|
||||
description="偏好可以在这里添加,也可以在 Agent 会话里说「以后都这样」形成长期偏好。"
|
||||
/>
|
||||
) : (
|
||||
<Panel title="偏好" description={`${tags.data.items.length} 个标签,点开看规则与证据`}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tags.data.items.map((t) => (
|
||||
<TagCard key={t.id} tag={t} onOpen={() => setSelectedId(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="抑制记录" description={`${activeSups.length} 条生效中`} bodyClassName="space-y-3">
|
||||
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-foreground">
|
||||
<ShieldOff className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p>
|
||||
手动停用、删除或修改语义的偏好会留下抑制记录,Agent
|
||||
不会根据同一旧证据自动加回;只有你重新启用、重新添加或在这里明确解除后才会恢复。
|
||||
</p>
|
||||
</div>
|
||||
{sups.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : sups.isError ? (
|
||||
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
|
||||
) : sups.data.items.length === 0 ? (
|
||||
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{sups.data.items.map((s) => (
|
||||
<SuppressionRow
|
||||
key={s.id}
|
||||
s={s}
|
||||
label={s.preferenceTagId ? tagById.get(s.preferenceTagId)?.label : undefined}
|
||||
onLift={async () => {
|
||||
try {
|
||||
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
|
||||
toast.success("已解除抑制");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="历史" description="停用、删除、解除抑制的记录(新 → 旧)">
|
||||
{sups.isPending || deleted.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : history.length === 0 ? (
|
||||
<EmptyState plain icon={History} title="还没有变更历史" className="py-8" />
|
||||
) : (
|
||||
<Timeline items={history} />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<DetailSheet
|
||||
open={!!selected}
|
||||
onOpenChange={(o) => !o && setSelectedId(null)}
|
||||
title={selected?.label ?? ""}
|
||||
description={selected ? `${PREF_KIND[selected.kind].label} · ${PREF_KIND[selected.kind].hint}` : undefined}
|
||||
footer={
|
||||
selected && selected.status !== "deleted" ? (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="destructive">删除</Button>}
|
||||
title={`删除「${selected.label}」?`}
|
||||
description="删除后会留下抑制记录,Agent 不会据同一旧证据自动加回;以后需要可重新添加。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await del.mutateAsync(selected.id);
|
||||
toast.success("已删除,留下抑制记录");
|
||||
setSelectedId(null);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => toggle(selected)} disabled={update.isPending}>
|
||||
{selected.status === "active" ? "停用" : "重新启用"}
|
||||
</Button>
|
||||
<Button onClick={() => setForm({ open: true, tag: selected })}>编辑</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: <Tone v={PREF_STATUS[selected.status]} /> },
|
||||
{ label: "来源", value: PREF_ORIGIN[selected.origin] },
|
||||
{ label: "作用范围", value: scopeSummary(selected.scope) },
|
||||
{ label: "修订", value: `r${selected.revision}` },
|
||||
{ label: "创建", value: formatDateTime(selected.createdAt) },
|
||||
{ label: "更新", value: formatDateTime(selected.updatedAt) },
|
||||
{ label: "说明", value: selected.description, wide: true },
|
||||
{ label: "语义指纹", value: selected.fingerprint, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
<DetailSection title="规则">
|
||||
<JsonView value={selected.rule} />
|
||||
</DetailSection>
|
||||
<DetailSection title="作用范围">
|
||||
<JsonView value={selected.scope} />
|
||||
</DetailSection>
|
||||
<DetailSection title="证据">
|
||||
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
|
||||
</DetailSection>
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
|
||||
<PreferenceFormDialog open={form.open} tag={form.tag} onOpenChange={(o) => setForm((f) => ({ ...f, open: o }))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SuppressionRow({
|
||||
s,
|
||||
label,
|
||||
onLift,
|
||||
}: {
|
||||
s: PreferenceSuppression;
|
||||
label: string | undefined;
|
||||
onLift: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 font-medium break-words text-foreground">{label ?? "未关联标签"}</span>
|
||||
<Chip>{supReason(s.reason)}</Chip>
|
||||
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
|
||||
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
|
||||
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{s.active ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
解除抑制
|
||||
</Button>
|
||||
}
|
||||
title="解除这条抑制?"
|
||||
description="解除后,Agent 可以再次根据证据提出同一偏好(仍需走正常授权)。标签本身不会自动恢复。"
|
||||
confirmLabel="解除"
|
||||
onConfirm={onLift}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,624 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
import {
|
||||
AcquisitionPriority,
|
||||
type MediaEntity,
|
||||
SeasonScope,
|
||||
type Subscription,
|
||||
SubscriptionCreate,
|
||||
SubscriptionMediaKind,
|
||||
SubscriptionMode,
|
||||
type SubscriptionStatus,
|
||||
} from "@umt/contracts";
|
||||
import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEntitySearch } from "~/api/library.ts";
|
||||
import {
|
||||
type SubscriptionFilters,
|
||||
useCreateSubscription,
|
||||
useEntityTitle,
|
||||
useSubscriptions,
|
||||
useUpdateSubscription,
|
||||
} from "~/api/subscriptions.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
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 { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import {
|
||||
MEDIA_KIND,
|
||||
opts,
|
||||
PRIORITY,
|
||||
SEASON_SCOPE,
|
||||
SUB_MODE,
|
||||
SUB_ORIGIN,
|
||||
SUB_STATUS,
|
||||
} from "~/components/library/labels.ts";
|
||||
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
|
||||
import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx";
|
||||
import { Button, buttonVariants } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 订阅与收录规则:订阅(单次/持续、仅本季/接续)+ 各作品的收录目标 Slot + 收录策略版本(REQ §6.1、§6.4、§6.5)
|
||||
|
||||
const SUB_KIND_LABEL: Record<SubscriptionMediaKind, string> = { movie: "电影", series: "剧集", variety: "综艺" };
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SubscriptionsPage() {
|
||||
return <WorkspacePlaceholder />;
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = params.get("tab") === "policies" ? "policies" : "subscriptions";
|
||||
const [creatingSub, setCreatingSub] = useState(false);
|
||||
const [creatingPolicy, setCreatingPolicy] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="订阅与收录规则"
|
||||
description="订阅决定追什么,收录目标决定要哪种版本,策略决定默认怎么做;启用即授权范围内的持续操作。"
|
||||
actions={
|
||||
tab === "subscriptions" ? (
|
||||
<Button onClick={() => setCreatingSub(true)}>
|
||||
<Plus />
|
||||
新建订阅
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setCreatingPolicy(true)}>
|
||||
<FilePlus2 />
|
||||
新建策略草案
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
if (v === "policies") p.set("tab", "policies");
|
||||
else p.delete("tab");
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<TabsList aria-label="订阅与收录规则">
|
||||
<TabsTrigger value="subscriptions">订阅</TabsTrigger>
|
||||
<TabsTrigger value="policies">收录策略</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="subscriptions">
|
||||
<SubscriptionsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="policies">
|
||||
<PolicyWorkspace creating={creatingPolicy} onCreatingChange={setCreatingPolicy} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{creatingSub ? <CreateSubscriptionDialog onClose={() => setCreatingSub(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 订阅列表 ──
|
||||
|
||||
function EntityTitle({ sub }: { sub: Subscription }) {
|
||||
const q = useEntityTitle(sub.mediaEntityId);
|
||||
if (!sub.mediaEntityId)
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="break-all text-foreground">{sub.titleQuery ?? "—"}</span>
|
||||
<Chip>尚未建档</Chip>
|
||||
</span>
|
||||
);
|
||||
if (q.isPending) return <span className="text-muted-foreground">加载中…</span>;
|
||||
if (q.isError)
|
||||
return <span className="font-mono text-meta text-muted-foreground">{sub.mediaEntityId.slice(0, 8)}(无法读取)</span>;
|
||||
return (
|
||||
<span className="min-w-0 break-all text-foreground">
|
||||
{q.data.title}
|
||||
{q.data.year ? <span className="text-muted-foreground"> ({q.data.year})</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionsTab() {
|
||||
const [status, setStatus] = useState("");
|
||||
const filters: SubscriptionFilters = { status: (status || undefined) as SubscriptionFilters["status"] };
|
||||
const q = useSubscriptions(filters);
|
||||
const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const open = rows?.find((r) => r.id === openId) ?? null;
|
||||
|
||||
const columns: DataColumn<Subscription>[] = [
|
||||
{ key: "title", header: "作品", cell: (r) => <EntityTitle sub={r} />, className: "min-w-40" },
|
||||
{
|
||||
key: "kind",
|
||||
header: "类型",
|
||||
cell: (r) => SUB_KIND_LABEL[r.mediaKind],
|
||||
hideBelow: "sm",
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
header: "追踪方式",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap">
|
||||
{SUB_MODE[r.mode]}
|
||||
{r.mediaKind !== "movie" ? (
|
||||
<span className="text-muted-foreground"> · {SEASON_SCOPE[r.seasonScope]}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
hideBelow: "md",
|
||||
},
|
||||
{
|
||||
key: "priority",
|
||||
header: "优先级",
|
||||
cell: (r) => PRIORITY[r.priority],
|
||||
hideBelow: "lg",
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (r) => <Tone v={SUB_STATUS[r.status]} /> },
|
||||
{
|
||||
key: "checked",
|
||||
header: "最近检查",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground tabular-nums">{relTime(r.lastCheckedAt)}</span>
|
||||
),
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterBar onReset={status ? () => setStatus("") : undefined}>
|
||||
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(SUB_STATUS)} />
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={q.isPending}
|
||||
error={q.error}
|
||||
onRetry={() => q.refetch()}
|
||||
onRowClick={(r) => setOpenId(r.id)}
|
||||
selectedKey={openId}
|
||||
hasMore={q.hasNextPage}
|
||||
onLoadMore={() => q.fetchNextPage()}
|
||||
loadingMore={q.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: CalendarClock,
|
||||
title: status ? "没有符合条件的订阅" : "还没有订阅",
|
||||
description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
|
||||
}}
|
||||
/>
|
||||
<SubscriptionSheet sub={open} onClose={() => setOpenId(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 订阅详情:修改 + 收录目标 ──
|
||||
|
||||
function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose: () => void }) {
|
||||
const update = useUpdateSubscription();
|
||||
const title = useEntityTitle(sub?.mediaEntityId);
|
||||
const setStatus = (status: SubscriptionStatus, done: string) =>
|
||||
sub
|
||||
? update
|
||||
.mutateAsync({ id: sub.id, status })
|
||||
.then(() => toast.success(done))
|
||||
.catch((e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
})
|
||||
: Promise.resolve();
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!sub}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
width="lg"
|
||||
title={sub ? (sub.mediaEntityId ? (title.data?.title ?? "订阅") : (sub.titleQuery ?? "订阅")) : ""}
|
||||
description={sub ? `${SUB_KIND_LABEL[sub.mediaKind]} · 来源:${SUB_ORIGIN[sub.origin] ?? sub.origin}` : null}
|
||||
footer={
|
||||
sub && sub.status !== "cancelled" && sub.status !== "completed" ? (
|
||||
<>
|
||||
{sub.status === "active" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStatus("paused", "已暂停,不再发起新下载")}
|
||||
disabled={update.isPending}
|
||||
>
|
||||
<Pause />
|
||||
暂停
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setStatus("active", "已恢复追踪")} disabled={update.isPending}>
|
||||
<Play />
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" className="hover:border-destructive/40 hover:text-destructive">
|
||||
<XCircle />
|
||||
取消订阅
|
||||
</Button>
|
||||
}
|
||||
title="取消这个订阅?"
|
||||
description="不再追踪后续内容;已入库文件、已有收录目标与下载历史都保留。"
|
||||
confirmLabel="取消订阅"
|
||||
destructive
|
||||
onConfirm={() => setStatus("cancelled", "订阅已取消")}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{sub ? (
|
||||
<>
|
||||
<DetailSection title="概况">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: <Tone v={SUB_STATUS[sub.status]} /> },
|
||||
{
|
||||
label: "作品",
|
||||
value: sub.mediaEntityId ? (
|
||||
<Link className="underline-offset-4 hover:underline" to={consolePath("library", sub.mediaEntityId)}>
|
||||
打开媒体详情
|
||||
</Link>
|
||||
) : (
|
||||
`按标题追踪:${sub.titleQuery ?? "—"}(尚未建档)`
|
||||
),
|
||||
},
|
||||
{ label: "状态说明", value: sub.statusReason },
|
||||
{ label: "最近检查", value: formatDateTime(sub.lastCheckedAt) },
|
||||
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${sub.createdBy ?? "—"}` },
|
||||
{ label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="追踪方式">
|
||||
<SubscriptionEditForm key={`${sub.id}-${sub.updatedAt}`} sub={sub} />
|
||||
</DetailSection>
|
||||
<DetailSection title="收录目标">
|
||||
{sub.mediaEntityId ? (
|
||||
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">作品建档并关联后才能设置收录目标。</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionEditForm({ sub }: { sub: Subscription }) {
|
||||
const id = useId();
|
||||
const update = useUpdateSubscription();
|
||||
const [mode, setMode] = useState<SubscriptionMode>(sub.mode);
|
||||
const [scope, setScope] = useState<SeasonScope>(sub.seasonScope);
|
||||
const [priority, setPriority] = useState<AcquisitionPriority>(sub.priority);
|
||||
const dirty = mode !== sub.mode || scope !== sub.seasonScope || priority !== sub.priority;
|
||||
const disabled = sub.status === "cancelled" || sub.status === "completed";
|
||||
|
||||
const save = () =>
|
||||
update.mutate(
|
||||
{ id: sub.id, mode, seasonScope: scope, priority },
|
||||
{ onSuccess: () => toast.success("追踪方式已更新"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<FormField label="方式" htmlFor={`${id}-mode`}>
|
||||
<EnumSelect
|
||||
id={`${id}-mode`}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={SubscriptionMode.options}
|
||||
labels={SUB_MODE}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="季范围" htmlFor={`${id}-scope`} hint={sub.mediaKind === "movie" ? "电影不适用" : undefined}>
|
||||
<EnumSelect
|
||||
id={`${id}-scope`}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
options={SeasonScope.options}
|
||||
labels={SEASON_SCOPE}
|
||||
disabled={disabled || sub.mediaKind === "movie"}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="优先级" htmlFor={`${id}-prio`}>
|
||||
<EnumSelect
|
||||
id={`${id}-prio`}
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
options={AcquisitionPriority.options}
|
||||
labels={PRIORITY}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{mode === "continuous" && scope === "following_seasons" && sub.mediaKind !== "movie" ? (
|
||||
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不直接视作下一季,按证据识别。</p>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={save} disabled={!dirty || update.isPending}>
|
||||
<Check />
|
||||
{update.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnumSelect<T extends string>({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
labels,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: readonly T[];
|
||||
labels: Record<T, string>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as T)} disabled={disabled}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o} value={o}>
|
||||
{labels[o]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 新建订阅 ──
|
||||
|
||||
const toSubKind = (k: MediaEntity["kind"]): SubscriptionMediaKind =>
|
||||
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
|
||||
|
||||
function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
|
||||
const id = useId();
|
||||
const create = useCreateSubscription();
|
||||
const [byTitle, setByTitle] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
const [entity, setEntity] = useState<MediaEntity | null>(null);
|
||||
const [titleQuery, setTitleQuery] = useState("");
|
||||
const [mediaKind, setMediaKind] = useState<SubscriptionMediaKind>("series");
|
||||
const [mode, setMode] = useState<SubscriptionMode>("continuous");
|
||||
const [scope, setScope] = useState<SeasonScope>("following_seasons");
|
||||
const [priority, setPriority] = useState<AcquisitionPriority>("tracking");
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const search = useEntitySearch(term, { enabled: !byTitle && !entity });
|
||||
|
||||
const pick = (e: MediaEntity) => {
|
||||
setEntity(e);
|
||||
const k = toSubKind(e.kind);
|
||||
setMediaKind(k);
|
||||
// REQ §6.5:用户主动添加的剧集/综艺默认持续追踪并接续后续季;电影默认单次
|
||||
setMode(k === "movie" ? "once" : "continuous");
|
||||
setScope("following_seasons");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const body = {
|
||||
mediaEntityId: byTitle ? undefined : entity?.id,
|
||||
titleQuery: byTitle ? titleQuery.trim() || undefined : undefined,
|
||||
mediaKind,
|
||||
mode,
|
||||
seasonScope: mediaKind === "movie" ? "current_season" : scope,
|
||||
priority,
|
||||
origin: "user" as const,
|
||||
};
|
||||
const v = SubscriptionCreate.safeParse(body);
|
||||
if (!v.success) {
|
||||
const errs = zodFieldErrors(v.error.issues);
|
||||
if (errs[""]) errs.target = byTitle ? "请填写要追踪的标题" : "请选择一部作品";
|
||||
setErrors(errs);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
create.mutate(v.data, {
|
||||
onSuccess: () => {
|
||||
toast.success("订阅已创建,将按收录目标开始检查");
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && !create.isPending && onClose()}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建订阅</DialogTitle>
|
||||
<DialogDescription>选择库里的作品,或先按标题追踪,作品建档后自动关联。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<fieldset className="flex gap-1" aria-label="订阅对象">
|
||||
{[
|
||||
[false, "选择作品"],
|
||||
[true, "按标题追踪"],
|
||||
].map(([v, l]) => (
|
||||
<button
|
||||
key={String(l)}
|
||||
type="button"
|
||||
aria-pressed={byTitle === v}
|
||||
onClick={() => {
|
||||
setByTitle(v as boolean);
|
||||
setErrors({});
|
||||
}}
|
||||
className={cn(
|
||||
"h-8 cursor-pointer rounded-full border px-3 text-sm font-medium transition-colors duration-micro",
|
||||
byTitle === v
|
||||
? "pill-selected text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:bg-foreground/[0.06]",
|
||||
)}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{byTitle ? (
|
||||
<FormField
|
||||
label="标题"
|
||||
htmlFor={`${id}-tq`}
|
||||
required
|
||||
error={errors.target ?? errors.titleQuery}
|
||||
hint="原名、译名或站点标题都可以"
|
||||
>
|
||||
<Input
|
||||
id={`${id}-tq`}
|
||||
value={titleQuery}
|
||||
aria-invalid={!!errors.target}
|
||||
onChange={(e) => setTitleQuery(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
) : entity ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{entity.title}
|
||||
{entity.year ? <span className="text-muted-foreground"> ({entity.year})</span> : null}
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground">{MEDIA_KIND[entity.kind]}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEntity(null)}>
|
||||
更换
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FormField label="作品" htmlFor={`${id}-q`} required error={errors.target}>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={`${id}-q`}
|
||||
className="pl-9"
|
||||
value={term}
|
||||
aria-invalid={!!errors.target}
|
||||
placeholder="搜索标题 / 原名"
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto rounded-lg border border-border/70">
|
||||
{search.isPending ? (
|
||||
<p className="px-3 py-2 text-caption text-muted-foreground">搜索中…</p>
|
||||
) : search.isError ? (
|
||||
<p className="px-3 py-2 text-caption text-destructive">{errorMessage(search.error)}</p>
|
||||
) : (search.data?.items ?? []).filter((e) => e.status !== "merged").length === 0 ? (
|
||||
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的作品,可以改用"按标题追踪"。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{(search.data?.items ?? [])
|
||||
.filter((e) => e.status !== "merged")
|
||||
.map((e) => (
|
||||
<li key={e.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(e)}
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm text-foreground">
|
||||
{e.title}
|
||||
{e.year ? <span className="text-muted-foreground"> ({e.year})</span> : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="类型" htmlFor={`${id}-kind`} error={errors.mediaKind}>
|
||||
<EnumSelect
|
||||
id={`${id}-kind`}
|
||||
value={mediaKind}
|
||||
onChange={(k) => {
|
||||
setMediaKind(k);
|
||||
if (k === "movie") setMode("once");
|
||||
}}
|
||||
options={SubscriptionMediaKind.options}
|
||||
labels={SUB_KIND_LABEL}
|
||||
disabled={!byTitle && !!entity}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="方式" htmlFor={`${id}-mode`}>
|
||||
<EnumSelect
|
||||
id={`${id}-mode`}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={SubscriptionMode.options}
|
||||
labels={SUB_MODE}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="季范围" htmlFor={`${id}-scope`} hint={mediaKind === "movie" ? "电影不适用" : "随时可改"}>
|
||||
<EnumSelect
|
||||
id={`${id}-scope`}
|
||||
value={mediaKind === "movie" ? "current_season" : scope}
|
||||
onChange={setScope}
|
||||
options={SeasonScope.options}
|
||||
labels={SEASON_SCOPE}
|
||||
disabled={mediaKind === "movie"}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="优先级" htmlFor={`${id}-prio`} hint="只影响资源分配,不越过磁盘阈值与权限">
|
||||
<EnumSelect
|
||||
id={`${id}-prio`}
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
options={AcquisitionPriority.options}
|
||||
labels={PRIORITY}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
onClick={onClose}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<Button onClick={submit} disabled={create.isPending}>
|
||||
{create.isPending ? "创建中…" : "创建订阅"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user