seaislee1209 b50ad147cd
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m0s
feat: v0.15.0 Seedance 2.0 Fast 模型上线 + 四档计费
- 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>
2026-03-30 20:33:02 +08:00

279 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = ''; }}
>
&#x27F2;
</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>
);
}