Browse Source

Add Seedance media review action

feature/0722ui
Luckyu_js 3 days ago
parent
commit
df9fe88954
  1. 83
      src/app/api/popi/media/review/__tests__/route.test.ts
  2. 66
      src/app/api/popi/media/review/route.ts
  3. 14
      src/components/CanvasContextMenu.tsx
  4. 3
      src/components/WorkflowCanvas.tsx
  5. 28
      src/components/__tests__/CanvasContextMenu.test.tsx
  6. 56
      src/components/__tests__/FloatingNodeHeader.test.tsx
  7. 5
      src/components/nodes/BaseNode.tsx
  8. 36
      src/components/nodes/FloatingNodeHeader.tsx
  9. 161
      src/hooks/__tests__/useCanvasContextMenuActions.test.tsx
  10. 68
      src/hooks/useCanvasContextMenuActions.tsx
  11. 7
      src/types/annotation.ts
  12. 38
      src/utils/__tests__/mediaReview.test.ts
  13. 53
      src/utils/mediaReview.ts

83
src/app/api/popi/media/review/__tests__/route.test.ts

@ -0,0 +1,83 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { POST } from "../route";
const originalFetch = global.fetch;
const originalBaseUrl = process.env.POPIART_API_BASE_URL;
function createRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/popi/media/review", {
method: "POST",
headers: new Headers({
Authorization: "Bearer user-token",
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
});
}
describe("/api/popi/media/review route", () => {
beforeEach(() => {
process.env.POPIART_API_BASE_URL = "https://popi.example";
});
afterEach(() => {
global.fetch = originalFetch;
process.env.POPIART_API_BASE_URL = originalBaseUrl;
vi.restoreAllMocks();
});
it("requires a media URL", async () => {
const fetchMock = vi.fn();
global.fetch = fetchMock;
const response = await POST(createRequest({ url: "" }));
await expect(response.json()).resolves.toEqual({ success: false, error: "Media URL is required." });
expect(response.status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it("proxies media review requests to Popiserver", async () => {
const payload = {
status: "0000",
message: "ok",
data: {
id: 12853,
reviewStatus: 1,
},
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(payload), { status: 200 })
);
global.fetch = fetchMock;
const response = await POST(createRequest({ url: "https://example.com/image.png" }));
await expect(response.json()).resolves.toEqual(payload);
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith("https://popi.example/api_client/media/review", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer user-token",
token: "user-token",
},
body: JSON.stringify({ url: "https://example.com/image.png" }),
cache: "no-store",
});
});
it("returns an error when Popiserver returns a non-success status", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: "0500", message: "review failed" }), { status: 200 })
);
global.fetch = fetchMock;
const response = await POST(createRequest({ url: "https://example.com/image.png" }));
await expect(response.json()).resolves.toEqual({ success: false, error: "review failed" });
expect(response.status).toBe(502);
});
});

66
src/app/api/popi/media/review/route.ts

@ -0,0 +1,66 @@
import { fetchPopiserverGateway as fetch } from "@/app/api/_popiserverFetch";
import { NextRequest, NextResponse } from "next/server";
import { requireLogin } from "@/app/api/_auth";
import { ApiError } from "@/app/api/_errors";
const POPI_MEDIA_REVIEW_PATH = "/api_client/media/review";
type PopiserverResponse<T> = {
status?: string;
message?: string;
data?: T;
};
type PopiMediaReviewData = {
reviewStatus?: number;
[key: string]: unknown;
};
function getPopiBaseUrl(request: NextRequest): string {
const baseUrl =
process.env.POPIART_API_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
request.nextUrl.origin;
return baseUrl.replace(/\/+$/, "");
}
export async function POST(request: NextRequest) {
try {
const login = requireLogin(request);
const body = (await request.json().catch(() => null)) as { url?: unknown } | null;
const mediaUrl = typeof body?.url === "string" ? body.url.trim() : "";
if (!mediaUrl) {
throw ApiError.badRequest("Media URL is required.");
}
const upstream = await fetch(getPopiBaseUrl(request) + POPI_MEDIA_REVIEW_PATH, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${login.token}`,
token: login.token,
},
body: JSON.stringify({ url: mediaUrl }),
cache: "no-store",
});
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<PopiMediaReviewData> | null;
if (!upstream.ok) {
throw ApiError.provider(payload?.message || "Popiserver media review failed.", upstream.status);
}
if (!payload || payload.status !== "0000") {
throw ApiError.provider(payload?.message || "Popiserver media review returned an error.", 502);
}
return NextResponse.json(payload);
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status });
}
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Media review failed." },
{ status: 500 }
);
}
}

14
src/components/CanvasContextMenu.tsx

@ -22,6 +22,7 @@ export type CanvasContextMenuAction =
| "redo"
| "paste"
| "editText"
| "reviewMedia"
| "saveToAssets"
| "copyNode"
| "copyImage"
@ -37,6 +38,7 @@ export interface CanvasContextMenuState {
canSaveToAssets?: boolean;
canCopyImage?: boolean;
canEditText?: boolean;
canReviewMedia?: boolean;
}
interface CanvasContextMenuProps {
@ -87,6 +89,7 @@ export function CanvasContextMenu({
const nodeItems: MenuItem[] = [
...(state.canEditText ? [{ key: "editText" as const, label: "编辑", icon: <EditOutlined /> }] : []),
...(state.canReviewMedia ? [{ key: "reviewMedia" as const, label: "Seedance 2.0 合规校验", icon: <CanvasReviewIcon /> }] : []),
{ key: "saveToAssets", label: "保存到我的资产", icon: <SaveOutlined />, disabled: !state.canSaveToAssets },
{ key: "copyNode", label: "复制节点", icon: <CopyOutlined /> },
{ key: "copyImage", label: "复制图片", icon: <FileImageOutlined />, disabled: !state.canCopyImage },
@ -96,7 +99,7 @@ export function CanvasContextMenu({
const items = state.mode === "node" ? nodeItems : canvasItems;
const viewportPadding = 8;
const menuWidth = state.mode === "node" ? 180 : 142;
const menuWidth = state.mode === "node" ? (state.canReviewMedia ? 224 : 180) : 142;
const menuHeight = items.length * 32 + Math.max(items.length - 1, 0) * 4 + 12;
const left = Math.min(state.position.x, window.innerWidth - menuWidth - viewportPadding);
const top = Math.min(state.position.y, window.innerHeight - menuHeight - viewportPadding);
@ -124,3 +127,12 @@ export function CanvasContextMenu({
</CanvasMenuSurface>
);
}
function CanvasReviewIcon() {
return (
<svg className="h-[15px] w-[15px]" fill="none" viewBox="0 0 20 20" stroke="currentColor" strokeWidth={1.4}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 3.5 15 5.4V9.6C15 12.8 12.85 15.45 10 16.5C7.15 15.45 5 12.8 5 9.6V5.4L10 3.5Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m7.8 10 1.45 1.45L12.5 8.2" />
</svg>
);
}

3
src/components/WorkflowCanvas.tsx

@ -83,6 +83,7 @@ import { buildDefaultGenerationNodeCreation } from "@/utils/defaultGenerationNod
import { isSmartImageGenerateMode } from "@/utils/smartMediaMode";
import { deriveSmartTextMode } from "@/utils/smartTextMode";
import { getNodePrimaryImageUrl, getNodePrimaryMediaUrl, isTextOnlyContextMenuNode } from "@/utils/canvasNodeMedia";
import { getReviewableMediaUrl } from "@/utils/mediaReview";
import {
getNodeHandleCapability,
getNodeOutputMediaType,
@ -2863,6 +2864,7 @@ export function WorkflowCanvas() {
const flowPosition = screenToFlowPosition(position);
const mediaUrl = getNodePrimaryMediaUrl(node, edges);
const imageUrl = getNodePrimaryImageUrl(node, edges);
const reviewableMediaUrl = getReviewableMediaUrl(node, edges);
setContextMenu({
mode: "node",
@ -2874,6 +2876,7 @@ export function WorkflowCanvas() {
canSaveToAssets: Boolean(mediaUrl) && !isTextOnlyContextMenuNode(node),
canCopyImage: Boolean(imageUrl),
canEditText: node.type === "smartText",
canReviewMedia: Boolean(reviewableMediaUrl),
});
}, [edges, screenToFlowPosition]);

28
src/components/__tests__/CanvasContextMenu.test.tsx

@ -45,4 +45,32 @@ describe("CanvasContextMenu", () => {
expect(screen.queryByRole("menuitem", { name: "编辑" })).not.toBeInTheDocument();
});
it("shows the Seedance review action for reviewable media nodes", () => {
render(
<CanvasContextMenu
state={{ ...baseNodeState, canReviewMedia: true }}
canUndo={false}
canRedo={false}
onAction={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByRole("menuitem", { name: "Seedance 2.0 合规校验" })).toBeInTheDocument();
});
it("hides the Seedance review action when no reviewable media URL exists", () => {
render(
<CanvasContextMenu
state={{ ...baseNodeState, canReviewMedia: false }}
canUndo={false}
canRedo={false}
onAction={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByRole("menuitem", { name: "Seedance 2.0 合规校验" })).not.toBeInTheDocument();
});
});

56
src/components/__tests__/FloatingNodeHeader.test.tsx

@ -199,4 +199,60 @@ describe("FloatingNodeHeader", () => {
expect(onCustomTitleChange).toHaveBeenCalledWith("image-1", "Selected Lost");
});
it("shows reviewing media review status", () => {
render(
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
selected={false}
title="Image Input"
mediaReview={{
status: "reviewing",
url: "https://example.com/image.png",
updatedAt: "2026-07-23T00:00:00.000Z",
}}
/>
);
expect(screen.getByText("校验中")).toBeInTheDocument();
});
it("shows approved and rejected media review icons", () => {
const { rerender } = render(
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
selected={false}
title="Image Input"
mediaReview={{
status: "approved",
reviewStatus: 1,
message: "ok",
url: "https://example.com/image.png",
updatedAt: "2026-07-23T00:00:00.000Z",
}}
/>
);
expect(screen.getByLabelText("审核通过")).toBeInTheDocument();
rerender(
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
selected={false}
title="Image Input"
mediaReview={{
status: "rejected",
reviewStatus: -1,
message: "blocked",
url: "https://example.com/image.png",
updatedAt: "2026-07-23T00:00:00.000Z",
}}
/>
);
expect(screen.getByLabelText("审核未通过")).toBeInTheDocument();
});
});

5
src/components/nodes/BaseNode.tsx

@ -22,6 +22,7 @@ import { FloatingNodeHeader } from "./FloatingNodeHeader";
import { useNodeHeaderContext } from "./NodeHeaderContext";
import { getNodeHeaderDimensionsText, getNodeHeaderIconKind } from "./nodeHeaderMedia";
import { getEffectiveNodeHeaderModel, getNodeHeaderTitle } from "./nodeHeaderTitle";
import { resolveActiveMediaReview } from "@/utils/mediaReview";
const DEFAULT_NODE_DIMENSION = 300;
@ -260,10 +261,11 @@ export function BaseNode({
// the inline-parameters flag. Memoizing avoids re-running edge-traversing helpers
// (title/effective-model/smart-mode checks) on unrelated re-renders.
const nodeData = node?.data as WorkflowNodeData | undefined;
const { nodeTitle, iconKind, dimensionsText, customTitle, effectiveModel, showBrowse } = useMemo(() => ({
const { nodeTitle, iconKind, dimensionsText, mediaReview, customTitle, effectiveModel, showBrowse } = useMemo(() => ({
nodeTitle: node ? getNodeHeaderTitle(node, edges, t) : "",
iconKind: node ? getNodeHeaderIconKind(node.type) : null,
dimensionsText: node ? getNodeHeaderDimensionsText(node) : null,
mediaReview: node ? resolveActiveMediaReview(node, edges) : null,
customTitle: typeof node?.data?.customTitle === "string" ? node.data.customTitle : undefined,
effectiveModel: node ? getEffectiveNodeHeaderModel(node, edges) : undefined,
showBrowse: Boolean(node && inlineParametersEnabled && (
@ -294,6 +296,7 @@ export function BaseNode({
title={nodeTitle}
iconKind={iconKind}
dimensionsText={dimensionsText}
mediaReview={mediaReview}
customTitle={customTitle}
provider={isPopiProviderMode() ? undefined : effectiveModel?.provider}
headerAction={browseAction}

36
src/components/nodes/FloatingNodeHeader.tsx

@ -8,6 +8,7 @@ import { DEFAULT_NODE_DIMENSIONS, defaultNodeDimensions } from "@/store/utils/no
import { ProviderBadge } from "./ProviderBadge";
import type { NodeHeaderIconKind } from "./nodeHeaderMedia";
import { NodeAudioIcon, NodeImageIcon, NodeTextIcon, NodeVideoIcon } from "@/components/icons/NodeMediaIcons";
import type { MediaReviewData } from "@/utils/mediaReview";
const EXPANDABLE_TYPES = new Set(['prompt', 'promptConstructor', 'annotation']);
const HEADER_MEDIA_BADGE_CLASS = "inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-white leading-none";
@ -76,6 +77,7 @@ interface FloatingNodeHeaderProps {
iconKind?: NodeHeaderIconKind | null;
dimensionsText?: string | null;
showDimensions?: boolean;
mediaReview?: MediaReviewData | null;
customTitle?: string;
onCustomTitleChange?: (nodeId: string, title: string) => void;
}
@ -94,6 +96,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
iconKind,
dimensionsText,
showDimensions = false,
mediaReview,
customTitle,
onCustomTitleChange,
}: FloatingNodeHeaderProps) {
@ -362,6 +365,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
{displayTitle}
</span>
)}
<MediaReviewBadge review={mediaReview} />
{showDimensions && dimensionsText && (
<span className="shrink-0 text-[11px] font-medium text-[var(--text-muted)]">
{dimensionsText}
@ -436,3 +440,35 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
</div>
);
});
function MediaReviewBadge({ review }: { review?: MediaReviewData | null }) {
if (!review) return null;
if (review.status === "reviewing") {
return (
<span className="shrink-0 text-[11px] font-medium text-[#19bfff]" title="Seedance 2.0 合规校验中">
</span>
);
}
if (review.status === "approved") {
return (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center text-[#19bfff]" title={review.message || "审核通过"} aria-label="审核通过">
<svg className="h-4 w-4" fill="none" viewBox="0 0 20 20" stroke="currentColor" strokeWidth={1.7}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 3.5 15 5.4V9.6C15 12.8 12.85 15.45 10 16.5C7.15 15.45 5 12.8 5 9.6V5.4L10 3.5Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m7.8 10 1.45 1.45L12.5 8.2" />
</svg>
</span>
);
}
return (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center text-[#ff4d4f]" title={review.message || "审核未通过"} aria-label="审核未通过">
<svg className="h-4 w-4" fill="none" viewBox="0 0 20 20" stroke="currentColor" strokeWidth={1.7}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 3.5 15 5.4V9.6C15 12.8 12.85 15.45 10 16.5C7.15 15.45 5 12.8 5 9.6V5.4L10 3.5Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m7.8 7.8 4.4 4.4M12.2 7.8l-4.4 4.4" />
</svg>
</span>
);
}

161
src/hooks/__tests__/useCanvasContextMenuActions.test.tsx

@ -0,0 +1,161 @@
import { act, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowNode } from "@/types";
import { useWorkflowStore } from "@/store/workflowStore";
import { useCanvasContextMenuActions } from "@/hooks/useCanvasContextMenuActions";
import { authFetch } from "@/utils/authFetch";
const messageSuccess = vi.fn();
const messageWarning = vi.fn();
const messageError = vi.fn();
let capturedOnAction: ((action: string) => void) | null = null;
vi.mock("antd", () => ({
App: {
useApp: () => ({
message: {
success: messageSuccess,
warning: messageWarning,
error: messageError,
},
}),
},
}));
vi.mock("@/utils/authFetch", () => ({
authFetch: vi.fn(),
}));
vi.mock("@/components/CanvasContextMenu", async () => {
const actual = await vi.importActual<typeof import("@/components/CanvasContextMenu")>("@/components/CanvasContextMenu");
return {
...actual,
CanvasContextMenu: ({ onAction }: { onAction: (action: string) => void }) => {
capturedOnAction = onAction;
return null;
},
};
});
function makeImageNode(): WorkflowNode {
return {
id: "image-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: {
image: "https://example.com/image.png",
filename: "image.png",
dimensions: null,
},
} as WorkflowNode;
}
function renderActions(node = makeImageNode()) {
useWorkflowStore.setState({
nodes: [node],
edges: [],
hasUnsavedChanges: false,
});
function TestComponent() {
const actions = useCanvasContextMenuActions({
contextMenu: {
mode: "node",
nodeId: node.id,
position: { x: 10, y: 10 },
flowPosition: { x: 10, y: 10 },
targetMediaUrl: "https://example.com/image.png",
targetImageUrl: "https://example.com/image.png",
canReviewMedia: true,
},
nodes: [node],
edges: [],
canUndo: false,
canRedo: false,
getCanvasFlowCenter: () => ({ centerX: 0, centerY: 0 }),
createNodesFromUploadedFiles: vi.fn(),
onCloseContextMenu: vi.fn(),
});
return <>{actions.menu}</>;
}
return render(<TestComponent />);
}
describe("useCanvasContextMenuActions media review", () => {
beforeEach(() => {
capturedOnAction = null;
vi.useRealTimers();
});
afterEach(() => {
messageSuccess.mockClear();
messageWarning.mockClear();
messageError.mockClear();
vi.mocked(authFetch).mockReset();
});
it("stores reviewing status and saves approved review result", async () => {
vi.mocked(authFetch).mockResolvedValue(
new Response(JSON.stringify({
status: "0000",
message: "ok",
data: { reviewStatus: 1 },
}), { status: 200 })
);
renderActions();
act(() => {
capturedOnAction?.("reviewMedia");
});
expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toMatchObject({
status: "reviewing",
url: "https://example.com/image.png",
});
await waitFor(() => expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toMatchObject({
status: "approved",
reviewStatus: 1,
message: "ok",
url: "https://example.com/image.png",
}));
expect(messageSuccess).toHaveBeenCalledWith("校验成功");
});
it("saves rejected review result and warns with response message", async () => {
vi.mocked(authFetch).mockResolvedValue(
new Response(JSON.stringify({
status: "0000",
message: "not ok",
data: { reviewStatus: -1 },
}), { status: 200 })
);
renderActions();
act(() => {
capturedOnAction?.("reviewMedia");
});
await waitFor(() => expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toMatchObject({
status: "rejected",
reviewStatus: -1,
message: "not ok",
}));
expect(messageWarning).toHaveBeenCalledWith("校验失败");
});
it("clears reviewing status and shows an error when review request fails", async () => {
vi.mocked(authFetch).mockResolvedValue(
new Response(JSON.stringify({ success: false, error: "request failed" }), { status: 500 })
);
renderActions();
act(() => {
capturedOnAction?.("reviewMedia");
});
await waitFor(() => expect(useWorkflowStore.getState().nodes[0].data.mediaReview).toBeUndefined());
expect(messageError).toHaveBeenCalledWith("request failed");
});
});

68
src/hooks/useCanvasContextMenuActions.tsx

@ -1,5 +1,6 @@
"use client";
import { App } from "antd";
import { useCallback, useRef, type ChangeEvent, type ReactNode } from "react";
import type { XYPosition } from "@xyflow/react";
import type { WorkflowEdge, WorkflowNode, WorkflowNodeData } from "@/types";
@ -12,6 +13,12 @@ import { pasteClipboardAtPosition } from "@/utils/canvasClipboard";
import {
copyImageUrlToClipboard,
} from "@/utils/canvasNodeMedia";
import { authFetch } from "@/utils/authFetch";
import {
getReviewableMediaUrl,
mediaReviewStatusFromReviewStatus,
type MediaReviewResponse,
} from "@/utils/mediaReview";
import { REQUEST_SMART_TEXT_EDIT_EVENT } from "@/utils/smartTextEditRequest";
interface UseCanvasContextMenuActionsOptions {
@ -35,6 +42,7 @@ interface CanvasContextMenuActionBindings {
export function useCanvasContextMenuActions({
contextMenu,
nodes,
edges,
canUndo,
canRedo,
getCanvasFlowCenter,
@ -43,6 +51,7 @@ export function useCanvasContextMenuActions({
onCloseContextMenu,
}: UseCanvasContextMenuActionsOptions): CanvasContextMenuActionBindings {
const { t } = useI18n();
const { message } = App.useApp();
const { show: showToast } = useToast();
const uploadInputRef = useRef<HTMLInputElement>(null);
const uploadFlowPositionRef = useRef<XYPosition | null>(null);
@ -130,6 +139,63 @@ export function useCanvasContextMenuActions({
});
return;
}
case "reviewMedia": {
if (!targetNode) return;
const mediaUrl = getReviewableMediaUrl(targetNode, edges);
if (!mediaUrl) return;
const startedAt = new Date().toISOString();
updateNodeData(targetNode.id, {
mediaReview: {
status: "reviewing",
url: mediaUrl,
updatedAt: startedAt,
},
});
void authFetch("/api/popi/media/review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: mediaUrl }),
})
.then(async (response) => {
const payload = (await response.json().catch(() => null)) as (MediaReviewResponse & { error?: string }) | null;
if (!response.ok) {
throw new Error(payload?.error || payload?.message || "Seedance 2.0 合规校验失败");
}
if (!payload || payload.status !== "0000") {
throw new Error(payload?.message || "Seedance 2.0 合规校验失败");
}
const nextStatus = mediaReviewStatusFromReviewStatus(payload.data?.reviewStatus);
if (!nextStatus) {
throw new Error(payload.message || "Seedance 2.0 合规校验结果异常");
}
const latestNode = useWorkflowStore.getState().nodes.find((node) => node.id === targetNode.id);
if (!latestNode || getReviewableMediaUrl(latestNode, edges) !== mediaUrl) return;
const nextReviewStatus = nextStatus === "approved" ? 1 : -1;
updateNodeData(targetNode.id, {
mediaReview: {
status: nextStatus,
reviewStatus: nextReviewStatus,
message: payload.message,
url: mediaUrl,
updatedAt: new Date().toISOString(),
},
});
if (nextStatus === "approved") {
void message.success("校验成功");
} else {
void message.warning("校验失败");
}
})
.catch((error) => {
const latestNode = useWorkflowStore.getState().nodes.find((node) => node.id === targetNode.id);
if (latestNode && getReviewableMediaUrl(latestNode, edges) === mediaUrl) {
updateNodeData(targetNode.id, { mediaReview: undefined });
}
void message.error(error instanceof Error ? error.message : "Seedance 2.0 合规校验失败");
});
return;
}
case "saveToAssets": {
useNodeToolStore.getState().openSaveToAsset(contextMenu.targetMediaUrl);
return;
@ -169,7 +235,9 @@ export function useCanvasContextMenuActions({
contextMenu,
copyNodesById,
copySelectedNodes,
edges,
nodes,
message,
onCloseContextMenu,
pasteAtPosition,
redo,

7
src/types/annotation.ts

@ -12,6 +12,13 @@
export interface BaseNodeData extends Record<string, unknown> {
customTitle?: string;
comment?: string;
mediaReview?: {
status: "reviewing" | "approved" | "rejected";
reviewStatus?: 1 | -1;
message?: string;
url: string;
updatedAt: string;
};
}
// Shape type discriminator

38
src/utils/__tests__/mediaReview.test.ts

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { WorkflowNode } from "@/types";
import { getReviewableMediaUrl, resolveActiveMediaReview } from "@/utils/mediaReview";
function makeNode(data: WorkflowNode["data"], type: WorkflowNode["type"] = "imageInput"): WorkflowNode {
return {
id: "node-1",
type,
position: { x: 0, y: 0 },
data,
} as WorkflowNode;
}
describe("mediaReview", () => {
it("returns image and video URLs as reviewable media", () => {
expect(getReviewableMediaUrl(makeNode({ image: "https://example.com/a.png" }), [])).toBe("https://example.com/a.png");
expect(getReviewableMediaUrl(makeNode({ video: "https://example.com/a.mp4" }, "videoInput"), [])).toBe("https://example.com/a.mp4");
});
it("does not return audio URLs as reviewable media", () => {
expect(getReviewableMediaUrl(makeNode({ audioFile: "https://example.com/a.mp3" }, "audioInput"), [])).toBeNull();
});
it("ignores stale review status when the node media URL changed", () => {
const node = makeNode({
image: "https://example.com/new.png",
mediaReview: {
status: "approved",
reviewStatus: 1,
message: "ok",
url: "https://example.com/old.png",
updatedAt: "2026-07-23T00:00:00.000Z",
},
});
expect(resolveActiveMediaReview(node, [])).toBeNull();
});
});

53
src/utils/mediaReview.ts

@ -0,0 +1,53 @@
import type { Node } from "@xyflow/react";
import type { BaseNodeData, WorkflowEdge, WorkflowNode } from "@/types";
import { getNodePrimaryImageUrl, getNodePrimaryMediaUrl } from "@/utils/canvasNodeMedia";
export type MediaReviewStatus = "reviewing" | "approved" | "rejected";
export type MediaReviewData = NonNullable<BaseNodeData["mediaReview"]>;
export interface MediaReviewPayload {
url: string;
}
export interface MediaReviewResponse {
status?: string;
message?: string;
data?: {
reviewStatus?: number;
[key: string]: unknown;
};
}
export function getReviewableMediaUrl(node: Node | WorkflowNode, edges: WorkflowEdge[]): string | null {
const imageUrl = getNodePrimaryImageUrl(node, edges);
if (imageUrl) return imageUrl;
const mediaUrl = getNodePrimaryMediaUrl(node, edges);
if (!mediaUrl) return null;
const type = node.type;
if (type === "audioInput" || type === "smartAudio" || type === "generateAudio" || type === "glbViewer" || type === "generate3d") {
return null;
}
return mediaUrl;
}
export function resolveActiveMediaReview(
node: Node | WorkflowNode | null | undefined,
edges: WorkflowEdge[]
): MediaReviewData | null {
if (!node) return null;
const review = (node.data as BaseNodeData | undefined)?.mediaReview;
if (!review) return null;
const mediaUrl = getReviewableMediaUrl(node, edges);
if (!mediaUrl || review.url !== mediaUrl) return null;
return review;
}
export function mediaReviewStatusFromReviewStatus(reviewStatus: number | undefined): MediaReviewStatus | null {
if (reviewStatus === 1) return "approved";
if (reviewStatus === -1) return "rejected";
return null;
}
Loading…
Cancel
Save