## 计费体系 - 团队额度从秒数改为金额(余额/冻结/月消费上限) - 用户限额从秒数改为次数(每日50次/每月1500次) - 新增 billing.py 工具模块(分辨率→像素映射 + token/费用计算) - 扣费流程:预扣制→冻结制(提交冻结预估金额,完成按实际tokens扣费,失败释放) - 允许小额透支(实际费用超预估时余额可变负) - 团队加价比例(markup_percentage),创建团队时必填 ## Token 追踪 - GenerationRecord 新增 tokens_consumed/cost_amount/base_cost_amount - 任务完成时从 Seedance API usage.total_tokens 获取精确值 - 生成页显示预估消耗(tokens + 金额),按团队售价计算 ## 管理后台 - 仪表盘新增利润分析板块(总收入/成本/利润/利润率 + 团队利润排行) - 消费记录新增 Tokens/售价/成本/利润列 - 团队管理:充值改为充金额,新增加价比例设置 - 系统设置:默认限额改为次数,新增基础token单价配置 ## Bug 修复 - 登录弹窗:拖选输入框内容不再误关闭(onClick→mousedown+mouseup) - 视频详情弹窗:遮罩层覆盖全视口(left:76px→0),admin/团管侧栏不再露出 ## UI 增强 - 图片大图预览:上传区和视频详情弹窗的图片支持点击查看大图(ImageLightbox) - 移除 adaptive 比例和智能时长选项,确保 token 预估可精确计算 - 视频详情弹窗显示实际消耗 tokens 和费用 ## 前端全量更新 - 所有页面秒数显示替换为金额(元)和次数(次) - TypeScript 类型全量更新 - API 调用参数同步更新 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
import { useRef, useState, useEffect, useCallback } from 'react';
|
|
import type { GenerationTask } from '../types';
|
|
import { useGenerationStore } from '../store/generation';
|
|
import { showToast } from './Toast';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
import styles from './GenerationCard.module.css';
|
|
|
|
const EditIcon = () => (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
);
|
|
|
|
const RefreshIcon = () => (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<polyline points="23 4 23 10 17 10" />
|
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
</svg>
|
|
);
|
|
|
|
const VideoIcon = () => (
|
|
<svg width="14" height="14" 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 DownloadIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="7 10 12 15 17 10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
);
|
|
|
|
interface Props {
|
|
task: GenerationTask;
|
|
onOpenDetail?: (task: GenerationTask) => void;
|
|
}
|
|
|
|
export function GenerationCard({ task, onOpenDetail }: Props) {
|
|
const removeTask = useGenerationStore((s) => s.removeTask);
|
|
const reEdit = useGenerationStore((s) => s.reEdit);
|
|
const regenerate = useGenerationStore((s) => s.regenerate);
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const moreRef = useRef<HTMLDivElement>(null);
|
|
const promptLineRef = useRef<HTMLDivElement>(null);
|
|
const promptWrapperRef = useRef<HTMLDivElement>(null);
|
|
const labelsRef = useRef<HTMLSpanElement>(null);
|
|
const [videoHover, setVideoHover] = useState(false);
|
|
const [promptHover, setPromptHover] = useState(false);
|
|
const [showMore, setShowMore] = useState(false);
|
|
const [truncatedPrompt, setTruncatedPrompt] = useState(task.prompt);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const [detailHover, setDetailHover] = useState(false);
|
|
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
|
const [promptAbove, setPromptAbove] = useState(false);
|
|
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
|
|
|
// Close more menu on click outside
|
|
useEffect(() => {
|
|
if (!showMore) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
|
|
setShowMore(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [showMore]);
|
|
|
|
// JS-level prompt truncation: ensure labels always visible at end of line 2
|
|
const computeTruncation = useCallback(() => {
|
|
const container = promptLineRef.current;
|
|
const labelsEl = labelsRef.current;
|
|
if (!container || !labelsEl) return;
|
|
|
|
const containerWidth = container.offsetWidth;
|
|
if (containerWidth === 0) return;
|
|
|
|
const style = getComputedStyle(container);
|
|
const font = `${style.fontSize} ${style.fontFamily}`;
|
|
|
|
// Measure labels width
|
|
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
|
|
|
|
// Two lines of available width, minus labels on line 2, with safety margin
|
|
const totalAvailable = containerWidth * 2 - labelsWidth - 24;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d')!;
|
|
ctx.font = font;
|
|
|
|
const prompt = task.prompt || '';
|
|
let totalWidth = 0;
|
|
let needsTruncation = false;
|
|
|
|
// Check if prompt fits
|
|
const fullWidth = ctx.measureText(prompt).width;
|
|
if (fullWidth <= totalAvailable) {
|
|
setTruncatedPrompt(prompt);
|
|
return;
|
|
}
|
|
|
|
// Truncate character by character
|
|
let truncated = '';
|
|
const ellipsisWidth = ctx.measureText('…').width;
|
|
for (const char of prompt) {
|
|
const charWidth = ctx.measureText(char).width;
|
|
if (totalWidth + charWidth + ellipsisWidth > totalAvailable) {
|
|
needsTruncation = true;
|
|
break;
|
|
}
|
|
truncated += char;
|
|
totalWidth += charWidth;
|
|
}
|
|
|
|
setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt);
|
|
}, [task.prompt]);
|
|
|
|
useEffect(() => {
|
|
computeTruncation();
|
|
|
|
const container = promptLineRef.current;
|
|
if (!container) return;
|
|
const ro = new ResizeObserver(() => computeTruncation());
|
|
ro.observe(container);
|
|
return () => ro.disconnect();
|
|
}, [computeTruncation]);
|
|
|
|
const isGenerating = task.status === 'generating';
|
|
const hasResult = task.status === 'completed' && !!task.resultUrl;
|
|
|
|
const handleVideoMouseEnter = () => {
|
|
setVideoHover(true);
|
|
const v = videoRef.current;
|
|
if (!v) return;
|
|
v.muted = false;
|
|
v.play().catch(() => {
|
|
// Browser blocks unmuted autoplay — fallback to muted
|
|
v.muted = true;
|
|
v.play().catch(() => {});
|
|
});
|
|
};
|
|
|
|
const handleVideoMouseLeave = () => {
|
|
setVideoHover(false);
|
|
const v = videoRef.current;
|
|
if (!v) return;
|
|
v.pause();
|
|
v.currentTime = 0;
|
|
};
|
|
|
|
const handleDownload = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!task.resultUrl) return;
|
|
try {
|
|
const res = await fetch(task.resultUrl);
|
|
const blob = await res.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = `airdrama-${task.id}.mp4`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
|
|
} catch {
|
|
const a = document.createElement('a');
|
|
a.href = task.resultUrl;
|
|
a.download = `airdrama-${task.id}.mp4`;
|
|
a.click();
|
|
}
|
|
};
|
|
|
|
const handleCopyPrompt = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(task.prompt).then(() => {
|
|
showToast('已复制');
|
|
});
|
|
};
|
|
|
|
const handleVideoClick = () => {
|
|
if (hasResult && onOpenDetail) {
|
|
onOpenDetail(task);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={styles.card}>
|
|
{/* Header: reference thumbnails + prompt + meta labels */}
|
|
<div className={styles.header}>
|
|
{/* Left: reference thumbnails */}
|
|
{task.references.length > 0 && (
|
|
<div className={styles.refColumn}>
|
|
{task.references.map((ref) => (
|
|
<div key={ref.id} className={styles.refThumb}>
|
|
{ref.type === 'video' ? (
|
|
<video src={ref.previewUrl} className={styles.refMedia} muted />
|
|
) : ref.type === 'audio' ? (
|
|
<div className={styles.audioThumb}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<path d="M9 18V5l12-2v13" />
|
|
<circle cx="6" cy="18" r="3" />
|
|
<circle cx="18" cy="16" r="3" />
|
|
</svg>
|
|
</div>
|
|
) : (
|
|
<img src={ref.previewUrl} alt={ref.label} className={styles.refMedia} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Right: prompt + inline labels */}
|
|
<div className={styles.headerRight}>
|
|
<div
|
|
ref={promptWrapperRef}
|
|
className={styles.promptWrapper}
|
|
onMouseLeave={() => setPromptHover(false)}
|
|
>
|
|
<div ref={promptLineRef} className={styles.promptLine}>
|
|
<span
|
|
onMouseEnter={() => {
|
|
const el = promptWrapperRef.current;
|
|
if (el) {
|
|
const rect = el.getBoundingClientRect();
|
|
setPromptAbove(rect.bottom + 350 > window.innerHeight);
|
|
}
|
|
setPromptHover(true);
|
|
}}
|
|
>{truncatedPrompt || '(无文字描述)'}</span>
|
|
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
|
|
<span className={styles.label}>
|
|
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
|
|
</span>
|
|
<span className={styles.label}>{task.duration}s</span>
|
|
<span className={styles.label}>{task.aspectRatio}</span>
|
|
<span
|
|
ref={detailLinkRef}
|
|
className={styles.detailLink}
|
|
onMouseEnter={() => {
|
|
const el = detailLinkRef.current;
|
|
if (el) {
|
|
const rect = el.getBoundingClientRect();
|
|
setDetailPos({
|
|
top: rect.bottom + 8,
|
|
right: window.innerWidth - rect.right,
|
|
});
|
|
}
|
|
setDetailHover(true);
|
|
}}
|
|
onMouseLeave={() => setDetailHover(false)}
|
|
>
|
|
详细信息 ⓘ
|
|
{detailHover && (
|
|
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
|
|
<div className={styles.detailRow}>
|
|
<span>视频比例</span><span>{task.aspectRatio}</span>
|
|
</div>
|
|
<div className={styles.detailRow}>
|
|
<span>时长</span><span>{task.duration}s</span>
|
|
</div>
|
|
<div className={styles.detailRow}>
|
|
<span>分辨率</span><span>720p</span>
|
|
</div>
|
|
<div className={styles.detailRow}>
|
|
<span>模型</span>
|
|
<span>{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}</span>
|
|
</div>
|
|
<div className={styles.detailRow}>
|
|
<span>生成时间</span>
|
|
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{promptHover && task.prompt && (
|
|
<div className={`${styles.promptTooltip} ${promptAbove ? styles.promptTooltipAbove : ''}`}>
|
|
<p className={styles.promptTooltipText}>{task.prompt}</p>
|
|
<button className={styles.copyBtn} onClick={handleCopyPrompt}>复制</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Video / result area */}
|
|
<div className={styles.content}>
|
|
{isGenerating ? (
|
|
<div className={styles.resultArea}>
|
|
<div className={styles.shimmerBg} />
|
|
<div className={styles.generating}>
|
|
<div className={styles.loadingSpinner} />
|
|
<span className={styles.loadingText}>视频生成中…</span>
|
|
<div className={styles.progressBar}>
|
|
<div
|
|
className={styles.progressFill}
|
|
style={{ width: `${task.progress}%` }}
|
|
/>
|
|
</div>
|
|
<span className={styles.progressText}>{Math.round(task.progress)}%</span>
|
|
</div>
|
|
</div>
|
|
) : task.status === 'failed' ? (
|
|
<p className={styles.errorText}>{task.errorMessage || '生成失败,请重试'}</p>
|
|
) : task.resultUrl ? (
|
|
<div
|
|
className={styles.resultArea}
|
|
onMouseEnter={handleVideoMouseEnter}
|
|
onMouseLeave={handleVideoMouseLeave}
|
|
onClick={handleVideoClick}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
src={task.resultUrl}
|
|
className={styles.resultMedia}
|
|
loop
|
|
preload="metadata"
|
|
/>
|
|
{videoHover && (
|
|
<div className={styles.videoOverlay}>
|
|
<button className={styles.downloadBtn} onClick={handleDownload}>
|
|
<DownloadIcon />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className={styles.resultArea}>
|
|
<div className={styles.resultPlaceholder}>
|
|
<VideoIcon />
|
|
<span>视频已生成</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom action buttons */}
|
|
{!isGenerating && (
|
|
<div className={styles.actions}>
|
|
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
|
|
<EditIcon /> <span>重新编辑</span>
|
|
</button>
|
|
<button className={styles.actionBtn} onClick={() => regenerate(task.id)}>
|
|
<RefreshIcon /> <span>再次生成</span>
|
|
</button>
|
|
<div className={styles.moreMenu} ref={moreRef}>
|
|
<button className={styles.moreBtn} onClick={() => setShowMore(!showMore)}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="5" cy="12" r="2" />
|
|
<circle cx="12" cy="12" r="2" />
|
|
<circle cx="19" cy="12" r="2" />
|
|
</svg>
|
|
</button>
|
|
{showMore && (
|
|
<div className={styles.moreDropdown}>
|
|
<button onClick={() => { setConfirmDelete(true); setShowMore(false); }}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
删除
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmModal
|
|
open={confirmDelete}
|
|
title="删除视频"
|
|
message="确定要删除这条生成记录吗?此操作不可撤销。"
|
|
confirmText="删除"
|
|
danger
|
|
onConfirm={() => { removeTask(task.id); setConfirmDelete(false); }}
|
|
onCancel={() => setConfirmDelete(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|