All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m0s
- Fast 模型:取消隐藏 Toolbar 选项,用户可选 AirDrama / AirDrama Fast - 四档计费:按模型+有无视频参考选单价(2.0: 46/28, Fast: 37/22 元/百万tokens) - QuotaConfig 新增 base_token_price_fast / base_token_price_fast_video 字段 - 系统设置页 4 个价格输入框(Seedance 2.0 + Fast 各两个) - 前端预估动态选价:根据当前选的模型和有无视频参考实时计算 - 推理接入点:Fast EP ep-m-20260329211530-68999 - 消费记录表格+CSV+详情弹窗加"模型"列 - 轮询间隔改为全程固定 5 秒 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
279 lines
9.6 KiB
TypeScript
279 lines
9.6 KiB
TypeScript
import { useEffect, useCallback, useMemo } from 'react';
|
||
import { useInputBarStore } from '../store/inputBar';
|
||
import { useGenerationStore } from '../store/generation';
|
||
import { useAuthStore } from '../store/auth';
|
||
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 /> },
|
||
];
|
||
|
||
const modelItems = [
|
||
{ label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
|
||
{ label: 'AirDrama 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: '21:9', value: '21:9' as AspectRatio },
|
||
{ label: '16:9', value: '16:9' as AspectRatio },
|
||
{ label: '4:3', value: '4:3' as AspectRatio },
|
||
{ label: '1:1', value: '1:1' as AspectRatio },
|
||
{ label: '3:4', value: '3:4' as AspectRatio },
|
||
{ label: '9:16', value: '9:16' as AspectRatio },
|
||
];
|
||
|
||
const keyframeRatioItems = [
|
||
...ratioItems,
|
||
];
|
||
|
||
const durationItems = Array.from({ length: 12 }, (_, i) => {
|
||
const v = i + 4;
|
||
return { label: `${v}s`, value: String(v) };
|
||
});
|
||
|
||
const RESOLUTION_MAP: Record<string, [number, number]> = {
|
||
'16:9': [1280, 720], '9:16': [720, 1280], '4:3': [1112, 834],
|
||
'1:1': [960, 960], '3:4': [834, 1112], '21:9': [1470, 630],
|
||
};
|
||
|
||
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 references = useInputBarStore((s) => s.references);
|
||
const team = useAuthStore((s) => s.team);
|
||
|
||
const addTask = useGenerationStore((s) => s.addTask);
|
||
|
||
const estimatedTokens = useMemo(() => {
|
||
const res = RESOLUTION_MAP[aspectRatio] || [1280, 720];
|
||
return Math.round((res[0] * res[1] * 24 * duration) / 1024);
|
||
}, [aspectRatio, duration]);
|
||
|
||
const estimatedCost = useMemo(() => {
|
||
const hasVideoRef = references.some((r) => r.type === 'video');
|
||
let price = team?.token_price || 0;
|
||
if (model === 'seedance_2.0_fast') {
|
||
price = hasVideoRef ? (team?.token_price_fast_video || 0) : (team?.token_price_fast || 0);
|
||
} else {
|
||
price = hasVideoRef ? (team?.token_price_video || 0) : (team?.token_price || 0);
|
||
}
|
||
return (estimatedTokens * price / 1000000).toFixed(2);
|
||
}, [estimatedTokens, model, references, team]);
|
||
|
||
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 — fixed to video */}
|
||
<button className={`${styles.btn} ${styles.primary}`}>
|
||
<VideoIcon />
|
||
<span className={styles.label}>视频生成</span>
|
||
</button>
|
||
|
||
{/* Model selector */}
|
||
<Dropdown
|
||
items={modelItems}
|
||
value={model}
|
||
onSelect={(v) => setModel(v as ModelOption)}
|
||
minWidth={160}
|
||
trigger={
|
||
<button className={styles.btn}>
|
||
{model === 'seedance_2.0_fast' ? <LightningIcon /> : <DiamondIcon />}
|
||
<span className={styles.label}>{model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'}</span>
|
||
<ChevronDown />
|
||
</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 */}
|
||
<Dropdown
|
||
items={isKeyframe ? keyframeRatioItems : 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 — push right group to the end */}
|
||
<div className={styles.spacer} />
|
||
|
||
{/* 全部清空 + 预估消耗:仅有内容时显示 */}
|
||
{isSubmittable && (
|
||
<span
|
||
onClick={() => useInputBarStore.getState().reset()}
|
||
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', cursor: 'pointer', transition: 'filter 0.15s', marginRight: 20, lineHeight: 1 }}
|
||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.filter = 'brightness(1.4)'; }}
|
||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.filter = ''; }}
|
||
>
|
||
⟲ 全部清空
|
||
</span>
|
||
)}
|
||
|
||
{/* Estimated cost */}
|
||
{isSubmittable && (team?.token_price || 0) > 0 && (
|
||
<span
|
||
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }}
|
||
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`}
|
||
>
|
||
预估消耗:{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
|
||
</span>
|
||
)}
|
||
|
||
{/* 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>
|
||
);
|
||
}
|