JobCard.jsx 9.15 KB
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>
  );
}