diff --git a/apps/web/app/components/slots/slot-mounts.test.ts b/apps/web/app/components/slots/slot-mounts.test.ts new file mode 100644 index 00000000..23bc4136 --- /dev/null +++ b/apps/web/app/components/slots/slot-mounts.test.ts @@ -0,0 +1,95 @@ +import type { PublicOrganizationRecord } from "@umt/contracts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { createSlotRegistry, type SlotEntity, slots, visibleActions } from "~/lib/slots.ts"; +import { SlotPanels, useSlotActions } from "./slot-mounts.tsx"; + +// 前端插槽(ADR-006):按位置注册、按 order 与注册顺序排列、默认直接显示(没有开关)、挂载点渲染。 + +const entity: SlotEntity = { id: "e1", title: "示例剧", kind: "series", status: "active" }; +const record = (over: Partial = {}) => + ({ + id: "r1", + status: "succeeded", + action: "copy", + media: { entityId: "e1", title: "示例剧" }, + ...over, + }) as unknown as PublicOrganizationRecord; + +describe("插槽注册表", () => { + it("按 order 排序,同序保持注册顺序;同一位置 id 重复报错", () => { + const reg = createSlotRegistry(); + const Panel = (name: string) => () => createElement("p", null, name); + reg.register("entity.panels", { id: "b", feature: "x", Component: Panel("B") }); + reg.register("entity.panels", { id: "a", feature: "x", order: 10, Component: Panel("A") }); + reg.register("entity.panels", { id: "c", feature: "x", Component: Panel("C") }); + expect(reg.entries("entity.panels").map((e) => e.id)).toEqual(["a", "b", "c"]); + expect(reg.entries("settings.sections")).toEqual([]); + expect(() => reg.register("entity.panels", { id: "a", feature: "x", Component: Panel("A2") })).toThrow(/已有 a/); + }); + + it("面板类挂载点按顺序渲染全部组件,拿到页面给的 props;没有注册时什么都不渲染", () => { + const reg = createSlotRegistry(); + reg.register("entity.panels", { + id: "p", + feature: "x", + Component: ({ entity: e }) => createElement("section", null, `面板:${e.title}`), + }); + const html = renderToStaticMarkup( + createElement(SlotPanels, { position: "entity.panels", props: { entity }, registry: reg }), + ); + expect(html).toBe("
面板:示例剧
"); + expect( + renderToStaticMarkup( + createElement(SlotPanels, { position: "entity.panels", props: { entity }, registry: createSlotRegistry() }), + ), + ).toBe(""); + }); + + it("动作类:visible 过滤,缺省显示;挂载点给出可见项", () => { + const reg = createSlotRegistry(); + const Dialog = () => null; + reg.register("entity.actions", { id: "always", feature: "x", label: "总在", Dialog }); + reg.register("entity.actions", { + id: "active-only", + feature: "x", + label: "未合并才有", + visible: ({ entity: e }) => e.status !== "merged", + Dialog, + }); + expect(visibleActions(reg, "entity.actions", { entity }).map((a) => a.id)).toEqual(["always", "active-only"]); + expect(visibleActions(reg, "entity.actions", { entity: { ...entity, status: "merged" } }).map((a) => a.id)).toEqual( + ["always"], + ); + const Probe = () => { + const s = useSlotActions("entity.actions", reg); + return createElement( + "ul", + null, + s.items({ entity }).map((a) => createElement("li", { key: a.id }, a.label)), + s.dialog, + ); + }; + expect(renderToStaticMarkup(createElement(Probe))).toBe(""); + }); +}); + +describe("补字幕样板挂在两个真实位置", () => { + it("作品页操作区「补字幕」:合并后的作品不显示", () => { + expect(visibleActions(slots, "entity.actions", { entity }).map((a) => [a.feature, a.label])).toEqual([ + ["subtitles", "补字幕"], + ]); + expect(visibleActions(slots, "entity.actions", { entity: { ...entity, status: "merged" } })).toEqual([]); + }); + + it("整理记录行菜单「补字幕」:只给成功 / 部分成功、认到作品的入库类整理", () => { + const labels = (r: PublicOrganizationRecord) => + visibleActions(slots, "organize.record.actions", { record: r }).map((a) => a.label); + expect(labels(record())).toEqual(["补字幕"]); + expect(labels(record({ status: "partial", action: "move" }))).toEqual(["补字幕"]); + expect(labels(record({ status: "failed" }))).toEqual([]); + expect(labels(record({ action: "trash" }))).toEqual([]); + expect(labels(record({ media: null }))).toEqual([]); + }); +}); diff --git a/apps/web/app/components/slots/slot-mounts.tsx b/apps/web/app/components/slots/slot-mounts.tsx new file mode 100644 index 00000000..0b79445f --- /dev/null +++ b/apps/web/app/components/slots/slot-mounts.tsx @@ -0,0 +1,57 @@ +import { type ComponentType, type ReactNode, useState } from "react"; +import "~/features/index.ts"; +import { + type ActionPosition, + type ActionSlot, + type PanelPosition, + type PanelSlot, + type SlotProps, + type SlotRegistry, + slots, + visibleActions, +} from "~/lib/slots.ts"; + +// 插槽挂载点(ADR-006)。功能在 app/features/*.tsx 里注册,这里只负责在页面上渲染;没有注册时什么都不渲染。 + +/** 面板类插槽:按顺序渲染所有注册的组件 */ +export function SlotPanels

({ + position, + props, + registry = slots, +}: { + position: P; + props: SlotProps[P]; + registry?: SlotRegistry; +}) { + const entries = registry.entries(position) as PanelSlot

[]; + if (entries.length === 0) return null; + return ( + <> + {entries.map(({ id, Component }) => ( + + ))} + + ); +} + +/** + * 动作类插槽:返回可见的项(页面按自己的样式渲染成按钮或菜单项)和当前打开的对话框。 + * 对话框由页面渲染在菜单外面(菜单关了对话框也不会跟着卸载)。 + */ +export function useSlotActions

( + position: P, + registry: SlotRegistry = slots, +): { + items: (props: SlotProps[P]) => ActionSlot

[]; + open: (entry: ActionSlot

, props: SlotProps[P]) => void; + dialog: ReactNode; +} { + const [active, setActive] = useState<{ entry: ActionSlot

; props: SlotProps[P] } | null>(null); + // 泛型位置的 props 与 IntrinsicAttributes 交不出来,这里按具体对象渲染 + const Dialog = active?.entry.Dialog as ComponentType<{ onClose: () => void }> | undefined; + return { + items: (props) => visibleActions(registry, position, props), + open: (entry, props) => setActive({ entry, props }), + dialog: Dialog && active ?

setActive(null)} /> : null, + }; +} diff --git a/apps/web/app/features/index.ts b/apps/web/app/features/index.ts new file mode 100644 index 00000000..0ba75a24 --- /dev/null +++ b/apps/web/app/features/index.ts @@ -0,0 +1,5 @@ +// 功能模块的前端插槽注册汇总(ADR-006)。新增功能:在本目录加 <功能>.tsx 导出 register(registry),在这里调用一次。 +import { slots } from "~/lib/slots.ts"; +import { registerSubtitles } from "./subtitles.tsx"; + +registerSubtitles(slots); diff --git a/apps/web/app/features/subtitles.tsx b/apps/web/app/features/subtitles.tsx new file mode 100644 index 00000000..d3ec6afe --- /dev/null +++ b/apps/web/app/features/subtitles.tsx @@ -0,0 +1,37 @@ +import { Captions } from "lucide-react"; +import { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.tsx"; +import type { SlotRegistry } from "~/lib/slots.ts"; + +// 补字幕(功能模块样板,ADR-006):作品页操作区「补字幕」、整理记录行菜单「补字幕」,都打开同一个补字幕对话框。 + +export function registerSubtitles(registry: SlotRegistry): void { + registry.register("entity.actions", { + id: "subtitles.entity", + feature: "subtitles", + label: "补字幕", + icon: Captions, + visible: ({ entity }) => entity.status !== "merged", + Dialog: ({ entity, onClose }) => ( + !o && onClose()} /> + ), + }); + registry.register("organize.record.actions", { + id: "subtitles.record", + feature: "subtitles", + label: "补字幕", + icon: Captions, + // 入库成功(或部分成功)且认到作品的整理才有视频可配字幕 + visible: ({ record }) => + !!record.media && + (record.status === "succeeded" || record.status === "partial") && + ["copy", "move", "in_place"].includes(record.action), + Dialog: ({ record, onClose }) => ( + !o && onClose()} + /> + ), + }); +} diff --git a/apps/web/app/lib/slots.ts b/apps/web/app/lib/slots.ts new file mode 100644 index 00000000..7db52599 --- /dev/null +++ b/apps/web/app/lib/slots.ts @@ -0,0 +1,91 @@ +import type { MediaKind, PublicOrganizationRecord, SlotPosition } from "@umt/contracts"; +import type { ComponentType } from "react"; + +// 前端插槽(ADR-006):功能模块按位置注册组件,页面在挂载点渲染。默认直接显示,不做开关(内部用,做了就是要用)。 +// 注册写在 app/features/*.tsx,由 app/features/index.ts 汇总;挂载点组件在 components/slots/slot-mounts.tsx。 +// 位置分两类: +// - 动作类(entity.actions、organize.record.actions):一个按钮 / 菜单项,点了打开功能自己的对话框; +// - 面板类(entity.panels、settings.sections、sidebar.entry):直接渲染组件。 + +/** 插槽里拿到的作品(作品页) */ +export interface SlotEntity { + id: string; + title: string; + kind: MediaKind; + status: string; +} + +export interface SlotProps { + "sidebar.entry": Record; + "entity.actions": { entity: SlotEntity }; + "entity.panels": { entity: SlotEntity }; + "organize.record.actions": { record: PublicOrganizationRecord }; + "settings.sections": Record; +} + +export type ActionPosition = "entity.actions" | "organize.record.actions"; +export type PanelPosition = Exclude; + +interface SlotBase { + /** 全局唯一,建议 <功能>.<位置> */ + id: string; + /** 所属功能(contracts FEATURES 的 id) */ + feature: string; + /** 越小越靠前,缺省 100;同序按注册顺序 */ + order?: number; +} + +export interface ActionSlot

extends SlotBase { + label: string; + icon?: ComponentType<{ className?: string }>; + /** 不显示时返回 false(缺省显示) */ + visible?: (props: SlotProps[P]) => boolean; + /** 点了之后渲染的对话框;关闭时调 onClose */ + Dialog: ComponentType void }>; +} + +export interface PanelSlot

extends SlotBase { + Component: ComponentType; +} + +export type SlotEntry

= P extends ActionPosition + ? ActionSlot

+ : P extends PanelPosition + ? PanelSlot

+ : never; + +export interface SlotRegistry { + register

(position: P, entry: SlotEntry

): void; + entries

(position: P): SlotEntry

[]; +} + +export function createSlotRegistry(): SlotRegistry { + const byPos = new Map(); + return { + register(position, entry) { + const list = byPos.get(position) ?? []; + if (list.some((e) => e.id === entry.id)) throw new Error(`插槽 ${position} 已有 ${entry.id}`); + byPos.set(position, [...list, entry]); + }, + entries

(position: P) { + const list = (byPos.get(position) ?? []) as SlotEntry

[]; + // 稳定排序:order 相同保持注册顺序 + return list + .map((e, i) => ({ e, i })) + .sort((a, b) => (a.e.order ?? 100) - (b.e.order ?? 100) || a.i - b.i) + .map((x) => x.e); + }, + }; +} + +/** 应用里唯一的插槽表(features/index.ts 往这里注册) */ +export const slots: SlotRegistry = createSlotRegistry(); + +/** 动作类插槽里当前可见的项 */ +export function visibleActions

( + registry: SlotRegistry, + position: P, + props: SlotProps[P], +): ActionSlot

[] { + return (registry.entries(position) as ActionSlot

[]).filter((a) => a.visible?.(props) ?? true); +} diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index 86d5fdc1..e6ebe975 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -14,7 +14,6 @@ import { ArrowLeft, CalendarClock, CalendarPlus, - Captions, ChevronRight, ExternalLink, FolderTree, @@ -89,9 +88,9 @@ import { import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx"; import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx"; import { EmbyBadge } from "~/components/search/work-card.tsx"; +import { SlotPanels, useSlotActions } from "~/components/slots/slot-mounts.tsx"; import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts"; import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; -import { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { useUiPrefs } from "~/lib/ui-prefs.ts"; @@ -1110,7 +1109,7 @@ export default function LibraryDetailPage() { const entity = useEntityDetail(id); const [merging, setMerging] = useState(false); const [subscribing, setSubscribing] = useState(false); - const [subtitling, setSubtitling] = useState(false); + const entityActions = useSlotActions("entity.actions"); const launcher = useReleaseSearchLauncher(); const ms = mediaServers.useList(); @@ -1147,6 +1146,7 @@ export default function LibraryDetailPage() { } const d = detail.data; const e = d.entity; + const slotEntity = { id: e.id, title: e.title, kind: e.kind, status: e.status }; const redirected = entity.data?.redirectedFrom; const credits = { cast: creditList(metaValue(e.metadata, "cast")), @@ -1200,12 +1200,13 @@ export default function LibraryDetailPage() { 合并作品 ) : null} - {e.status !== "merged" ? ( - - ) : null} + ))} @@ -1230,6 +1231,8 @@ export default function LibraryDetailPage() { ) : null} + {/* 作品页面板区插槽(ADR-006) */} + {subscribing ? ( @@ -1244,9 +1247,7 @@ export default function LibraryDetailPage() { onClose={() => setSubscribing(false)} /> ) : null} - {subtitling ? ( - !o && setSubtitling(false)} /> - ) : null} + {entityActions.dialog} {launcher.dialog} ); diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index 4eda2afd..0c565439 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -66,9 +66,11 @@ import { } from "~/components/organize/record-parts.ts"; import { SourceSelection } from "~/components/organize/source-selection.tsx"; import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; +import { useSlotActions } from "~/components/slots/slot-mounts.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { reasonLine } from "~/lib/reasons.ts"; +import type { ActionSlot } from "~/lib/slots.ts"; import { useSse } from "~/lib/sse.ts"; import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; @@ -222,12 +224,17 @@ function RowMenu({ onReorganize, onDelete, onOnlyEntity, + slotActions, + onSlotAction, }: { r: Rec; onOpen: () => void; onReorganize: () => void; onDelete: () => void; onOnlyEntity: () => void; + /** 整理记录行操作插槽(ADR-006):功能模块注册的菜单项 */ + slotActions: ActionSlot<"organize.record.actions">[]; + onSlotAction: (a: ActionSlot<"organize.record.actions">) => void; }) { const [open, setOpen] = useState(false); const run = (fn: () => void) => () => { @@ -276,6 +283,11 @@ function RowMenu({ 只看这个作品 ) : null} + {slotActions.map((a) => ( + : null} onClick={run(() => onSlotAction(a))}> + {a.label} + + ))} {!isRunning(r) ? ( } onClick={run(onDelete)} danger> 删除… @@ -356,6 +368,7 @@ function StatsLine({ export default function OrganizePage() { const [params, setParams] = useSearchParams(); + const recordActions = useSlotActions("organize.record.actions"); const f = useQueryState(RECORD_FILTER_KEYS); const { set: setFilter, values: v } = f; const [qInput, setQInput] = useState(v.q); @@ -636,6 +649,8 @@ export default function OrganizePage() { v.group ? { mediaEntityId: r.media.entityId, group: v.group } : { mediaEntityId: r.media.entityId }, ) } + slotActions={recordActions.items({ record: r })} + onSlotAction={(a) => recordActions.open(a, { record: r })} /> ), }, @@ -771,6 +786,7 @@ export default function OrganizePage() { }} /> ) : null} + {recordActions.dialog}