Files
yingqing/core/frontend/src/components/free-create/generation-card.tsx
T
zycandClaude Sonnet 5 bf20de956c feat(billing): 计费商业化重构(积分制)+ 团队差异化调价
统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账,
视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。
Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类
按下单时的价格/汇率快照结算(中途改配不影响在途任务)。

BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale
迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0,
商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 10:04:00 +08:00

99 lines
4.4 KiB
TypeScript

// 自由创作·任务卡:生成中(shimmer+平滑进度) / 失败(中文错误+重试) / 完成(悬停播放+操作)。
import { useRef } from "react";
import { Download, Heart, RotateCcw, Trash2 } from "lucide-react";
import type { FreeVideoTask } from "../../types";
import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants";
function ratioStyle(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" };
}
export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavorite, onDelete, onDownload }: {
task: FreeVideoTask;
progress: number;
onOpen: () => void;
onRetry: () => void;
onToggleFavorite: () => void;
onDelete: () => void;
onDownload: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const inFlight = isInFlight(task.status);
const failed = task.status === "failed" || task.status === "cancelled";
const done = task.status === "succeeded" && !!task.video_url;
return (
<div className={`fc-card${done ? " done" : ""}`}>
<div
className="fc-card-media"
style={ratioStyle(task.aspect_ratio)}
role={done ? "button" : undefined}
tabIndex={done ? 0 : undefined}
onClick={done ? onOpen : undefined}
onKeyDown={done ? (event) => { if (event.key === "Enter") onOpen(); } : undefined}
onMouseEnter={() => { if (done) void videoRef.current?.play().catch(() => undefined); }}
onMouseLeave={() => { videoRef.current?.pause(); if (videoRef.current) videoRef.current.currentTime = 0; }}
>
{inFlight && (
<div className="fc-card-generating">
<span className="spinner" />
<div className="fc-progress"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
<span className="mono">{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
</div>
)}
{failed && (
<div className="fc-card-failed">
<span className="pill pill-l2 pill-err"><span className="dot" />失败</span>
<p>{task.error_message || "生成失败,请重试"}</p>
<div className="hstack">
<button type="button" className="btn btn-sm" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<RotateCcw size={13} /> 重试
</button>
<button type="button" className="btn btn-sm btn-ghost" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<Trash2 size={13} /> 删除
</button>
</div>
</div>
)}
{done && (
<>
<video
ref={videoRef}
src={task.video_url}
poster={task.thumbnail_url || undefined}
muted
loop
playsInline
preload="metadata"
/>
<div className="fc-card-hover">
<button type="button" className="fc-hover-btn" title="下载" onClick={(event) => { event.stopPropagation(); onDownload(); }}>
<Download size={14} />
</button>
<button type="button" className={`fc-hover-btn${task.is_favorited ? " fav" : ""}`} title={task.is_favorited ? "取消收藏" : "收藏"} onClick={(event) => { event.stopPropagation(); onToggleFavorite(); }}>
<Heart size={14} fill={task.is_favorited ? "currentColor" : "none"} />
</button>
<button type="button" className="fc-hover-btn danger" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<Trash2 size={14} />
</button>
</div>
</>
)}
{task.status === "succeeded" && !task.video_url && (
<div className="fc-card-failed">
<p>视频链接已失效{task.fallback_note ? `(${task.fallback_note})` : ""}</p>
</div>
)}
</div>
<div className="fc-card-meta">
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
<div className="fc-card-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
{task.status === "succeeded" && ` · ${Number(task.actual_cost || 0)} 积分`}
</div>
</div>
</div>
);
}