Sidebar.jsx
14.8 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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
import React, { useState, useEffect } from 'react';
import {
Plus, Search, ArrowUpDown, Trash2, KeyRound,
ChevronLeft, ChevronRight, ChevronsRight, Server, RefreshCw
} from 'lucide-react';
import JobCard from './JobCard';
import { fetchJobs, prioritizeJob, deleteJob, restoreJob, resetGpu } from '../api';
import { getToken, getH3Base } from '../utils';
import { useToast } from './Toast';
import Modal from './Modal';
export default function Sidebar({
selectedId,
onSelectTask,
onCreateTask,
onRerunTask,
backendInfo,
onRefreshBackends,
onManageToken,
}) {
const { addToast } = useToast();
const [jobs, setJobs] = useState([]);
const [totalJobs, setTotalJobs] = useState(0);
const [page, setPage] = useState(1);
const [searchQ, setSearchQ] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [kindFilter, setKindFilter] = useState('');
const [sortOrder, setSortOrder] = useState('desc');
const [viewingTrash, setViewingTrash] = useState(false);
const [trashJobs, setTrashJobs] = useState([]);
const [loading, setLoading] = useState(false);
// 模态弹窗状态
const [confirmModal, setConfirmModal] = useState({ isOpen: false, title: '', content: '', isDanger: false, onConfirm: null });
// 刷新任务列表
const loadJobs = async (targetPage = page) => {
setLoading(true);
try {
if (viewingTrash) {
const data = await fetchJobs({ trash: true });
setTrashJobs(data.jobs || []);
} else {
const data = await fetchJobs({
page: targetPage,
limit: 20,
q: searchQ.trim(),
status: statusFilter,
kind: kindFilter,
sort: sortOrder,
});
setJobs(data.jobs || []);
setTotalJobs(data.total || 0);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadJobs(1);
setPage(1);
}, [searchQ, statusFilter, kindFilter, sortOrder, viewingTrash]);
// 定时每 5 秒轮询主列表
useEffect(() => {
const timer = setInterval(() => {
if (!viewingTrash) loadJobs(page);
}, 5000);
return () => clearInterval(timer);
}, [page, searchQ, statusFilter, kindFilter, sortOrder, viewingTrash]);
const handlePrioritize = async (id) => {
try {
const res = await prioritizeJob(id);
addToast(`任务 #${id.substring(0, 8)} 已插队到第 ${res.queue_position} 位`, 'success');
loadJobs(page);
} catch (e) {
addToast('插队失败: ' + e.message, 'error');
}
};
const handleDelete = async (id) => {
try {
await deleteJob(id);
addToast('已将任务移入回收站', 'info');
if (selectedId === id) onCreateTask();
loadJobs(page);
} catch (e) {
addToast('删除失败: ' + e.message, 'error');
}
};
const handleRestore = async (id) => {
try {
await restoreJob(id);
addToast('任务已从回收站恢复', 'success');
loadJobs();
} catch (e) {
addToast('恢复失败: ' + e.message, 'error');
}
};
const handleResetGpu = () => {
setConfirmModal({
isOpen: true,
title: '重置整个服务的 GPU 显存',
content: '确定要触发硬重启?\n这会打断当前正在执行的任务(如果有),并重启后端的 Ray 与推理进程以回收显存泄露。\n大约需要耗时 15-20 秒,重置完成前无法提交新任务。',
isDanger: true,
onConfirm: async () => {
setConfirmModal((prev) => ({ ...prev, isOpen: false }));
try {
await resetGpu();
addToast('已触发 GPU 重启,正在回收显存…', 'info');
if (onRefreshBackends) onRefreshBackends();
} catch (e) {
addToast('重置失败: ' + e.message, 'error');
}
},
});
};
const totalPages = Math.max(1, Math.ceil(totalJobs / 20));
// 跳到指定页(越界/非数字时夹到 1..totalPages)
const gotoPage = (v) => {
const n = Math.min(totalPages, Math.max(1, parseInt(v, 10) || 1));
setPage(n);
loadJobs(n);
};
return (
<aside className="w-full md:w-[390px] lg:w-[420px] shrink-0 border-r border-slate-200 bg-white flex flex-col h-full select-none shadow-xs">
{/* 顶部标题与新建按钮 */}
<div className="p-4 border-b border-slate-200 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-xs shadow-emerald-500/50" />
<h1 className="text-sm font-bold tracking-wide text-slate-800">H3 Console</h1>
</div>
<span className="text-[11px] font-mono text-slate-400">AI Video Studio</span>
</div>
<button
onClick={onCreateTask}
className={`w-full py-2.5 px-4 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition-all cursor-pointer shadow-xs ${
!viewingTrash && !selectedId
? 'bg-emerald-600 hover:bg-emerald-700 text-white shadow-emerald-600/30'
: 'bg-slate-50 hover:bg-slate-100 text-slate-700 border border-slate-200'
}`}
>
<Plus className="w-4 h-4 text-emerald-500" />
<span>新建生成任务</span>
</button>
</div>
{/* 搜索与多维过滤条 */}
{!viewingTrash ? (
<div className="p-3 border-b border-slate-200 flex flex-col gap-2 bg-slate-50/60">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
<input
type="text"
placeholder="搜索 Prompt 或任务 ID…"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/20 transition-all"
/>
</div>
<div className="flex items-center gap-1.5">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="flex-1 bg-white border border-slate-200 rounded-xl px-2 py-1.5 text-xs text-slate-700 outline-none focus:border-emerald-500 cursor-pointer"
>
<option value="">全部状态</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="done">已完成</option>
<option value="failed">失败</option>
<option value="cancelled">已取消</option>
</select>
<select
value={kindFilter}
onChange={(e) => setKindFilter(e.target.value)}
className="flex-1 bg-white border border-slate-200 rounded-xl px-2 py-1.5 text-xs text-slate-700 outline-none focus:border-emerald-500 cursor-pointer"
>
<option value="">全部类型</option>
<option value="text">文生视频</option>
<option value="fl2va">首尾帧</option>
<option value="ref2va">多参考图</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="p-1.5 bg-white hover:bg-slate-100 border border-slate-200 rounded-xl text-slate-600 transition-colors cursor-pointer shrink-0"
title="切换排序"
>
<ArrowUpDown className="w-3.5 h-3.5" />
</button>
</div>
</div>
) : (
<div className="p-3 border-b border-slate-200 flex items-center justify-between bg-slate-100/70">
<span className="text-xs font-semibold text-slate-700 flex items-center gap-1.5">
<Trash2 className="w-4 h-4 text-rose-500" />
<span>回收站视图</span>
</span>
<button
onClick={() => setViewingTrash(false)}
className="text-xs text-emerald-600 hover:underline cursor-pointer font-medium"
>
← 返回列表
</button>
</div>
)}
{/* 任务卡片列表 */}
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2.5 bg-slate-50/40">
{!viewingTrash ? (
jobs.length > 0 ? (
jobs.map((job) => (
<JobCard
key={job.id}
job={job}
isSelected={selectedId === job.id}
onSelect={onSelectTask}
onRerun={onRerunTask}
onPrioritize={handlePrioritize}
onDelete={handleDelete}
/>
))
) : (
<div className="py-12 text-center text-xs text-slate-400">
{loading ? '正在加载任务…' : '暂无符合筛选条件的任务'}
</div>
)
) : trashJobs.length > 0 ? (
trashJobs.map((job) => (
<div
key={job.id}
className="p-3 bg-white border border-slate-200 rounded-xl flex items-center justify-between text-xs shadow-xs"
>
<div className="min-w-0 flex-1 pr-2">
<span className="font-mono text-slate-500 font-semibold">#{job.id.substring(0, 8)}</span>
<p className="text-slate-700 truncate mt-0.5">{job.req?.prompt || '(无 prompt)'}</p>
</div>
<button
onClick={() => handleRestore(job.id)}
className="px-3 py-1 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg shrink-0 cursor-pointer transition-colors font-medium"
>
恢复
</button>
</div>
))
) : (
<div className="py-12 text-center text-xs text-slate-400">回收站是空的</div>
)}
</div>
{/* 分页控制栏 (非回收站) */}
{!viewingTrash && totalJobs > 0 && (
<div className="p-3 border-t border-slate-200 bg-white flex items-center justify-between text-xs text-slate-600">
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => gotoPage(page - 1)}
className="p-1 hover:bg-slate-100 disabled:opacity-30 rounded-lg cursor-pointer transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<input
key={page}
type="number"
min={1}
max={totalPages}
defaultValue={page}
onKeyDown={(e) => { if (e.key === 'Enter') { gotoPage(e.currentTarget.value); e.currentTarget.blur(); } }}
onBlur={(e) => { e.currentTarget.value = page; }}
title="输入页码,回车跳转"
className="w-12 px-1 py-0.5 text-center border border-slate-200 rounded-md font-medium focus:outline-none focus:border-emerald-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
<span className="font-medium">/ {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => gotoPage(page + 1)}
className="p-1 hover:bg-slate-100 disabled:opacity-30 rounded-lg cursor-pointer transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
disabled={page >= totalPages}
onClick={() => gotoPage(totalPages)}
title="最后一页"
className="p-1 hover:bg-slate-100 disabled:opacity-30 rounded-lg cursor-pointer transition-colors"
>
<ChevronsRight className="w-4 h-4" />
</button>
</div>
<span className="text-[11px] text-slate-400">共 {totalJobs} 条记录</span>
</div>
)}
{/* 底部:后端实例卡片与工具 */}
<div className="p-3 border-t border-slate-200 bg-slate-50/80 flex flex-col gap-2">
{/* 后端状态摘要 */}
{backendInfo && (
<div className="flex items-center justify-between text-[11px] text-slate-600 bg-white px-2.5 py-1.5 rounded-lg border border-slate-200 font-mono shadow-xs">
<div className="flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-emerald-600" />
<span className="font-semibold">GPU: {backendInfo.ready}/{backendInfo.total} 就绪</span>
</div>
<span className="text-slate-400">
排队 {backendInfo.queued || 0} | 运行 {backendInfo.running || 0}
</span>
</div>
)}
{/* 接入文档:同域名下由 server.py 直接吐 API.md / skill.md */}
<div className="flex items-center gap-3 text-[11px] text-slate-500">
<a href={`${getH3Base()}/docs/api.md`} target="_blank" rel="noreferrer" className="hover:text-slate-800 underline-offset-2 hover:underline">API 文档</a>
<a href={`${getH3Base()}/skill.md`} target="_blank" rel="noreferrer" className="hover:text-slate-800 underline-offset-2 hover:underline">skill.md</a>
</div>
{/* 底部功能栏:回收站 / 令牌设置 / 显存重置 */}
<div className="flex items-center justify-between pt-1 text-[11px]">
<button
onClick={() => setViewingTrash(!viewingTrash)}
className="flex items-center gap-1 text-slate-500 hover:text-slate-800 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>回收站</span>
</button>
<button
onClick={onManageToken}
className="flex items-center gap-1 text-slate-500 hover:text-slate-800 transition-colors cursor-pointer font-mono"
title="点击配置访问令牌"
>
<KeyRound className="w-3.5 h-3.5 text-emerald-600" />
<span>{getToken() ? 'Token:已配' : 'Token:未设'}</span>
</button>
<button
onClick={handleResetGpu}
className="flex items-center gap-1 text-amber-600 hover:text-amber-700 transition-colors cursor-pointer font-medium"
title="硬重启服务打断任务并回收显存"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>重置显存</span>
</button>
</div>
</div>
{/* 确认模态框 */}
<Modal
isOpen={confirmModal.isOpen}
title={confirmModal.title}
content={confirmModal.content}
isDanger={confirmModal.isDanger}
onConfirm={confirmModal.onConfirm}
onCancel={() => setConfirmModal((prev) => ({ ...prev, isOpen: false }))}
/>
</aside>
);
}