revert(canvas): TapNow 公开画布导入 - 撤销 main 误合并

This commit is contained in:
ddCat
2026-08-19 11:54:58 +08:00
parent 6fc49b4712
commit 1c4ce44112
13 changed files with 2 additions and 1087 deletions

View File

@@ -1,25 +0,0 @@
const TAPNOW_SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const TAPNOW_SHARE_HOST = "app.tapnow.media";
export function parseTapNowShareID(value: string) {
const trimmed = value.trim();
if (!trimmed) return "";
let candidate = trimmed;
try {
const parsed = new URL(trimmed);
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
if (hostname !== TAPNOW_SHARE_HOST || !/^\/tapflow\/view\/?[^/]*\/?$/.test(parsed.pathname)) return "";
const parts = parsed.pathname.split("/").filter(Boolean);
candidate = parts.at(-1) || "";
} catch {
candidate = trimmed;
}
return TAPNOW_SHARE_ID_PATTERN.test(candidate) ? candidate : "";
}
export function formatTapNowBatchTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未知";
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}`;
}

View File

@@ -1,7 +1,7 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
@@ -28,7 +28,6 @@ type CanvasTopBarProps = {
onCreateProject: () => void;
onDeleteProject: () => void;
onImportImage: () => void;
onImportTapNow: () => void;
onUndo: () => void;
onRedo: () => void;
onShare: () => void;
@@ -59,7 +58,6 @@ export function CanvasTopBar({
onCreateProject,
onDeleteProject,
onImportImage,
onImportTapNow,
onUndo,
onRedo,
onShare,
@@ -193,9 +191,6 @@ export function CanvasTopBar({
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} onClick={onImportTapNow} aria-label="导入 TapNow 画布" title="导入 TapNow 画布">
<span className="hidden xl:inline">导入 TapNow</span>
</Button>
<Dropdown
trigger={["click"]}
menu={{

View File

@@ -1,185 +0,0 @@
import { App, Button, Input, Modal, Tag } from "antd";
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { formatTapNowBatchTime, parseTapNowShareID } from "@/lib/canvas/tapnow-import";
import { importTapNowCanvas, type TapNowImportResult } from "@/services/api/tapnow";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
type Props = {
open: boolean;
projectId: string;
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onClose: () => void;
onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise<void>;
};
function buildCanvasNodes(result: TapNowImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) {
const minX = Math.min(...result.nodes.map((node) => node.x));
const minY = Math.min(...result.nodes.map((node) => node.y));
const maxX = Math.max(...result.nodes.map((node) => node.x + node.width));
const maxY = Math.max(...result.nodes.map((node) => node.y + node.height));
const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2;
const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2;
return result.nodes.map<CanvasNodeData>((node) => ({
id: node.id,
type: node.type === "video" ? CanvasNodeType.Video : node.type === "audio" ? CanvasNodeType.Audio : node.type === "text" ? CanvasNodeType.Text : CanvasNodeType.Image,
title: node.title,
position: { x: node.x + offsetX, y: node.y + offsetY },
width: node.width,
height: node.height,
metadata: {
content: node.content,
prompt: node.prompt,
model: node.model,
size: node.size,
quality: node.quality,
seconds: node.seconds,
vquality: node.vquality,
generateAudio: node.generateAudio,
status: node.status || "idle",
errorDetails: node.errorDetails,
naturalWidth: node.naturalWidth,
naturalHeight: node.naturalHeight,
durationMs: node.durationMs,
mimeType: node.mimeType,
importSource: node.metadata,
},
}));
}
export function TapNowImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) {
const { message } = App.useApp();
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<TapNowImportResult | null>(null);
const shareID = useMemo(() => parseTapNowShareID(value), [value]);
const reset = () => {
setValue("");
setResult(null);
};
const close = () => {
if (loading) return;
reset();
onClose();
};
const changeValue = (nextValue: string) => {
setValue(nextValue);
setResult(null);
};
const load = async () => {
if (!shareID) {
message.error("请填写有效的 TapNow 画布分享链接或分享 ID");
return;
}
setLoading(true);
try {
setResult(await importTapNowCanvas(projectId, shareID));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取 TapNow 画布失败");
} finally {
setLoading(false);
}
};
const apply = async () => {
if (!result) return;
setLoading(true);
try {
await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections);
reset();
onClose();
message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存导入结果失败");
} finally {
setLoading(false);
}
};
const hasWarnings = result ? Boolean(result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.warnings.length) : false;
return (
<Modal
className="workspace-modal"
open={open}
onCancel={close}
title="导入 TapNow 画布"
width={620}
footer={
result ? (
[
<Button key="close" onClick={close}>
关闭
</Button>,
<Button key="apply" type="primary" icon={<Import className="size-4" />} loading={loading} onClick={() => void apply()}>
确认导入
</Button>,
]
) : (
<Button type="primary" loading={loading} onClick={() => void load()}>
读取画布
</Button>
)
}
>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">TapNow 画布分享链接或分享 ID</label>
<Input
value={value}
onChange={(event) => changeValue(event.target.value)}
placeholder="粘贴 https://app.tapnow.media/tapflow/view/…"
disabled={loading}
suffix={shareID && value !== shareID ? <ExternalLink className="size-4 text-foreground/35" /> : null}
/>
</div>
{loading && !result ? (
<div className="flex items-center gap-2 text-sm text-foreground/55">
<LoaderCircle className="size-4 animate-spin" />
正在读取 TapNow 画布…
</div>
) : null}
{result ? (
<div className="space-y-3">
<div className="rounded-xl p-4" style={{ background: "var(--library-surface)" }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{result.projectName || "TapNow 画布"}</div>
<div className="mt-1 text-sm text-foreground/60">
可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线
</div>
</div>
<Tag color="blue">批次:{formatTapNowBatchTime(result.batchCreatedAt)}</Tag>
</div>
<div className="mt-3 text-xs leading-5 text-foreground/50">支持图片、视频、音频和文本节点;节点会保留相对位置,并整体放到当前可视区域中心。</div>
</div>
{hasWarnings ? (
<div className="flex items-start gap-2 rounded-lg px-3 py-2.5 text-xs leading-5 text-foreground/60" style={{ background: "var(--surface-hover)" }}>
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<div>
{result.skippedNodes.length ? <div>{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。</div> : null}
{!result.skippedNodes.length && result.skippedConnections.length ? <div>{result.skippedConnections.length} 条无效连线已自动忽略。</div> : null}
{result.reusedFailedNodeCount ? <div>{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。</div> : null}
{result.placeholderNodeCount ? <div>{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。</div> : null}
{result.multiResultNodeCount ? <div>{result.multiResultNodeCount} 个多结果节点已使用首个结果。</div> : null}
{result.warnings.map((warning, index) => (
<div key={`${warning.id || "warning"}-${index}`}>{warning.message}</div>
))}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Tag>等待确认导入</Tag>
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -72,7 +72,6 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
@@ -191,7 +190,6 @@ function InfiniteCanvasPage() {
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(readCanvasWorkspaceMode);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
const [nodeSearchOpen, setNodeSearchOpen] = useState(false);
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
@@ -333,28 +331,6 @@ function InfiniteCanvasPage() {
cleanupAssetImages,
cleanupCanvasFiles,
});
const applyTapNowImport = useCallback(
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
const previousNodes = nodesRef.current;
const previousConnections = connectionsRef.current;
const nextNodes = [...nodesRef.current, ...importedNodes];
const nextConnections = [...connectionsRef.current, ...importedConnections];
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
const saved = await saveCanvasProject();
if (!saved) {
nodesRef.current = previousNodes;
connectionsRef.current = previousConnections;
setNodes(previousNodes);
setConnections(previousConnections);
throw new Error("画布保存失败,已撤销本次 TapNow 导入");
}
},
[saveCanvasProject, setConnections, setNodes],
);
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
const refetchLinkedProject = linkedProjectQuery.refetch;
@@ -1663,7 +1639,6 @@ function InfiniteCanvasPage() {
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onImportTapNow={() => setTapNowImportOpen(true)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onShare={() => setShareModalOpen(true)}
@@ -1711,7 +1686,6 @@ function InfiniteCanvasPage() {
) : null}
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
<TapNowImportDialog open={tapNowImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setTapNowImportOpen(false)} onApply={applyTapNowImport} />
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />

View File

@@ -1,57 +0,0 @@
import type { CanvasConnection } from "@/types/canvas";
import { apiClient, request } from "@/services/api/request";
export type TapNowImportIssue = { id?: string; name?: string; reason: string };
export type TapNowImportWarning = { id?: string; message: string };
export type TapNowImportNode = {
id: string;
type: "image" | "video" | "audio" | "text";
title: string;
x: number;
y: number;
width: number;
height: number;
content: string;
prompt?: string;
model?: string;
size?: string;
quality?: string;
seconds?: string;
vquality?: string;
generateAudio?: string;
naturalWidth?: number;
naturalHeight?: number;
durationMs?: number;
mimeType?: string;
status?: "idle" | "success" | "error";
errorDetails?: string;
metadata: {
provider: "tapnow";
shareId: string;
nodeId: string;
batchId: string;
sourceType?: string;
};
};
export type TapNowImportResult = {
batchId: string;
batchCreatedAt: string;
shareId: string;
projectName: string;
nodes: TapNowImportNode[];
connections: CanvasConnection[];
importedNodeCount: number;
importedConnectionCount: number;
skippedNodes: TapNowImportIssue[];
skippedConnections: TapNowImportIssue[];
warnings: TapNowImportWarning[];
multiResultNodeCount: number;
reusedFailedNodeCount: number;
placeholderNodeCount: number;
};
export function importTapNowCanvas(projectId: string, shareId: string) {
return request<TapNowImportResult>(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/tapnow`, { shareId }));
}

View File

@@ -142,13 +142,6 @@ export type CanvasSkillSnapshot = {
};
export type CanvasNodeMetadata = {
importSource?: {
provider: "tapnow";
shareId: string;
nodeId: string;
batchId: string;
sourceType?: string;
};
content?: string;
richText?: Record<string, unknown>;
composerContent?: string;