PicoBot/web/src/components/Sidebar/SchedulerJobList.tsx
oudecheng eaee29841d feat(web): 对齐 deepseek-harness 设计语言,重构前端视觉与三栏布局
- 设计 token 重映射为 DeepSeek 调色板(深浅双主题),正文改无衬线字体栈,全面去霓虹化
- 移除顶部 Header,控件迁入左栏(Logo/连接/通道/会话/tabs/底部操作区),左右栏支持拖拽调宽并持久化,折叠为图标 rail
- 对话区对齐 DeepSeek 聊天风:hero/docked 状态机、736px 居中列、用户右对齐柔和气泡、助手无气泡平铺、composer 卡片与反差发送键;子智能体点击导航完整保留
- 全功能区同步换肤:侧栏列表、右栏面板、ConfigPage、弹窗、选择器等
- fix: 修复 virtual-core 3.17.x 陈旧行高导致消息行重叠(宽度变化/滚动停止后强制 resizeItem 重测)
- fix: 修复 ConfigPage/Modal 引用不存在的 fadeIn/scaleIn keyframes 致入场动画失效
2026-08-14 19:15:45 +08:00

232 lines
8.8 KiB
TypeScript

import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react';
import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol';
interface SchedulerJobListProps {
jobs: SchedulerJobSummary[];
onRefresh: () => void;
onViewJob: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => void;
sessionId: string | null;
}
function kindLabel(kind: string): string {
const map: Record<string, string> = {
internal_event: '内部事件',
outbound_message: '外发消息',
agent_task: '智能体',
silent_agent_task: '静默智能体',
};
return map[kind] ?? kind;
}
function stateBadge(state: string): { label: string; color: string; pulse: boolean } {
switch (state) {
case 'running':
return {
label: '执行中',
color: 'bg-[var(--accent-amber)]/15 text-[var(--accent-amber)] border-[var(--accent-amber)]/30',
pulse: true,
};
case 'scheduled':
return {
label: '已调度',
color: 'bg-[var(--accent-amber)]/10 text-[var(--accent-amber)] border-[var(--accent-amber)]/20',
pulse: false,
};
case 'paused':
return {
label: '已暂停',
color: 'bg-[var(--overlay-dim)] text-[var(--text-muted)] border-[var(--border-color)]',
pulse: false,
};
case 'completed':
return {
label: '已完成',
color: 'bg-[var(--accent-green)]/15 text-[var(--accent-green)] border-[var(--accent-green)]/30',
pulse: false,
};
default:
return {
label: state,
color: 'bg-[var(--overlay-dim)] text-[var(--text-muted)] border-[var(--border-color)]',
pulse: false,
};
}
}
function formatTime(tsMillis: number | undefined): string {
if (tsMillis == null) return '--';
const d = new Date(tsMillis);
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function scheduleDescription(schedule: unknown): string {
if (!schedule || typeof schedule !== 'object') return '--';
const s = schedule as Record<string, unknown>;
switch (s.type) {
case 'cron':
return `Cron: ${s.expression}`;
case 'interval':
return `${s.seconds}`;
case 'delay':
return `延迟 ${s.seconds}`;
case 'at':
return `定时: ${s.timestamp}`;
default:
return JSON.stringify(s);
}
}
function lastStatusIcon(lastStatus: string | undefined) {
switch (lastStatus) {
case 'ok':
return <Check className="h-3 w-3 text-[var(--accent-green)]" />;
case 'error':
return <X className="h-3 w-3 text-[rgb(242,90,90)]" />;
default:
return <Minus className="h-3 w-3 text-[var(--text-muted)]" />;
}
}
export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: SchedulerJobListProps) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--border-color)] px-3 py-2.5">
<h2 className="font-semibold text-[var(--text-primary)] flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-[var(--accent-cyan)]" />
{jobs.length > 0 && (
<span className="text-xs text-[var(--text-muted)]">({jobs.length})</span>
)}
</h2>
<button
onClick={onRefresh}
className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] transition-all"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{/* Job List */}
<div className="flex-1 overflow-y-auto p-2">
{!sessionId ? (
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>...</p>
</div>
) : jobs.length === 0 ? (
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p></p>
</div>
) : (
<div className="space-y-1.5">
{jobs.map((job) => {
const badge = stateBadge(job.state);
const hasLookup = !!job.session_lookup;
return (
<button
key={job.id}
onClick={() => {
if (hasLookup && job.session_lookup) {
onViewJob(job.session_lookup, job.id, job.id);
}
}}
disabled={!hasLookup}
className={`w-full rounded-lg px-3 py-2.5 text-left transition-all border ${
hasLookup
? 'hover:bg-[var(--overlay-hover)] border-transparent hover:border-[var(--border-color)] cursor-pointer'
: 'border-transparent cursor-default opacity-70'
}`}
>
{/* Row 1: ID + kind + enabled */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs text-[var(--text-muted)] font-mono truncate max-w-[120px]">
{job.id}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--overlay-subtle)] text-[var(--text-secondary)] shrink-0">
{kindLabel(job.kind)}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-[var(--accent-green)]' : 'bg-[var(--text-muted)]'}`}
/>
{hasLookup && (
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
)}
</div>
</div>
{/* Row 2: State badge + last status */}
<div className="flex items-center gap-2 mb-1.5">
<span
className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}
>
{badge.pulse && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--accent-amber)] animate-pulse" />
)}
{badge.label}
</span>
<span className="inline-flex items-center gap-0.5 text-xs text-[var(--text-muted)]">
{lastStatusIcon(job.last_status)}
<span
className={
job.last_status === 'error'
? 'text-[rgb(242,90,90)]'
: job.last_status === 'ok'
? 'text-[var(--accent-green)]'
: 'text-[var(--text-muted)]'
}
>
{job.last_status === 'ok'
? '正常'
: job.last_status === 'error'
? '异常'
: job.last_status === 'skipped'
? '跳过'
: '--'}
</span>
</span>
</div>
{/* Row 3: Error message (if any) */}
{job.last_error && (
<div className="mb-1.5 text-xs text-[rgb(242,90,90)] truncate bg-[rgb(242,90,90)]/10 rounded px-1.5 py-0.5">
{job.last_error}
</div>
)}
{/* Row 4: Run count + schedule */}
<div className="flex items-center justify-between text-xs text-[var(--text-muted)] mb-1">
<span>
: {job.run_count}
{job.max_runs ? `/${job.max_runs}` : ''}
</span>
<span className="truncate max-w-[120px] text-[var(--text-muted)]">
{scheduleDescription(job.schedule)}
</span>
</div>
{/* Row 5: Times */}
<div className="flex items-center justify-between text-xs text-[var(--text-muted)]">
<span>: {formatTime(job.last_fired_at)}</span>
<span>: {formatTime(job.next_fire_at)}</span>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}