zyc ffe92f7b15 Initial commit: 即梦视频生成平台
- web/: React + Vite + TypeScript 前端
- backend/: Django + DRF + SimpleJWT 后端
- prototype/: HTML 设计原型
- docs/: PRD 和设计评审文档
- test: 单元测试 + E2E 极限测试
2026-03-13 09:59:33 +08:00

258 lines
8.4 KiB
TypeScript

import { useEffect, useCallback } from 'react';
import { useInputBarStore } from '../store/inputBar';
import { useGenerationStore } from '../store/generation';
import { Dropdown } from './Dropdown';
import type { CreationMode, AspectRatio, Duration, GenerationType, ModelOption } from '../types';
import styles from './Toolbar.module.css';
const VideoIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
);
const ImageIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
const DiamondIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
const LightningIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
);
const StarIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
const SwapIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M7 12h10M17 12l-3-3M17 12l-3 3M7 12l3-3M7 12l3 3" />
</svg>
);
const MonitorIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
const ClockIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const ChevronDown = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9" />
</svg>
);
const generationTypeItems = [
{ label: '视频生成', value: 'video' as GenerationType, icon: <VideoIcon /> },
{ label: '图片生成', value: 'image' as GenerationType, icon: <ImageIcon /> },
];
const modelItems = [
{ label: 'Seedance 2.0', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
{ label: 'Seedance 2.0 Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
];
const modeItems = [
{ label: '全能参考', value: 'universal' as CreationMode, icon: <StarIcon /> },
{ label: '首尾帧', value: 'keyframe' as CreationMode, icon: <SwapIcon /> },
];
const ratioItems = [
{ label: '16:9', value: '16:9' as AspectRatio },
{ label: '9:16', value: '9:16' as AspectRatio },
{ label: '1:1', value: '1:1' as AspectRatio },
{ label: '21:9', value: '21:9' as AspectRatio },
{ label: '4:3', value: '4:3' as AspectRatio },
{ label: '3:4', value: '3:4' as AspectRatio },
];
const durationItems = [
{ label: '5s', value: '5' },
{ label: '10s', value: '10' },
{ label: '15s', value: '15' },
];
const modeLabels: Record<CreationMode, string> = {
universal: '全能参考',
keyframe: '首尾帧',
};
export function Toolbar() {
const generationType = useInputBarStore((s) => s.generationType);
const setGenerationType = useInputBarStore((s) => s.setGenerationType);
const model = useInputBarStore((s) => s.model);
const setModel = useInputBarStore((s) => s.setModel);
const mode = useInputBarStore((s) => s.mode);
const switchMode = useInputBarStore((s) => s.switchMode);
const aspectRatio = useInputBarStore((s) => s.aspectRatio);
const setAspectRatio = useInputBarStore((s) => s.setAspectRatio);
const duration = useInputBarStore((s) => s.duration);
const setDuration = useInputBarStore((s) => s.setDuration);
const isSubmittable = useInputBarStore((s) => s.canSubmit());
const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt);
const isKeyframe = mode === 'keyframe';
const addTask = useGenerationStore((s) => s.addTask);
const handleSend = useCallback(() => {
if (!isSubmittable) return;
addTask();
}, [isSubmittable, addTask]);
const handleInsertAt = useCallback(() => {
triggerInsertAt();
}, [triggerInsertAt]);
// Keyboard shortcut: Ctrl/Cmd + Enter
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
handleSend();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [handleSend]);
return (
<div className={styles.toolbar}>
{/* Generation type dropdown */}
<Dropdown
items={generationTypeItems}
value={generationType}
onSelect={(v) => setGenerationType(v as GenerationType)}
minWidth={150}
trigger={
<button className={`${styles.btn} ${styles.primary}`}>
<VideoIcon />
<span className={styles.label}>
{generationType === 'video' ? '视频生成' : '图片生成'}
</span>
<ChevronDown />
</button>
}
/>
{/* Model selector dropdown */}
<Dropdown
items={modelItems}
value={model}
onSelect={(v) => setModel(v as ModelOption)}
minWidth={190}
trigger={
<button className={styles.btn}>
<DiamondIcon />
<span className={styles.label}>
{model === 'seedance_2.0' ? 'Seedance 2.0' : 'Seedance 2.0 Fast'}
</span>
</button>
}
/>
{/* Mode selector */}
<Dropdown
items={modeItems}
value={mode}
onSelect={(v) => switchMode(v as CreationMode)}
minWidth={150}
trigger={
<button className={styles.btn}>
{isKeyframe ? <SwapIcon /> : <StarIcon />}
<span className={styles.label}>{modeLabels[mode]}</span>
<ChevronDown />
</button>
}
/>
{/* Aspect ratio */}
{isKeyframe ? (
<button className={styles.btn} style={{ opacity: 0.5, pointerEvents: 'none' }}>
<MonitorIcon />
<span className={styles.label}></span>
</button>
) : (
<Dropdown
items={ratioItems}
value={aspectRatio}
onSelect={(v) => setAspectRatio(v as AspectRatio)}
minWidth={100}
trigger={
<button className={styles.btn}>
<MonitorIcon />
<span className={styles.label}>{aspectRatio}</span>
</button>
}
/>
)}
{/* Duration */}
<Dropdown
items={durationItems}
value={String(duration)}
onSelect={(v) => setDuration(Number(v) as Duration)}
minWidth={100}
trigger={
<button className={styles.btn}>
<ClockIcon />
<span className={styles.label}>{duration}s</span>
</button>
}
/>
{/* @ button - universal mode only */}
{!isKeyframe && (
<button className={styles.btn} onClick={handleInsertAt}>
<span style={{ fontSize: 15, fontWeight: 600 }}>@</span>
</button>
)}
{/* Spacer */}
<div className={styles.spacer} />
{/* Credits indicator */}
<div className={styles.credits}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span>30</span>
</div>
{/* Send button */}
<button
className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`}
onClick={handleSend}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</div>
);
}