Sidebar.jsx 14.8 KB
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>
  );
}