mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
- 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
524 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|