Add native MoneyPrinter short video factory

This commit is contained in:
boos
2026-06-02 15:26:42 +08:00
parent 056e9fb651
commit b29c7ffa7a
6 changed files with 1226 additions and 0 deletions
+6
View File
@@ -39,6 +39,7 @@ const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
const Workspace = lazy(() => import('./pages/Workspace.js'));
const Workflows = lazy(() => import('./pages/Workflows.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
@@ -387,6 +388,7 @@ export const sidebarGroups = [
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
@@ -419,6 +421,7 @@ export const userSidebarGroups = [
label: '创作应用',
items: [
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
@@ -430,6 +433,7 @@ const topNavItems = [
{ label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' },
{ label: '短视频工厂', to: '/money-printer' },
{ label: '工作流', to: '/workflows' },
{ label: '视频助手', to: '/comfyui-agent' },
{ label: '工作空间', to: '/workspace' },
@@ -987,6 +991,7 @@ function AppShell() {
<Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} />
<Route path="/picture-agent" element={<PictureAgent />} />
<Route path="/money-printer" element={<MoneyPrinter />} />
<Route path="/digital-human" element={<DigitalHuman />} />
<Route path="/workflows" element={<Workflows />} />
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
@@ -1006,6 +1011,7 @@ function AppShell() {
<Route path="/user/usage" element={<UsageRecords />} />
<Route path="/user/announcements" element={<UserAnnouncements />} />
<Route path="/user/picture-agent" element={<PictureAgent />} />
<Route path="/user/money-printer" element={<MoneyPrinter />} />
<Route path="/user/digital-human" element={<DigitalHuman />} />
<Route path="/user/workflows" element={<Workflows />} />
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
+40
View File
@@ -1184,6 +1184,18 @@ export type DigitalHumanJobsResponse = {
jobs: DigitalHumanJob[];
};
export type MoneyPrinterJob = DigitalHumanJob;
export type MoneyPrinterJobResponse = {
success: boolean;
job: MoneyPrinterJob;
};
export type MoneyPrinterJobsResponse = {
success: boolean;
jobs: MoneyPrinterJob[];
};
export const api = {
// Sites
getSites: async () => {
@@ -2081,6 +2093,20 @@ export const api = {
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () =>
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
createMoneyPrinterJob: (data: any) =>
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getMoneyPrinterJobs: (params?: { limit?: number }) =>
request<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
getMoneyPrinterJob: (id: string) =>
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
cancelMoneyPrinterJob: (id: string) =>
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearMoneyPrinterJobs: () =>
request<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
};
export const userApi = {
register: (data: {
@@ -2170,4 +2196,18 @@ export const userApi = {
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
createMoneyPrinterJob: (data: any) =>
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getMoneyPrinterJobs: (params?: { limit?: number }) =>
userRequest<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
getMoneyPrinterJob: (id: string) =>
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
cancelMoneyPrinterJob: (id: string) =>
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearMoneyPrinterJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
};
+193
View File
@@ -9250,6 +9250,199 @@ textarea {
font-size: 11px;
}
.money-printer-page {
max-width: 1440px;
margin: 0 auto;
}
.money-printer-layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(340px, 0.95fr);
gap: 16px;
align-items: start;
}
.money-printer-panel {
border: 1px solid var(--color-border-light);
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: 18px;
box-shadow: var(--shadow-card);
}
.money-printer-hero {
display: grid;
gap: 8px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg-card));
}
.money-printer-hero span {
color: var(--color-text-muted);
font-size: 12px;
font-weight: 720;
text-transform: uppercase;
}
.money-printer-hero strong {
color: var(--color-text-primary);
font-size: 18px;
line-height: 1.35;
}
.money-printer-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.money-printer-field {
display: grid;
gap: 6px;
margin-bottom: 12px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 650;
}
.money-printer-field input,
.money-printer-field select,
.money-printer-field textarea {
width: 100%;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text-primary);
border-radius: var(--radius-sm);
padding: 9px 11px;
font-size: 13px;
}
.money-printer-field textarea {
min-height: 112px;
resize: vertical;
}
.money-printer-section-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.money-printer-section-head h2 {
margin: 0 0 4px;
font-size: 14px;
}
.money-printer-section-head p {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.money-printer-job-list {
display: grid;
gap: 10px;
}
.money-printer-job-card {
display: grid;
gap: 10px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 66%, var(--color-bg-card));
padding: 12px;
}
.money-printer-job-head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.money-printer-job-head div {
display: grid;
gap: 3px;
min-width: 0;
}
.money-printer-job-head strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.money-printer-job-head span,
.money-printer-job-head em,
.money-printer-job-meta,
.money-printer-hook {
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.45;
}
.money-printer-job-head em {
flex: none;
font-style: normal;
}
.money-printer-progress {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 58%, transparent);
}
.money-printer-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--color-primary);
transition: width .25s ease;
}
.money-printer-job-meta,
.money-printer-assets,
.money-printer-job-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.money-printer-job-meta span,
.money-printer-job-meta a {
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg) 80%, var(--color-bg-card));
border: 1px solid var(--color-border-light);
padding: 3px 7px;
color: var(--color-text-muted);
text-decoration: none;
}
.money-printer-hook {
margin: 0;
padding: 9px 10px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 78%, var(--color-bg-card));
}
.money-printer-job-error {
color: var(--color-danger);
font-size: 12px;
line-height: 1.45;
}
.money-printer-assets a {
color: var(--color-primary);
font-size: 12px;
text-decoration: none;
}
.workspace-page .page-header {
margin-bottom: 16px;
}
+262
View File
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api, userApi, type MoneyPrinterJob } from '../api.js';
import type { SessionAudience } from '../authSession.js';
const STYLE_PRESETS = [
'爆款短视频,节奏紧凑,适合口播',
'商品种草,真实体验,强转化',
'知识科普,信息密度高,画面干净',
'电影感广告,镜头运动丰富,高级质感',
];
function terminal(status: string) {
return ['succeeded', 'failed', 'cancelled'].includes(status);
}
function statusLabel(status: string) {
if (status === 'succeeded') return '完成';
if (status === 'failed') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'running') return '生成中';
return '排队中';
}
function progress(job: MoneyPrinterJob) {
return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
}
function formatTime(value?: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('zh-CN', { hour12: false });
}
function jobAssets(job: MoneyPrinterJob) {
return Array.isArray(job.result?.assets) ? job.result.assets : [];
}
function planValue(job: MoneyPrinterJob, key: string) {
return job.result?.script?.plan?.[key] || '';
}
export default function MoneyPrinter() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
const [scriptModel, setScriptModel] = useState('gpt-5.4');
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
const [style, setStyle] = useState(STYLE_PRESETS[0]);
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
const [duration, setDuration] = useState(30);
const [ratio, setRatio] = useState('9:16');
const [resolution, setResolution] = useState('1080p');
const [requirements, setRequirements] = useState('画面要有科技感,突出统一网关、多模型、多模态和工作流能力。');
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const loadJobs = useCallback(async () => {
const payload = await client.getMoneyPrinterJobs({ limit: 30 });
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
}, [client]);
useEffect(() => {
loadJobs().catch((err) => setError(err?.message || '短视频任务加载失败'));
}, [loadJobs]);
useEffect(() => {
if (!jobs.some((job) => !terminal(job.status))) return;
const timer = window.setInterval(() => {
client.getMoneyPrinterJobs({ limit: 30 })
.then((payload) => setJobs(payload.jobs || []))
.catch(() => {});
}, 5000);
return () => window.clearInterval(timer);
}, [client, jobs]);
const submit = async () => {
if (!topic.trim() || submitting) return;
setSubmitting(true);
setError('');
try {
const payload = await client.createMoneyPrinterJob({
topic: topic.trim(),
scriptModel: scriptModel.trim(),
videoModel: videoModel.trim(),
style,
audience: audienceText,
duration,
ratio,
resolution,
requirements,
language: '中文',
});
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
} catch (err: any) {
setError(err?.message || '短视频任务创建失败');
} finally {
setSubmitting(false);
}
};
const cancel = async (id: string) => {
const payload = await client.cancelMoneyPrinterJob(id);
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
};
const clear = async () => {
await client.clearMoneyPrinterJobs();
await loadJobs();
};
const retry = (job: MoneyPrinterJob) => {
const request = job.result?.request || {};
setTopic(request.topic || job.result?.topic || topic);
setScriptModel(request.scriptModel || scriptModel);
setVideoModel(request.videoModel || request.model || videoModel);
setStyle(request.style || style);
setAudienceText(request.audience || audienceText);
setDuration(Number(request.duration || duration));
setRatio(request.ratio || ratio);
setResolution(request.resolution || resolution);
setRequirements(request.requirements || requirements);
};
return (
<div className="money-printer-page animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">短视频工厂</h1>
<p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BoosAPI 视频模型生产成片。</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={() => void loadJobs()}>刷新</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting || !topic.trim()}>
{submitting ? '提交中...' : '生成短视频'}
</button>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div className="money-printer-layout">
<section className="money-printer-panel">
<div className="money-printer-hero">
<span>MoneyPrinterTurbo inside</span>
<strong>一句主题,自动拆成脚本、分镜、视频提示词和成片任务。</strong>
</div>
<label className="money-printer-field">
<span>短视频主题</span>
<textarea value={topic} onChange={(event) => setTopic(event.target.value)} rows={4} />
</label>
<div className="money-printer-form-grid">
<label className="money-printer-field">
<span>脚本模型</span>
<input value={scriptModel} onChange={(event) => setScriptModel(event.target.value)} />
</label>
<label className="money-printer-field">
<span>视频模型</span>
<input value={videoModel} onChange={(event) => setVideoModel(event.target.value)} />
</label>
<label className="money-printer-field">
<span>时长</span>
<input type="number" min={5} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value || 30))} />
</label>
<label className="money-printer-field">
<span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
<option value="9:16">9:16 竖屏</option>
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select>
</label>
<label className="money-printer-field">
<span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
</select>
</label>
</div>
<label className="money-printer-field">
<span>风格</span>
<select value={style} onChange={(event) => setStyle(event.target.value)}>
{STYLE_PRESETS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="money-printer-field">
<span>目标受众</span>
<input value={audienceText} onChange={(event) => setAudienceText(event.target.value)} />
</label>
<label className="money-printer-field">
<span>额外要求</span>
<textarea value={requirements} onChange={(event) => setRequirements(event.target.value)} rows={4} />
</label>
</section>
<section className="money-printer-panel">
<div className="money-printer-section-head">
<div>
<h2>任务队列</h2>
<p>脚本、视频生成和工作空间归档都会在后台执行。</p>
</div>
<button type="button" className="btn btn-ghost" onClick={() => void clear()}>清空已完成</button>
</div>
<div className="money-printer-job-list">
{jobs.length === 0 ? (
<div className="empty-state" style={{ minHeight: 220 }}>
<div className="empty-state-title">暂无短视频任务</div>
<div className="empty-state-desc">提交主题后,系统会自动调用 BoosAPI 内置模型生成脚本和视频。</div>
</div>
) : jobs.map((job) => {
const percent = progress(job);
const assets = jobAssets(job);
return (
<article key={job.id} className="money-printer-job-card">
<div className="money-printer-job-head">
<div>
<strong>{job.title || '短视频工厂任务'}</strong>
<span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span>
</div>
<em>{statusLabel(job.status)}</em>
</div>
<div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div>
<div className="money-printer-job-meta">
<span>进度 {percent}%</span>
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
</div>
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
{job.error && <div className="money-printer-job-error">{job.error}</div>}
{assets.length > 0 && (
<div className="money-printer-assets">
{assets.map((asset: any) => (
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
成片 · {asset.publicId || asset.status}
</a>
))}
</div>
)}
<div className="money-printer-job-buttons">
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)}>取消</button>}
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>}
</div>
</article>
);
})}
</div>
</section>
</div>
</div>
);
}