feat: 交互式对话框生成器 + 电商取消生成与上传优化
新增: - 交互式对话框生成器模块(路由、页面、样式、MorePage入口) - 电商模块取消生成功能(任务追踪/取消按钮/中止逻辑) - 视频服务图片上传支持 Blob/dataURL/远程URL 多种来源 优化: - 电商图片上传修复本地 blob 预览图缺少原始文件的问题 - 视频规划管线错误信息改进 - 生成流程中多处增加中止检查点
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
|
||||
|
||||
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
||||
|
||||
interface DialogItem {
|
||||
id: number;
|
||||
style: DialogStyle;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
color: string;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
const dialogStyles: Array<{
|
||||
key: DialogStyle;
|
||||
label: string;
|
||||
description: string;
|
||||
swatchClass: string;
|
||||
}> = [
|
||||
{ key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" },
|
||||
{ key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" },
|
||||
{ key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" },
|
||||
{ key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" },
|
||||
];
|
||||
|
||||
const textColorOptions = [
|
||||
{ value: "#ffffff", label: "白色" },
|
||||
{ value: "#111827", label: "黑色" },
|
||||
{ value: "#ef4444", label: "红色" },
|
||||
{ value: "#f59e0b", label: "黄色" },
|
||||
{ value: "#165dff", label: "蓝色" },
|
||||
{ value: "#00ff88", label: "绿色" },
|
||||
];
|
||||
|
||||
function DialogGeneratorPage() {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const nextIdRef = useRef(0);
|
||||
const [backgroundUrl, setBackgroundUrl] = useState("");
|
||||
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
|
||||
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
|
||||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||||
|
||||
const handleFile = useCallback((file?: File | null) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
setBackgroundUrl(reader.result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const addDialog = useCallback((style: DialogStyle) => {
|
||||
nextIdRef.current += 1;
|
||||
const id = nextIdRef.current;
|
||||
setDialogs((current) => [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
style,
|
||||
x: 30 + (id * 25) % 200,
|
||||
y: 30 + (id * 20) % 150,
|
||||
text: "",
|
||||
color: selectedTextColor,
|
||||
confirmed: false,
|
||||
},
|
||||
]);
|
||||
}, [selectedTextColor]);
|
||||
|
||||
const updateDialog = useCallback((id: number, patch: Partial<DialogItem>) => {
|
||||
setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
}, []);
|
||||
|
||||
const deleteDialog = useCallback((id: number) => {
|
||||
setDialogs((current) => current.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const startDrag = useCallback((id: number, clientX: number, clientY: number) => {
|
||||
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${id}"]`);
|
||||
if (!dialogEl) return;
|
||||
const rect = dialogEl.getBoundingClientRect();
|
||||
dragRef.current = {
|
||||
id,
|
||||
offsetX: clientX - rect.left,
|
||||
offsetY: clientY - rect.top,
|
||||
};
|
||||
setActiveDragId(id);
|
||||
}, []);
|
||||
|
||||
const moveDrag = useCallback((clientX: number, clientY: number) => {
|
||||
const drag = dragRef.current;
|
||||
const preview = previewRef.current;
|
||||
if (!drag || !preview) return;
|
||||
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${drag.id}"]`);
|
||||
if (!dialogEl) return;
|
||||
|
||||
const bounds = preview.getBoundingClientRect();
|
||||
const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth));
|
||||
const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight));
|
||||
updateDialog(drag.id, { x: nextX, y: nextY });
|
||||
}, [updateDialog]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
setActiveDragId(null);
|
||||
}, []);
|
||||
|
||||
const handleCanvasMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
moveDrag(event.clientX, event.clientY);
|
||||
}, [moveDrag]);
|
||||
|
||||
const handleCanvasTouchMove = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
moveDrag(touch.clientX, touch.clientY);
|
||||
}, [moveDrag]);
|
||||
|
||||
return (
|
||||
<section className="dialog-generator-page page-motion">
|
||||
<div className="dialog-generator-shell">
|
||||
<aside className="dialog-generator-panel">
|
||||
<div className="dialog-generator-heading">
|
||||
<span className="dialog-generator-kicker">Interactive Dialog</span>
|
||||
<h1>交互式对话框生成器</h1>
|
||||
<p>上传背景图,在画面上添加可拖拽、可编辑的文字图层,用于图片标注、剧情分镜和互动内容设计。</p>
|
||||
</div>
|
||||
|
||||
<div className="dialog-generator-section">
|
||||
<h2>上传背景图片</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-generator-drop"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
handleFile(event.dataTransfer.files[0]);
|
||||
}}
|
||||
>
|
||||
<span className="dialog-generator-drop-icon">🖼</span>
|
||||
<strong>点击或拖拽图片到此处</strong>
|
||||
<small>支持 JPG、PNG、WEBP 格式</small>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(event) => handleFile(event.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dialog-generator-section">
|
||||
<h2>点击添加文字</h2>
|
||||
<p className="dialog-generator-hint">每点一次即在预览区新增一个可编辑文字图层。</p>
|
||||
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
|
||||
{textColorOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
|
||||
style={{ "--text-color": item.value } as CSSProperties}
|
||||
aria-checked={selectedTextColor === item.value}
|
||||
role="radio"
|
||||
onClick={() => setSelectedTextColor(item.value)}
|
||||
>
|
||||
<span />
|
||||
<strong>{item.label}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dialog-generator-style-list">
|
||||
{dialogStyles.map((item) => (
|
||||
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
|
||||
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
|
||||
<span>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
|
||||
清空全部文字
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="dialog-generator-preview-card">
|
||||
<div className="dialog-generator-preview-head">
|
||||
<div>
|
||||
<span>Preview</span>
|
||||
<h2>预览区域</h2>
|
||||
</div>
|
||||
<p>拖动文字定位,输入文字后点击确认,确认后只保留文字图层,双击可重新编辑。</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="dialog-generator-preview"
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
onTouchMove={handleCanvasTouchMove}
|
||||
onTouchEnd={endDrag}
|
||||
>
|
||||
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
|
||||
{!backgroundUrl ? (
|
||||
<div className="dialog-generator-empty">
|
||||
<span>🖼</span>
|
||||
<p>上传图片后开始编辑</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dialogs.map((dialog) => (
|
||||
<div
|
||||
key={dialog.id}
|
||||
data-dialog-id={dialog.id}
|
||||
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
|
||||
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
|
||||
onMouseDown={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("textarea,button")) return;
|
||||
startDrag(dialog.id, event.clientX, event.clientY);
|
||||
event.preventDefault();
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("textarea,button")) return;
|
||||
const touch = event.touches[0];
|
||||
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
|
||||
}}
|
||||
>
|
||||
{!dialog.confirmed ? (
|
||||
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
{dialog.confirmed ? (
|
||||
<div className="dialog-generator-text-display">{dialog.text}</div>
|
||||
) : (
|
||||
<textarea
|
||||
className="dialog-generator-text"
|
||||
rows={2}
|
||||
placeholder="输入文本..."
|
||||
value={dialog.text}
|
||||
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
{!dialog.confirmed ? (
|
||||
<div className="dialog-generator-bubble-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-generator-confirm"
|
||||
onClick={() => {
|
||||
if (dialog.text.trim()) {
|
||||
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
✓ 确认
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default DialogGeneratorPage;
|
||||
Reference in New Issue
Block a user