mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): rework Scheduled Tasks page with edit & delete
This commit is contained in:
@@ -194,6 +194,34 @@ const translations: Record<string, Record<string, string>> = {
|
||||
tasks_active: '运行中',
|
||||
tasks_paused: '已暂停',
|
||||
tasks_empty: '暂无定时任务',
|
||||
tasks_empty_guide: '在对话中告诉 Agent「每天 9 点提醒我…」即可创建定时任务',
|
||||
tasks_go_chat: '去对话创建',
|
||||
tasks_next_run: '下次执行',
|
||||
tasks_loading: '加载定时任务中...',
|
||||
task_edit_title: '编辑任务',
|
||||
task_name: '名称',
|
||||
task_enabled: '启用',
|
||||
task_schedule_type: '调度类型',
|
||||
task_type_cron: 'Cron 表达式',
|
||||
task_type_interval: '固定间隔',
|
||||
task_type_once: '单次执行',
|
||||
task_cron_expr: 'Cron 表达式',
|
||||
task_cron_hint: '如 0 9 * * * 表示每天 9 点',
|
||||
task_interval_seconds: '间隔(秒)',
|
||||
task_once_time: '执行时间',
|
||||
task_action_type: '动作类型',
|
||||
task_action_send: '发送消息',
|
||||
task_action_agent: 'Agent 任务',
|
||||
task_message_content: '消息内容',
|
||||
task_task_description: '任务描述',
|
||||
task_channel: '通道',
|
||||
task_receiver: '接收者',
|
||||
task_channel_locked: '通道与接收者创建后不可修改',
|
||||
task_save: '保存',
|
||||
task_cancel: '取消',
|
||||
task_delete: '删除',
|
||||
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
||||
task_save_error: '保存失败',
|
||||
logs_title: '日志',
|
||||
logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时',
|
||||
@@ -399,6 +427,34 @@ const translations: Record<string, Record<string, string>> = {
|
||||
tasks_active: 'Active',
|
||||
tasks_paused: 'Paused',
|
||||
tasks_empty: 'No scheduled tasks',
|
||||
tasks_empty_guide: 'Tell the agent "remind me every day at 9am…" in chat to create a scheduled task',
|
||||
tasks_go_chat: 'Create in chat',
|
||||
tasks_next_run: 'Next run',
|
||||
tasks_loading: 'Loading scheduled tasks...',
|
||||
task_edit_title: 'Edit Task',
|
||||
task_name: 'Name',
|
||||
task_enabled: 'Enabled',
|
||||
task_schedule_type: 'Schedule type',
|
||||
task_type_cron: 'Cron',
|
||||
task_type_interval: 'Interval',
|
||||
task_type_once: 'Once',
|
||||
task_cron_expr: 'Cron expression',
|
||||
task_cron_hint: 'e.g. 0 9 * * * runs daily at 9am',
|
||||
task_interval_seconds: 'Interval (seconds)',
|
||||
task_once_time: 'Run at',
|
||||
task_action_type: 'Action type',
|
||||
task_action_send: 'Send message',
|
||||
task_action_agent: 'Agent task',
|
||||
task_message_content: 'Message content',
|
||||
task_task_description: 'Task description',
|
||||
task_channel: 'Channel',
|
||||
task_receiver: 'Receiver',
|
||||
task_channel_locked: 'Channel and receiver cannot be changed after creation',
|
||||
task_save: 'Save',
|
||||
task_cancel: 'Cancel',
|
||||
task_delete: 'Delete',
|
||||
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
||||
task_save_error: 'Failed to save',
|
||||
logs_title: 'Logs',
|
||||
logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live',
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Loader2, Clock, CalendarClock } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import type { SchedulerTask } from '../types'
|
||||
import type { SchedulerTask, TaskSchedule, TaskAction } from '../types'
|
||||
import { Modal, Btn, Toggle, TextInput, Dropdown } from './settings/primitives'
|
||||
|
||||
interface TasksPageProps {
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
// Human-readable schedule summary, mirroring the web console.
|
||||
const scheduleSummary = (s: TaskSchedule): string => {
|
||||
if (s.type === 'cron') return s.expression || 'cron'
|
||||
if (s.type === 'interval') {
|
||||
const sec = s.seconds || 0
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const r = sec % 60
|
||||
const parts: string[] = []
|
||||
if (h) parts.push(`${h}h`)
|
||||
if (m) parts.push(`${m}m`)
|
||||
if (r || parts.length === 0) parts.push(`${r}s`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
return s.type || 'once'
|
||||
}
|
||||
|
||||
const formatNextRun = (iso?: string): string => {
|
||||
if (!iso) return '--'
|
||||
const d = new Date(iso)
|
||||
return isNaN(d.getTime()) ? '--' : d.toLocaleString()
|
||||
}
|
||||
|
||||
const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
|
||||
const navigate = useNavigate()
|
||||
const [tasks, setTasks] = useState<SchedulerTask[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
loadTasks()
|
||||
}, [baseUrl])
|
||||
const [editing, setEditing] = useState<SchedulerTask | null>(null)
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
@@ -23,68 +46,269 @@ const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
|
||||
setTasks(data || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks:', err)
|
||||
setTasks([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('tasks_title')}</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('tasks_desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
useEffect(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
void loadTasks()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseUrl])
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
||||
<i className="fas fa-clock text-rose-400 text-xl" />
|
||||
</div>
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium">Loading...</p>
|
||||
</div>
|
||||
) : tasks.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="fas fa-clock text-sm text-rose-400" />
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{task.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
task.enabled
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
|
||||
: 'bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400'
|
||||
}`}>
|
||||
{task.enabled ? t('tasks_active') : t('tasks_paused')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>
|
||||
{task.schedule?.type}:{' '}
|
||||
<code className="bg-slate-100 dark:bg-white/10 px-1.5 py-0.5 rounded font-mono">
|
||||
{task.schedule?.expression || task.schedule?.run_at || `${task.schedule?.seconds ?? ''}s`}
|
||||
</code>
|
||||
</span>
|
||||
{task.next_run_at && <span>Next: {task.next_run_at}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
||||
<i className="fas fa-clock text-rose-400 text-xl" />
|
||||
</div>
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium">{t('tasks_empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
const toggle = async (task: SchedulerTask, enabled: boolean) => {
|
||||
// Optimistic flip; revert on failure.
|
||||
setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled } : x)))
|
||||
try {
|
||||
await apiClient.toggleTask(task.id, enabled)
|
||||
} catch {
|
||||
setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled: !enabled } : x)))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 pt-5 pb-3 flex-shrink-0">
|
||||
<h2 className="text-xl font-bold text-content">{t('tasks_title')}</h2>
|
||||
<p className="text-xs text-content-tertiary mt-1">{t('tasks_desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||
<div className="max-w-3xl mx-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||
<Loader2 size={18} className="animate-spin mr-2" />
|
||||
{t('tasks_loading')}
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<CalendarClock size={32} className="mb-3 text-content-tertiary opacity-60" />
|
||||
<p className="text-content font-medium mb-1">{t('tasks_empty')}</p>
|
||||
<p className="text-sm text-content-tertiary max-w-sm mb-5">{t('tasks_empty_guide')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{t('tasks_go_chat')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{tasks.map((task) => {
|
||||
const content = task.action?.content || task.action?.task_description || ''
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => setEditing(task)}
|
||||
className={`rounded-card border border-default bg-surface p-4 cursor-pointer hover:border-strong transition-colors ${
|
||||
task.enabled ? '' : 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-accent' : 'bg-content-tertiary'}`} />
|
||||
<span className="font-medium text-sm text-content truncate">{task.name || task.id}</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs font-mono text-content-tertiary">{scheduleSummary(task.schedule)}</span>
|
||||
</div>
|
||||
{content && <p className="text-xs text-content-secondary mb-2 line-clamp-2">{content}</p>}
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-content-tertiary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Clock size={12} />
|
||||
<span>
|
||||
{t('tasks_next_run')}: {formatNextRun(task.next_run_at)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Toggle checked={task.enabled} onChange={(v) => toggle(task, v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<TaskEditModal
|
||||
task={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
void loadTasks()
|
||||
}}
|
||||
onDeleted={() => {
|
||||
setEditing(null)
|
||||
void loadTasks()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TaskEditModal: React.FC<{
|
||||
task: SchedulerTask
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onDeleted: () => void
|
||||
}> = ({ task, onClose, onSaved, onDeleted }) => {
|
||||
const [name, setName] = useState(task.name || '')
|
||||
const [enabled, setEnabled] = useState(task.enabled)
|
||||
const [schedType, setSchedType] = useState<TaskSchedule['type']>(task.schedule.type || 'cron')
|
||||
const [cron, setCron] = useState(task.schedule.expression || '')
|
||||
const [interval, setIntervalVal] = useState(task.schedule.seconds ? String(task.schedule.seconds) : '')
|
||||
const [runAt, setRunAt] = useState(task.schedule.run_at ? task.schedule.run_at.slice(0, 16) : '')
|
||||
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
||||
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const buildSchedule = (): TaskSchedule => {
|
||||
if (schedType === 'cron') return { type: 'cron', expression: cron.trim() }
|
||||
if (schedType === 'interval') return { type: 'interval', seconds: Number(interval) || 0 }
|
||||
return { type: 'once', run_at: runAt }
|
||||
}
|
||||
|
||||
const buildAction = (): TaskAction => {
|
||||
const a: TaskAction = { ...task.action, type: actionType }
|
||||
if (actionType === 'send_message') a.content = content
|
||||
else a.task_description = content
|
||||
return a
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await apiClient.updateTask(task.id, {
|
||||
name: name.trim(),
|
||||
enabled,
|
||||
schedule: buildSchedule(),
|
||||
action: buildAction(),
|
||||
})
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('task_save_error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm(t('task_delete_confirm'))) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiClient.deleteTask(task.id)
|
||||
onDeleted()
|
||||
} catch {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={t('task_edit_title')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
||||
{t('task_delete')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t('task_cancel')}
|
||||
</Btn>
|
||||
<Btn variant="primary" onClick={save} disabled={saving}>
|
||||
{t('task_save')}
|
||||
</Btn>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('task_name')}>
|
||||
<TextInput value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-content-secondary">{t('task_enabled')}</span>
|
||||
<Toggle checked={enabled} onChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<Field label={t('task_schedule_type')}>
|
||||
<Dropdown
|
||||
value={schedType}
|
||||
onChange={(v) => setSchedType(v as TaskSchedule['type'])}
|
||||
options={[
|
||||
{ value: 'cron', label: t('task_type_cron') },
|
||||
{ value: 'interval', label: t('task_type_interval') },
|
||||
{ value: 'once', label: t('task_type_once') },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{schedType === 'cron' && (
|
||||
<Field label={t('task_cron_expr')} hint={t('task_cron_hint')}>
|
||||
<TextInput value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 9 * * *" className="font-mono" />
|
||||
</Field>
|
||||
)}
|
||||
{schedType === 'interval' && (
|
||||
<Field label={t('task_interval_seconds')}>
|
||||
<TextInput type="number" value={interval} onChange={(e) => setIntervalVal(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{schedType === 'once' && (
|
||||
<Field label={t('task_once_time')}>
|
||||
<TextInput type="datetime-local" value={runAt} onChange={(e) => setRunAt(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={t('task_action_type')}>
|
||||
<Dropdown
|
||||
value={actionType}
|
||||
onChange={(v) => setActionType(v as TaskAction['type'])}
|
||||
options={[
|
||||
{ value: 'send_message', label: t('task_action_send') },
|
||||
{ value: 'agent_task', label: t('task_action_agent') },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={actionType === 'send_message' ? t('task_message_content') : t('task_task_description')}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors resize-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Channel and receiver are channel-bound and read-only after creation. */}
|
||||
{(task.action.channel_type || task.action.receiver) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('task_channel')}>
|
||||
<TextInput value={task.action.channel_type || 'web'} disabled />
|
||||
</Field>
|
||||
<Field label={t('task_receiver')}>
|
||||
<TextInput value={task.action.receiver_name || task.action.receiver || '--'} disabled />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({ label, hint, children }) => (
|
||||
<div>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-content-tertiary mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default TasksPage
|
||||
|
||||
Reference in New Issue
Block a user