JobCard.jsx
9.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import React, { useState, useRef } from 'react';
import { Play, RotateCcw, ArrowUpToLine, Trash2, MoreVertical, Clock } from 'lucide-react';
import { formatTimeAgo, thumbUrl, h3FileUrl, getH3Base, getJobKind } from '../utils';
export default function JobCard({ job, isSelected, onSelect, onRerun, onPrioritize, onDelete }) {
const [isHovered, setIsHovered] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const videoRef = useRef(null);
const req = job.req || {};
const kind = getJobKind(req);
const isDone = job.status === 'done';
const isRunning = job.status === 'running';
const isQueued = job.status === 'queued';
const isFailed = job.status === 'failed';
const isCancelled = job.status === 'cancelled';
// 进度计算
const curStep = job.step || 0;
const totalSteps = job.total_steps || req.steps || 20;
const progressPct = totalSteps > 0 ? Math.min(100, Math.max(curStep > 0 ? 5 : 2, Math.round((curStep / totalSteps) * 100))) : 0;
// 悬停时自动微播放
const handleMouseEnter = () => {
setIsHovered(true);
if (isDone && videoRef.current) {
videoRef.current.currentTime = 0;
videoRef.current.play().catch(() => {});
}
};
const handleMouseLeave = () => {
setIsHovered(false);
setShowMenu(false);
if (isDone && videoRef.current) {
videoRef.current.pause();
}
};
// 缩略图地址
const videoFileUrl = isDone ? h3FileUrl(`${getH3Base()}/v1/videos/${job.id}/file`) : null;
const coverImg = req.first_frame_url || (req.reference_image_urls || [])[0];
return (
<div
onClick={() => onSelect(job.id)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`group relative p-3 rounded-2xl border transition-all duration-200 cursor-pointer flex flex-col gap-2 ${
isSelected
? 'bg-emerald-50/80 border-emerald-500 ring-1 ring-emerald-500/40 shadow-xs'
: 'bg-white border-slate-200/90 hover:bg-slate-50/80 hover:border-slate-300 shadow-xs'
}`}
>
<div className="flex gap-3.5">
{/* 缩略图容器 (自适应比例) */}
<div className="relative w-24 h-32 shrink-0 rounded-xl overflow-hidden bg-slate-100 border border-slate-200 flex items-center justify-center">
{isDone && videoFileUrl ? (
<video
ref={videoRef}
src={videoFileUrl}
muted
loop
playsInline
preload="metadata"
className="w-full h-full object-cover"
/>
) : coverImg ? (
<img
src={thumbUrl(coverImg)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center text-slate-400 text-[11px] gap-1 p-2 text-center">
<span className="font-semibold text-slate-600">{kind.label}</span>
<span className="text-[10px] opacity-70">{job.partition || 'H3'}</span>
</div>
)}
{/* 状态悬浮微标 */}
{isDone && (
<div className="absolute top-1.5 left-1.5 p-1 bg-emerald-600/90 rounded-md backdrop-blur-md text-white shadow-xs">
<Play className="w-2.5 h-2.5 fill-current" />
</div>
)}
{isRunning && (
<div className="absolute inset-0 bg-emerald-950/20 backdrop-blur-[1px] flex flex-col items-center justify-center gap-1">
<span className="text-xs font-bold text-emerald-700 bg-white/90 px-1.5 py-0.5 rounded shadow-xs font-mono">
{progressPct}%
</span>
</div>
)}
{isQueued && (
<div className="absolute bottom-1.5 left-1.5 px-1.5 py-0.5 bg-blue-600/90 text-white text-[10px] rounded font-mono shadow-xs">
{job.queue_position ? `#${job.queue_position}` : '排队中'}
</div>
)}
</div>
{/* 右侧核心信息 */}
<div className="flex-1 min-w-0 flex flex-col justify-between py-0.5">
<div>
{/* 顶栏:短 ID 与状态胶囊 */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono font-bold text-slate-600 group-hover:text-slate-900 transition-colors">
#{job.id.substring(0, 8)}
</span>
{/* 状态 Pill */}
<div className="flex items-center gap-1.5">
{isDone && (
<span className="px-2 py-0.5 text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-full">
已完成
</span>
)}
{isRunning && (
<span className="px-2 py-0.5 text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-full flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
采样 {curStep}/{totalSteps}步
</span>
)}
{isQueued && (
<span className="px-2 py-0.5 text-[10px] font-semibold bg-slate-100 text-slate-700 border border-slate-200 rounded-full">
排队中
</span>
)}
{isFailed && (
<span className="px-2 py-0.5 text-[10px] font-semibold bg-rose-50 text-rose-700 border border-rose-200 rounded-full">
失败
</span>
)}
{isCancelled && (
<span className="px-2 py-0.5 text-[10px] font-semibold bg-slate-100 text-slate-500 border border-slate-200 rounded-full">
已取消
</span>
)}
</div>
</div>
{/* Prompt 摘要 */}
<p className="mt-1.5 text-xs text-slate-700 line-clamp-3 leading-relaxed break-words font-normal">
{req.prompt || '(无 prompt 描述)'}
</p>
</div>
{/* 底栏:规格、时间与快捷菜单 */}
<div className="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-500">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-600 font-mono text-[10px]">
{kind.label} · {req.width ? `${req.width}p` : ''}
</span>
<span className="flex items-center gap-1 text-slate-400">
<Clock className="w-3 h-3" />
<span>{formatTimeAgo(job.created_at)}</span>
</span>
</div>
{/* 三点操作菜单 */}
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 hover:bg-slate-100 rounded-md text-slate-400 hover:text-slate-700 transition-colors cursor-pointer"
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
{showMenu && (
<div className="absolute right-0 bottom-full mb-1.5 w-32 py-1 bg-white border border-slate-200 rounded-xl shadow-xl z-30 text-xs flex flex-col">
<button
onClick={() => { setShowMenu(false); onRerun(job); }}
className="px-3 py-1.5 flex items-center gap-2 text-slate-700 hover:bg-slate-50 transition-colors cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5 text-emerald-600" />
<span>复用重跑</span>
</button>
{isQueued && (
<button
onClick={() => { setShowMenu(false); onPrioritize(job.id); }}
className="px-3 py-1.5 flex items-center gap-2 text-slate-700 hover:bg-slate-50 transition-colors cursor-pointer"
>
<ArrowUpToLine className="w-3.5 h-3.5 text-indigo-600" />
<span>优先插队</span>
</button>
)}
<div className="my-1 border-t border-slate-100"></div>
<button
onClick={() => { setShowMenu(false); onDelete(job.id); }}
className="px-3 py-1.5 flex items-center gap-2 text-rose-600 hover:bg-rose-50 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>移入回收站</span>
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* 处于运行中时,卡片底部直接展现清晰的横向进度条 */}
{isRunning && (
<div className="w-full bg-slate-100 rounded-full h-1.5 overflow-hidden mt-1 border border-slate-200/60">
<div
className="bg-emerald-500 h-full transition-all duration-300 rounded-full"
style={{ width: `${progressPct}%` }}
/>
</div>
)}
</div>
);
}