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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// 功能模块的前端插槽注册汇总(ADR-006)。新增功能:在本目录加 <功能>.tsx 导出 register(registry),在这里调用一次。
|
||||||
|
import { slots } from "~/lib/slots.ts";
|
||||||
|
import { registerSubtitles } from "./subtitles.tsx";
|
||||||
|
|
||||||
|
registerSubtitles(slots);
|
||||||
@@ -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 }) => (
|
||||||
|
<SubtitleDialog entityId={entity.id} title={entity.title} open onOpenChange={(o) => !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 }) => (
|
||||||
|
<SubtitleDialog
|
||||||
|
entityId={record.media!.entityId}
|
||||||
|
title={record.media!.title}
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, never>;
|
||||||
|
"entity.actions": { entity: SlotEntity };
|
||||||
|
"entity.panels": { entity: SlotEntity };
|
||||||
|
"organize.record.actions": { record: PublicOrganizationRecord };
|
||||||
|
"settings.sections": Record<string, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionPosition = "entity.actions" | "organize.record.actions";
|
||||||
|
export type PanelPosition = Exclude<SlotPosition, ActionPosition>;
|
||||||
|
|
||||||
|
interface SlotBase {
|
||||||
|
/** 全局唯一,建议 <功能>.<位置> */
|
||||||
|
id: string;
|
||||||
|
/** 所属功能(contracts FEATURES 的 id) */
|
||||||
|
feature: string;
|
||||||
|
/** 越小越靠前,缺省 100;同序按注册顺序 */
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionSlot<P extends ActionPosition = ActionPosition> extends SlotBase {
|
||||||
|
label: string;
|
||||||
|
icon?: ComponentType<{ className?: string }>;
|
||||||
|
/** 不显示时返回 false(缺省显示) */
|
||||||
|
visible?: (props: SlotProps[P]) => boolean;
|
||||||
|
/** 点了之后渲染的对话框;关闭时调 onClose */
|
||||||
|
Dialog: ComponentType<SlotProps[P] & { onClose: () => void }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelSlot<P extends PanelPosition = PanelPosition> extends SlotBase {
|
||||||
|
Component: ComponentType<SlotProps[P]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlotEntry<P extends SlotPosition> = P extends ActionPosition
|
||||||
|
? ActionSlot<P>
|
||||||
|
: P extends PanelPosition
|
||||||
|
? PanelSlot<P>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export interface SlotRegistry {
|
||||||
|
register<P extends SlotPosition>(position: P, entry: SlotEntry<P>): void;
|
||||||
|
entries<P extends SlotPosition>(position: P): SlotEntry<P>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSlotRegistry(): SlotRegistry {
|
||||||
|
const byPos = new Map<SlotPosition, SlotBase[]>();
|
||||||
|
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<P extends SlotPosition>(position: P) {
|
||||||
|
const list = (byPos.get(position) ?? []) as SlotEntry<P>[];
|
||||||
|
// 稳定排序: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<P extends ActionPosition>(
|
||||||
|
registry: SlotRegistry,
|
||||||
|
position: P,
|
||||||
|
props: SlotProps[P],
|
||||||
|
): ActionSlot<P>[] {
|
||||||
|
return (registry.entries(position) as ActionSlot<P>[]).filter((a) => a.visible?.(props) ?? true);
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarPlus,
|
CalendarPlus,
|
||||||
Captions,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
@@ -89,9 +88,9 @@ import {
|
|||||||
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
||||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||||
import { EmbyBadge } from "~/components/search/work-card.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 { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts";
|
||||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||||
import { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.tsx";
|
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||||
@@ -1110,7 +1109,7 @@ export default function LibraryDetailPage() {
|
|||||||
const entity = useEntityDetail(id);
|
const entity = useEntityDetail(id);
|
||||||
const [merging, setMerging] = useState(false);
|
const [merging, setMerging] = useState(false);
|
||||||
const [subscribing, setSubscribing] = useState(false);
|
const [subscribing, setSubscribing] = useState(false);
|
||||||
const [subtitling, setSubtitling] = useState(false);
|
const entityActions = useSlotActions("entity.actions");
|
||||||
const launcher = useReleaseSearchLauncher();
|
const launcher = useReleaseSearchLauncher();
|
||||||
const ms = mediaServers.useList();
|
const ms = mediaServers.useList();
|
||||||
|
|
||||||
@@ -1147,6 +1146,7 @@ export default function LibraryDetailPage() {
|
|||||||
}
|
}
|
||||||
const d = detail.data;
|
const d = detail.data;
|
||||||
const e = d.entity;
|
const e = d.entity;
|
||||||
|
const slotEntity = { id: e.id, title: e.title, kind: e.kind, status: e.status };
|
||||||
const redirected = entity.data?.redirectedFrom;
|
const redirected = entity.data?.redirectedFrom;
|
||||||
const credits = {
|
const credits = {
|
||||||
cast: creditList(metaValue(e.metadata, "cast")),
|
cast: creditList(metaValue(e.metadata, "cast")),
|
||||||
@@ -1200,12 +1200,13 @@ export default function LibraryDetailPage() {
|
|||||||
合并作品
|
合并作品
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{e.status !== "merged" ? (
|
{/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */}
|
||||||
<Button variant="outline" size="sm" onClick={() => setSubtitling(true)}>
|
{entityActions.items({ entity: slotEntity }).map((a) => (
|
||||||
<Captions />
|
<Button key={a.id} variant="outline" size="sm" onClick={() => entityActions.open(a, { entity: slotEntity })}>
|
||||||
补字幕
|
{a.icon ? <a.icon /> : null}
|
||||||
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
))}
|
||||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||||
</div>
|
</div>
|
||||||
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
|
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
|
||||||
@@ -1230,6 +1231,8 @@ export default function LibraryDetailPage() {
|
|||||||
<UnitsPanel entity={entity} detail={d} />
|
<UnitsPanel entity={entity} detail={d} />
|
||||||
) : null}
|
) : null}
|
||||||
<DownloadsPanel detail={d} />
|
<DownloadsPanel detail={d} />
|
||||||
|
{/* 作品页面板区插槽(ADR-006) */}
|
||||||
|
<SlotPanels position="entity.panels" props={{ entity: slotEntity }} />
|
||||||
</div>
|
</div>
|
||||||
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
|
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
|
||||||
{subscribing ? (
|
{subscribing ? (
|
||||||
@@ -1244,9 +1247,7 @@ export default function LibraryDetailPage() {
|
|||||||
onClose={() => setSubscribing(false)}
|
onClose={() => setSubscribing(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{subtitling ? (
|
{entityActions.dialog}
|
||||||
<SubtitleDialog entityId={e.id} title={e.title} open onOpenChange={(o) => !o && setSubtitling(false)} />
|
|
||||||
) : null}
|
|
||||||
{launcher.dialog}
|
{launcher.dialog}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,9 +66,11 @@ import {
|
|||||||
} from "~/components/organize/record-parts.ts";
|
} from "~/components/organize/record-parts.ts";
|
||||||
import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
||||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||||
import { reasonLine } from "~/lib/reasons.ts";
|
import { reasonLine } from "~/lib/reasons.ts";
|
||||||
|
import type { ActionSlot } from "~/lib/slots.ts";
|
||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
@@ -222,12 +224,17 @@ function RowMenu({
|
|||||||
onReorganize,
|
onReorganize,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOnlyEntity,
|
onOnlyEntity,
|
||||||
|
slotActions,
|
||||||
|
onSlotAction,
|
||||||
}: {
|
}: {
|
||||||
r: Rec;
|
r: Rec;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onReorganize: () => void;
|
onReorganize: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onOnlyEntity: () => void;
|
onOnlyEntity: () => void;
|
||||||
|
/** 整理记录行操作插槽(ADR-006):功能模块注册的菜单项 */
|
||||||
|
slotActions: ActionSlot<"organize.record.actions">[];
|
||||||
|
onSlotAction: (a: ActionSlot<"organize.record.actions">) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const run = (fn: () => void) => () => {
|
const run = (fn: () => void) => () => {
|
||||||
@@ -276,6 +283,11 @@ function RowMenu({
|
|||||||
只看这个作品
|
只看这个作品
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{slotActions.map((a) => (
|
||||||
|
<MenuItem key={a.id} icon={a.icon ? <a.icon /> : null} onClick={run(() => onSlotAction(a))}>
|
||||||
|
{a.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
{!isRunning(r) ? (
|
{!isRunning(r) ? (
|
||||||
<MenuItem icon={<Trash2 />} onClick={run(onDelete)} danger>
|
<MenuItem icon={<Trash2 />} onClick={run(onDelete)} danger>
|
||||||
删除…
|
删除…
|
||||||
@@ -356,6 +368,7 @@ function StatsLine({
|
|||||||
|
|
||||||
export default function OrganizePage() {
|
export default function OrganizePage() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
const recordActions = useSlotActions("organize.record.actions");
|
||||||
const f = useQueryState(RECORD_FILTER_KEYS);
|
const f = useQueryState(RECORD_FILTER_KEYS);
|
||||||
const { set: setFilter, values: v } = f;
|
const { set: setFilter, values: v } = f;
|
||||||
const [qInput, setQInput] = useState(v.q);
|
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 },
|
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}
|
) : null}
|
||||||
|
{recordActions.dialog}
|
||||||
<DeleteRecordsDialog
|
<DeleteRecordsDialog
|
||||||
records={toDelete ?? []}
|
records={toDelete ?? []}
|
||||||
open={!!toDelete}
|
open={!!toDelete}
|
||||||
|
|||||||
Reference in new issue
Block a user