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:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user