feat(web): 前端插槽设施,作品页与整理记录行挂「补字幕」

- lib/slots.ts 按位置注册(侧栏入口、作品页操作区 / 面板区、整理记录行操作、设置分区),默认直接显示;
  components/slots 挂载点:面板类直接渲染,动作类渲染成按钮 / 菜单项、对话框在菜单外渲染
- 作品页操作区与面板区、整理记录行菜单接入插槽;补字幕改由 features/subtitles.tsx 注册,
  作品页按钮行为不变,整理记录行菜单新增「补字幕」(成功 / 部分成功的入库类整理)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 19:27:14 +08:00
1 parent 079c3eafe9
commit 62119a9b30
7 files changed
+313 -11

No files matched your search

@@ -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<PublicOrganizationRecord> = {}) =>
({
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("<section>面板:示例剧</section>");
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("<ul><li>总在</li><li>未合并才有</li></ul>");
});
});
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([]);
});
});
@@ -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<P extends PanelPosition>({
position,
props,
registry = slots,
}: {
position: P;
props: SlotProps[P];
registry?: SlotRegistry;
}) {
const entries = registry.entries(position) as PanelSlot<P>[];
if (entries.length === 0) return null;
return (
<>
{entries.map(({ id, Component }) => (
<Component key={id} {...props} />
))}
</>
);
}
/**
* 动作类插槽:返回可见的项(页面按自己的样式渲染成按钮或菜单项)和当前打开的对话框。
* 对话框由页面渲染在菜单外面(菜单关了对话框也不会跟着卸载)。
*/
export function useSlotActions<P extends ActionPosition>(
position: P,
registry: SlotRegistry = slots,
): {
items: (props: SlotProps[P]) => ActionSlot<P>[];
open: (entry: ActionSlot<P>, props: SlotProps[P]) => void;
dialog: ReactNode;
} {
const [active, setActive] = useState<{ entry: ActionSlot<P>; 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 ? <Dialog {...(active.props as object)} onClose={() => setActive(null)} /> : null,
};
}