Files
cc-connect/web/src/pages/Cron/CronList.tsx
T
Claude 345cdd59ac feat: add skill management page with local/recommended tabs
- Add /skills route with local skills browser and recommended presets
- Backend: ListSkills/SkillDirs/AgentTypeName on Engine, skill presets API
- Provider preset cards: pin register/add buttons to card bottom
- Fix layout footer overlap by removing min-h-0 from content wrapper
- Replace logo with styled text brand, remove temporary local JSON override
- Add i18n translations for skill-related strings (en/zh/zh-TW/ja/es)

Made-with: Cursor
2026-04-19 08:57:45 +08:00

524 lines
20 KiB
TypeScript

import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock, Plus, Trash2, Terminal, MessageSquare, Pencil, Power, X,
ChevronDown,
} from 'lucide-react';
import { Card, Button, Badge, Modal, Input, Textarea, EmptyState } from '@/components/ui';
import { listCronJobs, createCronJob, updateCronJob, deleteCronJob, type CronJob } from '@/api/cron';
import { listProjects, type ProjectSummary } from '@/api/projects';
import { listSessions, type Session } from '@/api/sessions';
import { formatTime, cn } from '@/lib/utils';
const MODE_OPTIONS = ['bypassPermissions', 'acceptEdits', 'auto', 'plan', 'dontAsk'] as const;
/* ── Cron presets ── */
interface CronPreset {
label: string;
labelZh: string;
expr: string;
}
const PRESETS: CronPreset[] = [
{ label: 'Every minute', labelZh: '每分钟', expr: '* * * * *' },
{ label: 'Every 5 min', labelZh: '每 5 分钟', expr: '*/5 * * * *' },
{ label: 'Every 15 min', labelZh: '每 15 分钟', expr: '*/15 * * * *' },
{ label: 'Every 30 min', labelZh: '每 30 分钟', expr: '*/30 * * * *' },
{ label: 'Every hour', labelZh: '每小时', expr: '0 * * * *' },
{ label: 'Every 2 hours', labelZh: '每 2 小时', expr: '0 */2 * * *' },
{ label: 'Every 6 hours', labelZh: '每 6 小时', expr: '0 */6 * * *' },
{ label: 'Daily 6:00', labelZh: '每天 6:00', expr: '0 6 * * *' },
{ label: 'Daily 9:00', labelZh: '每天 9:00', expr: '0 9 * * *' },
{ label: 'Daily 12:00', labelZh: '每天 12:00', expr: '0 12 * * *' },
{ label: 'Daily 18:00', labelZh: '每天 18:00', expr: '0 18 * * *' },
{ label: 'Daily 22:00', labelZh: '每天 22:00', expr: '0 22 * * *' },
{ label: 'Weekdays 9:00', labelZh: '工作日 9:00',expr: '0 9 * * 1-5' },
{ label: 'Weekly Mon 9AM',labelZh: '每周一 9:00',expr: '0 9 * * 1' },
{ label: 'Monthly 1st', labelZh: '每月 1 号', expr: '0 0 1 * *' },
];
function describeCron(expr: string): string {
const preset = PRESETS.find(p => p.expr === expr);
if (preset) return preset.labelZh;
return expr;
}
/* ── Cron Schedule Picker (dropdown + custom input) ── */
const CUSTOM_VALUE = '__custom__';
function CronPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { i18n } = useTranslation();
const isZh = i18n.language?.startsWith('zh');
const isPreset = PRESETS.some(p => p.expr === value);
const [custom, setCustom] = useState(!isPreset && !!value);
const selectValue = custom ? CUSTOM_VALUE : value;
const handleSelect = (v: string) => {
if (v === CUSTOM_VALUE) {
setCustom(true);
} else {
setCustom(false);
onChange(v);
}
};
return (
<div className="space-y-2">
<div className="relative">
<select
value={selectValue}
onChange={e => handleSelect(e.target.value)}
className={cn(
'w-full px-3 py-2 text-sm rounded-lg transition-all duration-200 appearance-none pr-8',
'border border-gray-300/90 dark:border-white/[0.1]',
'bg-white/90 backdrop-blur-sm dark:bg-[rgba(0,0,0,0.45)]',
'text-gray-900 dark:text-white',
'focus:outline-none focus:ring-2 focus:ring-accent/45 focus:border-accent',
)}
>
<option value="" disabled>{isZh ? '选择执行频率' : 'Select schedule'}</option>
{PRESETS.map(p => (
<option key={p.expr} value={p.expr}>
{isZh ? p.labelZh : p.label} ({p.expr})
</option>
))}
<option value={CUSTOM_VALUE}>{isZh ? '✏ 自定义表达式' : '✏ Custom expression'}</option>
</select>
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
</div>
{custom && (
<Input
placeholder="0 9 * * 1-5"
value={value}
onChange={e => onChange(e.target.value)}
className="font-mono text-xs"
/>
)}
</div>
);
}
/* ── Select dropdown ── */
function Select({ label, value, onChange, options, placeholder }: {
label?: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}) {
return (
<div className="space-y-1.5">
{label && <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>}
<div className="relative">
<select
value={value}
onChange={e => onChange(e.target.value)}
className={cn(
'w-full px-3 py-2 text-sm rounded-lg transition-all duration-200 appearance-none pr-8',
'border border-gray-300/90 dark:border-white/[0.1]',
'bg-white/90 backdrop-blur-sm dark:bg-[rgba(0,0,0,0.45)]',
'text-gray-900 dark:text-white',
'focus:outline-none focus:ring-2 focus:ring-accent/45 focus:border-accent',
)}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
</div>
</div>
);
}
/* ── Toggle ── */
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<label className="inline-flex items-center gap-2 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'relative w-9 h-5 rounded-full transition-colors duration-200 shrink-0',
checked ? 'bg-accent' : 'bg-gray-300 dark:bg-gray-600',
)}
>
<span className={cn(
'block w-3.5 h-3.5 rounded-full bg-white shadow-sm transition-transform duration-200',
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
'mt-[3px]',
)} />
</button>
{label && <span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>}
</label>
);
}
/* ── Job form type ── */
interface JobForm {
project: string;
session_key: string;
cron_expr: string;
prompt: string;
exec: string;
description: string;
silent: boolean;
enabled: boolean;
mode: string;
_type: 'prompt' | 'exec';
}
const emptyForm: JobForm = {
project: '', session_key: '', cron_expr: '', prompt: '', exec: '',
description: '', silent: false, enabled: true, mode: '', _type: 'prompt',
};
/* ── Main page ── */
export default function CronList() {
const { t } = useTranslation();
const [jobs, setJobs] = useState<CronJob[]>([]);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
const [editJob, setEditJob] = useState<CronJob | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<JobForm>({ ...emptyForm });
const [saving, setSaving] = useState(false);
const [sessionKeys, setSessionKeys] = useState<string[]>([]);
const isEdit = !!editJob;
const projectOptions = useMemo(
() => projects.map(p => ({ value: p.name, label: p.name })),
[projects],
);
useEffect(() => {
if (!form.project) { setSessionKeys([]); return; }
let cancelled = false;
listSessions(form.project).then(data => {
if (cancelled) return;
const keys = new Set<string>();
for (const s of data.sessions || []) {
if (s.session_key) keys.add(s.session_key);
}
setSessionKeys([...keys]);
}).catch(() => { if (!cancelled) setSessionKeys([]); });
return () => { cancelled = true; };
}, [form.project]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [cronData, projData] = await Promise.all([listCronJobs(), listProjects()]);
setJobs(cronData.jobs || []);
setProjects(projData.projects || []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const handler = () => fetchData();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchData]);
const openAdd = () => {
setEditJob(null);
setForm({ ...emptyForm });
setShowForm(true);
};
const openEdit = (job: CronJob) => {
setEditJob(job);
setForm({
project: job.project,
session_key: job.session_key,
cron_expr: job.cron_expr,
prompt: job.prompt,
exec: job.exec,
description: job.description,
silent: !!job.silent,
enabled: job.enabled,
mode: job.mode || '',
_type: job.exec ? 'exec' : 'prompt',
});
setShowForm(true);
};
const handleSave = async () => {
setSaving(true);
const activePrompt = form._type === 'prompt' ? form.prompt : '';
const activeExec = form._type === 'exec' ? form.exec : '';
try {
if (isEdit && editJob) {
const updates: Record<string, any> = {};
if (form.cron_expr !== editJob.cron_expr) updates.cron_expr = form.cron_expr;
if (form.description !== editJob.description) updates.description = form.description;
if (activePrompt !== editJob.prompt) updates.prompt = activePrompt;
if (activeExec !== editJob.exec) updates.exec = activeExec;
if (form.project !== editJob.project) updates.project = form.project;
if (form.session_key !== editJob.session_key) updates.session_key = form.session_key;
if (form.silent !== !!editJob.silent) updates.silent = form.silent;
if (form.enabled !== editJob.enabled) updates.enabled = form.enabled;
if ((form.mode || '') !== (editJob.mode || '')) updates.mode = form.mode;
if (Object.keys(updates).length > 0) {
await updateCronJob(editJob.id, updates);
}
} else {
const body: any = { ...form, prompt: activePrompt, exec: activeExec };
delete body._type;
if (!body.prompt) delete body.prompt;
if (!body.exec) delete body.exec;
await createCronJob(body);
}
setShowForm(false);
fetchData();
} catch (e: any) {
alert(e.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm(t('common.confirmDelete'))) return;
await deleteCronJob(id);
fetchData();
};
const handleToggleEnabled = async (job: CronJob) => {
try {
await updateCronJob(job.id, { enabled: !job.enabled });
fetchData();
} catch (e: any) {
alert(e.message);
}
};
if (loading && jobs.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="space-y-4 animate-fade-in ">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('cron.title')}</h2>
<Button onClick={openAdd}><Plus size={16} /> {t('cron.add')}</Button>
</div>
{jobs.length === 0 ? (
<EmptyState message={t('cron.noJobs')} icon={Clock} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{jobs.map(job => (
<div
key={job.id}
onClick={() => openEdit(job)}
className={cn(
'relative p-4 rounded-xl border transition-all cursor-pointer group',
'bg-white dark:bg-white/[0.02]',
job.enabled
? 'border-gray-200/80 dark:border-white/[0.06] hover:border-accent/40 hover:shadow-md hover:shadow-accent/5'
: 'border-dashed border-gray-300/60 dark:border-white/[0.04] opacity-60 hover:opacity-80',
)}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
{job.prompt ? (
<MessageSquare size={14} className="text-blue-400 shrink-0" />
) : (
<Terminal size={14} className="text-amber-400 shrink-0" />
)}
<span className="font-medium text-sm text-gray-900 dark:text-white truncate">
{job.description || job.id}
</span>
</div>
{/* Schedule badge + mode badge */}
<div className="flex items-center gap-2 mb-3">
<span className="inline-flex items-center gap-1 text-[11px] font-mono bg-accent/10 text-accent px-2 py-0.5 rounded-md">
<Clock size={10} />
{describeCron(job.cron_expr)}
</span>
{job.silent && <Badge variant="default" className="text-[10px] px-1.5 py-0">silent</Badge>}
<Badge variant="default" className="text-[10px] px-1.5 py-0">{job.mode || t('cron.modeDefault')}</Badge>
</div>
{/* Info */}
<div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.project')}</span>
<span className="truncate">{job.project}</span>
</div>
{job.prompt && (
<div className="flex items-start gap-1.5">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.prompt')}</span>
<span className="line-clamp-2">{job.prompt}</span>
</div>
)}
{job.exec && (
<div className="flex items-center gap-1.5">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.exec')}</span>
<code className="truncate text-[11px]">{job.exec}</code>
</div>
)}
{job.last_run && (
<div className="flex items-center gap-1.5 pt-1 border-t border-gray-100 dark:border-white/[0.04] mt-1">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.lastRun')}</span>
<span>{formatTime(job.last_run)}</span>
</div>
)}
</div>
{job.last_error && (
<p className="text-[11px] text-red-500 mt-2 line-clamp-1">{job.last_error}</p>
)}
{/* Action buttons (top right) */}
<div
className="absolute top-3 right-3 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={e => e.stopPropagation()}
>
<button
onClick={() => handleToggleEnabled(job)}
className={cn(
'p-1.5 rounded-lg transition-colors',
job.enabled
? 'text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.06]',
)}
title={job.enabled ? 'Disable' : 'Enable'}
>
<Power size={14} />
</button>
<button
onClick={() => handleDelete(job.id)}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title={t('cron.delete')}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
{/* Add / Edit modal */}
<Modal
open={showForm}
onClose={() => setShowForm(false)}
title={isEdit ? t('cron.editJob') : t('cron.add')}
className="max-w-xl"
>
<div className="space-y-4">
<Select
label={t('cron.project')}
value={form.project}
onChange={v => setForm({ ...form, project: v })}
options={projectOptions}
placeholder={t('cron.selectProject')}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{t('cron.schedule')}
</label>
<CronPicker value={form.cron_expr} onChange={v => setForm({ ...form, cron_expr: v })} />
</div>
<Input
label={t('cron.description')}
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
placeholder={t('cron.descPlaceholder')}
/>
{/* Task type: prompt or exec, mutually exclusive */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{t('cron.taskType')}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setForm({ ...form, _type: 'prompt' as const })}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium transition-all',
form._type === 'prompt'
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
: 'bg-gray-50 dark:bg-white/[0.04] text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.08]',
)}
>
<MessageSquare size={14} /> {t('cron.prompt')}
</button>
<button
type="button"
onClick={() => setForm({ ...form, _type: 'exec' as const })}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium transition-all',
form._type === 'exec'
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400 ring-1 ring-amber-500/30'
: 'bg-gray-50 dark:bg-white/[0.04] text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.08]',
)}
>
<Terminal size={14} /> {t('cron.exec')}
</button>
</div>
</div>
{form._type === 'prompt' ? (
<Textarea
label={t('cron.prompt')}
value={form.prompt}
onChange={e => setForm({ ...form, prompt: e.target.value })}
rows={3}
placeholder={t('cron.promptPlaceholder')}
/>
) : (
<Input
label={t('cron.exec')}
value={form.exec}
onChange={e => setForm({ ...form, exec: e.target.value })}
placeholder="npm run report"
/>
)}
<Select
label={t('cron.sessionKey')}
value={form.session_key}
onChange={v => setForm({ ...form, session_key: v })}
options={sessionKeys.map(k => ({ value: k, label: k }))}
placeholder={t('cron.selectSessionKey')}
/>
<Select
label={t('cron.mode')}
value={form.mode}
onChange={v => setForm({ ...form, mode: v })}
options={MODE_OPTIONS.map(m => ({ value: m, label: m }))}
placeholder={t('cron.modeDefault')}
/>
<div className="flex items-center gap-6 pt-1">
<Toggle checked={form.enabled} onChange={v => setForm({ ...form, enabled: v })} label={t('cron.enabled')} />
<Toggle checked={form.silent} onChange={v => setForm({ ...form, silent: v })} label={t('cron.silent')} />
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-gray-100 dark:border-white/[0.06]">
<Button variant="secondary" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} loading={saving}>
{isEdit ? t('common.save') : t('cron.add')}
</Button>
</div>
</div>
</Modal>
</div>
);
}