feat(i18n): add scheduled tasks (cron) translations for en, zh, ja
Add comprehensive i18n messages for the new scheduled tasks (cron) feature. Includes UI labels, actions, stats, validation, and example tasks for English, Simplified Chinese, and Japanese locales.
This commit is contained in:
@@ -51,6 +51,193 @@ export const messages: I18nMessages = {
|
||||
scripts: 'Scripts',
|
||||
settings: 'Settings',
|
||||
},
|
||||
cron: {
|
||||
title: 'Scheduled tasks',
|
||||
subtitle: 'Automate AI workflows with scheduled tasks',
|
||||
actions: {
|
||||
refresh: 'Refresh',
|
||||
newTask: 'New task',
|
||||
createTask: 'Create task',
|
||||
runNow: 'Run now',
|
||||
delete: 'Delete',
|
||||
},
|
||||
stats: {
|
||||
total: 'All',
|
||||
active: 'Active',
|
||||
paused: 'Paused',
|
||||
failed: 'Recent failures',
|
||||
},
|
||||
empty: {
|
||||
title: 'No scheduled tasks',
|
||||
description: 'Create a task and optionally prefill channel accounts and delivery targets.',
|
||||
},
|
||||
loading: 'Loading tasks...',
|
||||
warnings: {
|
||||
agentsLoadFailed: 'Failed to load Agents. Tasks will keep showing using the current snapshot. {error}',
|
||||
channelsLoadFailed: 'Failed to load channel accounts. Delivery info will be shown as placeholders. {error}',
|
||||
channelsCatalogFailed: 'Failed to load channel accounts. You can keep existing configuration. {error}',
|
||||
targetsLoadFailed: 'Failed to load target suggestions. You can still enter manually. {error}',
|
||||
noChannelsAvailable: 'No channel accounts are available. Delivery tasks cannot be created right now.',
|
||||
},
|
||||
feedback: {
|
||||
updated: 'Task updated.',
|
||||
created: 'Task created.',
|
||||
enabled: 'Task enabled.',
|
||||
paused: 'Task paused.',
|
||||
triggered: 'Task triggered.',
|
||||
deleted: 'Task deleted.',
|
||||
},
|
||||
confirmDelete: 'Delete “{name}”? This cannot be undone.',
|
||||
common: {
|
||||
unnamedJob: 'Untitled task',
|
||||
},
|
||||
card: {
|
||||
enabled: 'Enabled',
|
||||
paused: 'Paused',
|
||||
modeAnnounce: 'Run & send',
|
||||
modeNone: 'Run only',
|
||||
pauseTask: 'Pause task',
|
||||
enableTask: 'Enable task',
|
||||
agentLabel: 'Agent',
|
||||
deliveryLabel: 'Delivery',
|
||||
lastRun: 'Last run: {time}',
|
||||
nextRun: 'Next run: {time}',
|
||||
success: 'Success',
|
||||
failed: 'Failed',
|
||||
},
|
||||
dialog: {
|
||||
editTitle: 'Edit task',
|
||||
createTitle: 'Create task',
|
||||
subtitle: 'Arrange an automated AI task',
|
||||
nameLabel: 'Task name',
|
||||
namePlaceholder: 'e.g. Morning briefing',
|
||||
messageLabel: 'Message / prompt',
|
||||
messagePlaceholder: 'Describe what the task should do or announce',
|
||||
scheduleLabel: 'Schedule',
|
||||
customCronPlaceholder: 'Enter a cron expression, e.g. 0 9 * * *',
|
||||
nextRun: 'Next run: {time}',
|
||||
scheduleHint: 'Choose a preset or enter a custom cron expression',
|
||||
usePreset: 'Use presets',
|
||||
useCustom: 'Use custom cron',
|
||||
deliveryTitle: 'Delivery',
|
||||
deliverySubtitle: 'Keep results in NIANXX, or push the final result to an external channel.',
|
||||
deliveryOptions: {
|
||||
noneTitle: 'Keep in NIANXX',
|
||||
noneDesc: 'The task runs normally and results stay in-app.',
|
||||
announceTitle: 'Send to external channel',
|
||||
announceDesc: 'Deliver the final result to a configured messaging channel.',
|
||||
},
|
||||
channelLabel: 'Channel',
|
||||
channelEmpty: 'No channels available',
|
||||
accountLabel: 'Account',
|
||||
accountLoading: 'Loading channel accounts…',
|
||||
accountEmpty: 'No accounts for this channel',
|
||||
targetLabel: 'Target',
|
||||
targetPlaceholder: 'e.g. Ops group, room-ops, https://example.com/webhook',
|
||||
targetsLoading: 'Loading suggested targets for this channel account...',
|
||||
targetsReady: 'Suggested targets are ready. You can also type a group, user ID, or webhook.',
|
||||
targetsNone: 'No suggested targets found. You can still type a group, user ID, or webhook.',
|
||||
preview: 'Preview: {preview}',
|
||||
currentAccount: 'Current account: {account}',
|
||||
enableTitle: 'Enable now',
|
||||
enableDesc: 'Start running this task immediately after creation.',
|
||||
saving: 'Saving...',
|
||||
saveEdit: 'Save changes',
|
||||
create: 'Create task',
|
||||
},
|
||||
validation: {
|
||||
nameRequired: 'Please enter a task name.',
|
||||
messageRequired: 'Please enter the message/prompt.',
|
||||
scheduleRequired: 'Please provide a schedule.',
|
||||
channelRequired: 'Please select a delivery channel.',
|
||||
targetRequired: 'Please enter a delivery target (group, user ID, or webhook).',
|
||||
},
|
||||
delivery: {
|
||||
none: 'Run only (no delivery)',
|
||||
pending: 'Delivery pending configuration',
|
||||
targetPending: 'Target pending',
|
||||
},
|
||||
deliveryTarget: {
|
||||
savedCustom: 'Custom target saved in this job',
|
||||
},
|
||||
channels: {
|
||||
unnamed: 'Unnamed channel',
|
||||
mainAccount: 'Main account',
|
||||
douyin: 'Douyin',
|
||||
feishu: 'Feishu',
|
||||
fliggy: 'Fliggy',
|
||||
meituan: 'Meituan',
|
||||
qqbot: 'QQ Bot',
|
||||
telegram: 'Telegram',
|
||||
wechat: 'WeChat',
|
||||
wecom: 'WeCom',
|
||||
},
|
||||
agent: {
|
||||
main: 'Main Agent',
|
||||
workspace: 'Workspace {path}',
|
||||
dir: 'Dir {path}',
|
||||
sharedMainWorkspace: 'Shared main workspace',
|
||||
syncPending: 'Workspace pending sync',
|
||||
},
|
||||
weekdays: {
|
||||
sun: 'Sun',
|
||||
mon: 'Mon',
|
||||
tue: 'Tue',
|
||||
wed: 'Wed',
|
||||
thu: 'Thu',
|
||||
fri: 'Fri',
|
||||
sat: 'Sat',
|
||||
},
|
||||
time: {
|
||||
justNow: 'Just now',
|
||||
secondsAgo: '{count} seconds ago',
|
||||
minutesAgo: '{count} minutes ago',
|
||||
hoursAgo: '{count} hours ago',
|
||||
daysAgo: '{count} days ago',
|
||||
},
|
||||
schedule: {
|
||||
once: 'One-time · {time}',
|
||||
everySeconds: 'Every {count} seconds',
|
||||
everyMinutes: 'Every {count} minutes',
|
||||
everyHours: 'Every {count} hours',
|
||||
everyDays: 'Every {count} days',
|
||||
everyMinute: 'Every minute',
|
||||
everyNMinutes: 'Every {count} minutes',
|
||||
hourly: 'Hourly',
|
||||
weekly: 'Every {weekday} {time}',
|
||||
monthly: 'Every month on day {day} at {time}',
|
||||
daily: 'Daily {time}',
|
||||
presets: {
|
||||
'every-minute': 'Every minute',
|
||||
'every-5-minutes': 'Every 5 minutes',
|
||||
'every-15-minutes': 'Every 15 minutes',
|
||||
'every-hour': 'Hourly',
|
||||
'daily-9': 'Daily 09:00',
|
||||
'daily-18': 'Daily 18:00',
|
||||
'weekly-mon': 'Mon 09:00',
|
||||
'monthly-first': 'Monthly 1st 09:00',
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
jobs: {
|
||||
morningBriefing: {
|
||||
name: 'Morning briefing',
|
||||
message: 'Before opening, remind on-duty staff to check channel status and today’s room inventory.',
|
||||
target: 'Morning ops group',
|
||||
},
|
||||
channelCheck: {
|
||||
name: 'Channel health check',
|
||||
message: 'Poll Fliggy, Meituan, and Douyin channel status every 15 minutes.',
|
||||
},
|
||||
reviewSummary: {
|
||||
name: 'Reviews summary',
|
||||
message: 'Generate a review todo list every evening and send it to the operations team.',
|
||||
target: 'Ops review group',
|
||||
error: 'Channel request timed out. Evening summary did not complete. Check gateway and service status.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
modal: {
|
||||
title: 'Channel configuration',
|
||||
@@ -326,6 +513,193 @@ export const messages: I18nMessages = {
|
||||
scripts: '脚本',
|
||||
settings: '设置',
|
||||
},
|
||||
cron: {
|
||||
title: '定时任务',
|
||||
subtitle: '通过定时任务自动化 AI 工作流',
|
||||
actions: {
|
||||
refresh: '刷新',
|
||||
newTask: '新建任务',
|
||||
createTask: '创建任务',
|
||||
runNow: '立即执行',
|
||||
delete: '删除',
|
||||
},
|
||||
stats: {
|
||||
total: '全部任务',
|
||||
active: '启用中',
|
||||
paused: '已暂停',
|
||||
failed: '最近失败',
|
||||
},
|
||||
empty: {
|
||||
title: '还没有定时任务',
|
||||
description: '现在可以创建任务并预留好渠道账号和发送目标。',
|
||||
},
|
||||
loading: '正在加载任务...',
|
||||
warnings: {
|
||||
agentsLoadFailed: 'Agents 数据加载失败,任务仍会使用当前快照继续展示。{error}',
|
||||
channelsLoadFailed: '渠道账号加载失败,发送配置将以占位信息展示。{error}',
|
||||
channelsCatalogFailed: '渠道账号加载失败,仍可保留现有配置。{error}',
|
||||
targetsLoadFailed: '目标候选加载失败,仍可手工输入。{error}',
|
||||
noChannelsAvailable: '当前没有可用的渠道账号,暂时无法新建发送型任务。',
|
||||
},
|
||||
feedback: {
|
||||
updated: '定时任务已更新。',
|
||||
created: '定时任务已创建。',
|
||||
enabled: '任务已启用。',
|
||||
paused: '任务已暂停。',
|
||||
triggered: '任务已触发执行。',
|
||||
deleted: '任务已删除。',
|
||||
},
|
||||
confirmDelete: '确认删除“{name}”吗?删除后将无法恢复。',
|
||||
common: {
|
||||
unnamedJob: '未命名任务',
|
||||
},
|
||||
card: {
|
||||
enabled: '启用中',
|
||||
paused: '已暂停',
|
||||
modeAnnounce: '执行并发送',
|
||||
modeNone: '仅执行',
|
||||
pauseTask: '暂停任务',
|
||||
enableTask: '启用任务',
|
||||
agentLabel: 'Agent',
|
||||
deliveryLabel: '送达',
|
||||
lastRun: '最近执行:{time}',
|
||||
nextRun: '下次执行:{time}',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
},
|
||||
dialog: {
|
||||
editTitle: '编辑任务',
|
||||
createTitle: '创建任务',
|
||||
subtitle: '安排自动化的 AI 任务',
|
||||
nameLabel: '任务名称',
|
||||
namePlaceholder: '例如:晨间播报',
|
||||
messageLabel: '消息/提示词',
|
||||
messagePlaceholder: '描述任务要执行或广播的内容',
|
||||
scheduleLabel: '调度计划',
|
||||
customCronPlaceholder: '输入 Cron 表达式,例如 0 9 * * *',
|
||||
nextRun: '下次执行:{time}',
|
||||
scheduleHint: '选择预设或填写自定义 Cron 表达式',
|
||||
usePreset: '使用预设',
|
||||
useCustom: '使用自定义 Cron',
|
||||
deliveryTitle: '投递设置',
|
||||
deliverySubtitle: '选择仅在 NIANXX 内保留结果,或把最终结果推送到外部通道。',
|
||||
deliveryOptions: {
|
||||
noneTitle: '仅在NIANXX内',
|
||||
noneDesc: '任务照常运行,结果只保留在应用内。',
|
||||
announceTitle: '发送到外部通道',
|
||||
announceDesc: '将最终结果投递到已配置的消息通道。',
|
||||
},
|
||||
channelLabel: '发送渠道',
|
||||
channelEmpty: '暂无可用渠道',
|
||||
accountLabel: '账号',
|
||||
accountLoading: '正在加载渠道账号…',
|
||||
accountEmpty: '当前渠道没有账号列表',
|
||||
targetLabel: '目标',
|
||||
targetPlaceholder: '例如:值班群、room-ops、https://example.com/webhook',
|
||||
targetsLoading: '正在为当前渠道账号加载推荐目标...',
|
||||
targetsReady: '推荐目标已就绪,也可以继续手工输入群组、用户标识或 Webhook。',
|
||||
targetsNone: '暂未发现推荐目标,仍可手工输入群组、用户标识或 Webhook。',
|
||||
preview: '预览:{preview}',
|
||||
currentAccount: '当前账号:{account}',
|
||||
enableTitle: '立即启用',
|
||||
enableDesc: '创建后立即开始运行此任务。',
|
||||
saving: '保存中...',
|
||||
saveEdit: '保存修改',
|
||||
create: '创建任务',
|
||||
},
|
||||
validation: {
|
||||
nameRequired: '请填写任务名称。',
|
||||
messageRequired: '请填写提醒内容。',
|
||||
scheduleRequired: '请填写执行计划。',
|
||||
channelRequired: '请选择一个发送渠道。',
|
||||
targetRequired: '请填写发送目标,例如群组名、用户标识或 Webhook。',
|
||||
},
|
||||
delivery: {
|
||||
none: '仅执行任务,不额外发送',
|
||||
pending: '公告发送待配置',
|
||||
targetPending: '目标待填写',
|
||||
},
|
||||
deliveryTarget: {
|
||||
savedCustom: '当前任务里已经保存的自定义目标',
|
||||
},
|
||||
channels: {
|
||||
unnamed: '未命名渠道',
|
||||
mainAccount: '主账号',
|
||||
douyin: '抖音',
|
||||
feishu: '飞书',
|
||||
fliggy: '飞猪',
|
||||
meituan: '美团',
|
||||
qqbot: 'QQ 机器人',
|
||||
telegram: 'Telegram',
|
||||
wechat: '微信',
|
||||
wecom: '企业微信',
|
||||
},
|
||||
agent: {
|
||||
main: '主 Agent',
|
||||
workspace: '工作区 {path}',
|
||||
dir: '目录 {path}',
|
||||
sharedMainWorkspace: '共享主工作区',
|
||||
syncPending: '工作区待同步',
|
||||
},
|
||||
weekdays: {
|
||||
sun: '周日',
|
||||
mon: '周一',
|
||||
tue: '周二',
|
||||
wed: '周三',
|
||||
thu: '周四',
|
||||
fri: '周五',
|
||||
sat: '周六',
|
||||
},
|
||||
time: {
|
||||
justNow: '刚刚',
|
||||
secondsAgo: '{count} 秒前',
|
||||
minutesAgo: '{count} 分钟前',
|
||||
hoursAgo: '{count} 小时前',
|
||||
daysAgo: '{count} 天前',
|
||||
},
|
||||
schedule: {
|
||||
once: '单次执行 · {time}',
|
||||
everySeconds: '每 {count} 秒',
|
||||
everyMinutes: '每 {count} 分钟',
|
||||
everyHours: '每 {count} 小时',
|
||||
everyDays: '每 {count} 天',
|
||||
everyMinute: '每分钟',
|
||||
everyNMinutes: '每 {count} 分钟',
|
||||
hourly: '每小时整点',
|
||||
weekly: '每周 {weekday} {time}',
|
||||
monthly: '每月 {day} 日 {time}',
|
||||
daily: '每天 {time}',
|
||||
presets: {
|
||||
'every-minute': '每分钟',
|
||||
'every-5-minutes': '每 5 分钟',
|
||||
'every-15-minutes': '每 15 分钟',
|
||||
'every-hour': '每小时整点',
|
||||
'daily-9': '每天 09:00',
|
||||
'daily-18': '每天 18:00',
|
||||
'weekly-mon': '每周一 09:00',
|
||||
'monthly-first': '每月 1 日 09:00',
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
jobs: {
|
||||
morningBriefing: {
|
||||
name: '晨间营业播报',
|
||||
message: '每天开店前提醒值班同事检查渠道状态和当日房态。',
|
||||
target: '早班值守群',
|
||||
},
|
||||
channelCheck: {
|
||||
name: '渠道异常巡检',
|
||||
message: '每 15 分钟轮询飞猪、美团和抖音渠道在线状态。',
|
||||
},
|
||||
reviewSummary: {
|
||||
name: '评论汇总提醒',
|
||||
message: '每日晚间生成评论待处理清单并推送给运营。',
|
||||
target: '运营复盘群',
|
||||
error: '渠道响应超时,晚间汇总未完成,请检查网关与服务状态。',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
modal: {
|
||||
title: '渠道配置',
|
||||
@@ -601,6 +975,193 @@ export const messages: I18nMessages = {
|
||||
scripts: 'スクリプト',
|
||||
settings: '設定',
|
||||
},
|
||||
cron: {
|
||||
title: '定時タスク',
|
||||
subtitle: 'AI 工作流を自動化する定時タスク',
|
||||
actions: {
|
||||
refresh: '更新',
|
||||
newTask: '新規タスク',
|
||||
createTask: 'タスクを作成',
|
||||
runNow: '今すぐ実行',
|
||||
delete: '削除',
|
||||
},
|
||||
stats: {
|
||||
total: '全タスク',
|
||||
active: '有効',
|
||||
paused: '停止中',
|
||||
failed: '直近の失敗',
|
||||
},
|
||||
empty: {
|
||||
title: '定時タスクがありません',
|
||||
description: 'タスクを作成し、チャンネルアカウントと配信先を事前に設定できます。',
|
||||
},
|
||||
loading: 'タスクを読み込み中...',
|
||||
warnings: {
|
||||
agentsLoadFailed: 'Agents の読み込みに失敗しました。現在のスナップショットで表示を継続します。{error}',
|
||||
channelsLoadFailed: 'チャンネルアカウントの読み込みに失敗しました。配信情報はプレースホルダー表示になります。{error}',
|
||||
channelsCatalogFailed: 'チャンネルアカウントの読み込みに失敗しました。既存の設定は保持できます。{error}',
|
||||
targetsLoadFailed: '配信先候補の読み込みに失敗しました。手動入力は可能です。{error}',
|
||||
noChannelsAvailable: '利用可能なチャンネルアカウントがありません。現在は配信型タスクを作成できません。',
|
||||
},
|
||||
feedback: {
|
||||
updated: 'タスクを更新しました。',
|
||||
created: 'タスクを作成しました。',
|
||||
enabled: 'タスクを有効にしました。',
|
||||
paused: 'タスクを停止しました。',
|
||||
triggered: 'タスクを実行しました。',
|
||||
deleted: 'タスクを削除しました。',
|
||||
},
|
||||
confirmDelete: '「{name}」を削除しますか?この操作は元に戻せません。',
|
||||
common: {
|
||||
unnamedJob: '無題のタスク',
|
||||
},
|
||||
card: {
|
||||
enabled: '有効',
|
||||
paused: '停止',
|
||||
modeAnnounce: '実行して送信',
|
||||
modeNone: '実行のみ',
|
||||
pauseTask: 'タスクを停止',
|
||||
enableTask: 'タスクを有効化',
|
||||
agentLabel: 'Agent',
|
||||
deliveryLabel: '配信',
|
||||
lastRun: '前回実行:{time}',
|
||||
nextRun: '次回実行:{time}',
|
||||
success: '成功',
|
||||
failed: '失敗',
|
||||
},
|
||||
dialog: {
|
||||
editTitle: 'タスクを編集',
|
||||
createTitle: 'タスクを作成',
|
||||
subtitle: '自動化 AI タスクを設定します',
|
||||
nameLabel: 'タスク名',
|
||||
namePlaceholder: '例:朝のブリーフィング',
|
||||
messageLabel: 'メッセージ / プロンプト',
|
||||
messagePlaceholder: 'タスクが実行・通知する内容を入力します',
|
||||
scheduleLabel: 'スケジュール',
|
||||
customCronPlaceholder: 'Cron 式を入力(例:0 9 * * *)',
|
||||
nextRun: '次回実行:{time}',
|
||||
scheduleHint: 'プリセットを選ぶか、カスタム Cron 式を入力してください',
|
||||
usePreset: 'プリセットを使用',
|
||||
useCustom: 'カスタム Cron',
|
||||
deliveryTitle: '配信設定',
|
||||
deliverySubtitle: '結果を NIANXX に保持するか、外部チャンネルに送信します。',
|
||||
deliveryOptions: {
|
||||
noneTitle: 'NIANXX 内のみ',
|
||||
noneDesc: 'タスクは通常実行され、結果はアプリ内に保持されます。',
|
||||
announceTitle: '外部チャンネルへ送信',
|
||||
announceDesc: '最終結果を設定済みメッセージチャンネルへ配信します。',
|
||||
},
|
||||
channelLabel: 'チャンネル',
|
||||
channelEmpty: '利用可能なチャンネルはありません',
|
||||
accountLabel: 'アカウント',
|
||||
accountLoading: 'チャンネルアカウントを読み込み中…',
|
||||
accountEmpty: 'このチャンネルにはアカウントがありません',
|
||||
targetLabel: '送信先',
|
||||
targetPlaceholder: '例:運用グループ、room-ops、https://example.com/webhook',
|
||||
targetsLoading: 'このチャンネルアカウントの候補を読み込み中...',
|
||||
targetsReady: '候補が利用可能です。グループ名、ユーザー ID、Webhook も手動入力できます。',
|
||||
targetsNone: '候補が見つかりません。グループ名、ユーザー ID、Webhook を手動入力できます。',
|
||||
preview: 'プレビュー:{preview}',
|
||||
currentAccount: '現在のアカウント:{account}',
|
||||
enableTitle: 'すぐに有効化',
|
||||
enableDesc: '作成後すぐにこのタスクの実行を開始します。',
|
||||
saving: '保存中...',
|
||||
saveEdit: '変更を保存',
|
||||
create: 'タスクを作成',
|
||||
},
|
||||
validation: {
|
||||
nameRequired: 'タスク名を入力してください。',
|
||||
messageRequired: 'メッセージ/プロンプトを入力してください。',
|
||||
scheduleRequired: 'スケジュールを入力してください。',
|
||||
channelRequired: '配信チャンネルを選択してください。',
|
||||
targetRequired: '配信先(グループ、ユーザー ID、Webhook)を入力してください。',
|
||||
},
|
||||
delivery: {
|
||||
none: '実行のみ(配信なし)',
|
||||
pending: '配信設定が未完了です',
|
||||
targetPending: '送信先が未入力です',
|
||||
},
|
||||
deliveryTarget: {
|
||||
savedCustom: 'このタスクに保存されているカスタム送信先',
|
||||
},
|
||||
channels: {
|
||||
unnamed: '名称未設定のチャンネル',
|
||||
mainAccount: 'メインアカウント',
|
||||
douyin: 'Douyin',
|
||||
feishu: 'Feishu',
|
||||
fliggy: 'Fliggy',
|
||||
meituan: 'Meituan',
|
||||
qqbot: 'QQ Bot',
|
||||
telegram: 'Telegram',
|
||||
wechat: 'WeChat',
|
||||
wecom: 'WeCom',
|
||||
},
|
||||
agent: {
|
||||
main: 'メイン Agent',
|
||||
workspace: 'ワークスペース {path}',
|
||||
dir: 'ディレクトリ {path}',
|
||||
sharedMainWorkspace: 'メインワークスペースを共有',
|
||||
syncPending: 'ワークスペース同期待ち',
|
||||
},
|
||||
weekdays: {
|
||||
sun: '日',
|
||||
mon: '月',
|
||||
tue: '火',
|
||||
wed: '水',
|
||||
thu: '木',
|
||||
fri: '金',
|
||||
sat: '土',
|
||||
},
|
||||
time: {
|
||||
justNow: 'たった今',
|
||||
secondsAgo: '{count} 秒前',
|
||||
minutesAgo: '{count} 分前',
|
||||
hoursAgo: '{count} 時間前',
|
||||
daysAgo: '{count} 日前',
|
||||
},
|
||||
schedule: {
|
||||
once: '1回のみ · {time}',
|
||||
everySeconds: '{count} 秒ごと',
|
||||
everyMinutes: '{count} 分ごと',
|
||||
everyHours: '{count} 時間ごと',
|
||||
everyDays: '{count} 日ごと',
|
||||
everyMinute: '毎分',
|
||||
everyNMinutes: '{count} 分ごと',
|
||||
hourly: '毎時',
|
||||
weekly: '毎週 {weekday} {time}',
|
||||
monthly: '毎月 {day} 日 {time}',
|
||||
daily: '毎日 {time}',
|
||||
presets: {
|
||||
'every-minute': '毎分',
|
||||
'every-5-minutes': '5 分ごと',
|
||||
'every-15-minutes': '15 分ごと',
|
||||
'every-hour': '毎時',
|
||||
'daily-9': '毎日 09:00',
|
||||
'daily-18': '毎日 18:00',
|
||||
'weekly-mon': '毎週月曜 09:00',
|
||||
'monthly-first': '毎月 1 日 09:00',
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
jobs: {
|
||||
morningBriefing: {
|
||||
name: '朝のブリーフィング',
|
||||
message: '開店前に、当番スタッフへチャンネル状態と本日の在庫確認を通知します。',
|
||||
target: '朝番グループ',
|
||||
},
|
||||
channelCheck: {
|
||||
name: 'チャンネル監視',
|
||||
message: 'Fliggy、Meituan、Douyin のオンライン状態を 15 分ごとに確認します。',
|
||||
},
|
||||
reviewSummary: {
|
||||
name: 'レビュー要約',
|
||||
message: '毎晩、レビュー対応リストを生成して運用へ送信します。',
|
||||
target: '運用レビューグループ',
|
||||
error: 'チャンネル応答がタイムアウトしました。夜間要約が完了しませんでした。ゲートウェイとサービス状態を確認してください。',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
modal: {
|
||||
title: 'チャンネル設定',
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CronJobDelivery,
|
||||
} from '../../lib/cron-types';
|
||||
import { agentsStore, useAgentsStore } from '../../stores';
|
||||
import { useI18n, type LanguageCode } from '../../i18n';
|
||||
|
||||
type FeedbackTone = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
@@ -30,6 +31,8 @@ type FeedbackState = {
|
||||
message: string;
|
||||
} | null;
|
||||
|
||||
type Translate = (path: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
type DialogProps = {
|
||||
open: boolean;
|
||||
job: CronJob | null;
|
||||
@@ -63,7 +66,6 @@ type StatCardProps = {
|
||||
|
||||
type SchedulePreset = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
@@ -122,87 +124,89 @@ type DeliveryTargetOptionLike = Partial<ChannelTargetCatalogItem> & {
|
||||
};
|
||||
|
||||
const SCHEDULE_PRESETS: SchedulePreset[] = [
|
||||
{ key: 'every-minute', label: '每分钟', value: '* * * * *' },
|
||||
{ key: 'every-5-minutes', label: '每 5 分钟', value: '*/5 * * * *' },
|
||||
{ key: 'every-15-minutes', label: '每 15 分钟', value: '*/15 * * * *' },
|
||||
{ key: 'every-hour', label: '每小时整点', value: '0 * * * *' },
|
||||
{ key: 'daily-9', label: '每天 09:00', value: '0 9 * * *' },
|
||||
{ key: 'daily-18', label: '每天 18:00', value: '0 18 * * *' },
|
||||
{ key: 'weekly-mon', label: '每周一 09:00', value: '0 9 * * 1' },
|
||||
{ key: 'monthly-first', label: '每月 1 日 09:00', value: '0 9 1 * *' },
|
||||
{ key: 'every-minute', value: '* * * * *' },
|
||||
{ key: 'every-5-minutes', value: '*/5 * * * *' },
|
||||
{ key: 'every-15-minutes', value: '*/15 * * * *' },
|
||||
{ key: 'every-hour', value: '0 * * * *' },
|
||||
{ key: 'daily-9', value: '0 9 * * *' },
|
||||
{ key: 'daily-18', value: '0 18 * * *' },
|
||||
{ key: 'weekly-mon', value: '0 9 * * 1' },
|
||||
{ key: 'monthly-first', value: '0 9 1 * *' },
|
||||
];
|
||||
|
||||
const CHANNEL_DISPLAY_NAMES: Record<string, string> = {
|
||||
douyin: '抖音',
|
||||
feishu: '飞书',
|
||||
fliggy: '飞猪',
|
||||
meituan: '美团',
|
||||
qqbot: 'QQ 机器人',
|
||||
telegram: 'Telegram',
|
||||
wechat: '微信',
|
||||
wecom: '企业微信',
|
||||
const CHANNEL_NAME_KEYS: Record<string, string> = {
|
||||
douyin: 'cron.channels.douyin',
|
||||
feishu: 'cron.channels.feishu',
|
||||
fliggy: 'cron.channels.fliggy',
|
||||
meituan: 'cron.channels.meituan',
|
||||
qqbot: 'cron.channels.qqbot',
|
||||
telegram: 'cron.channels.telegram',
|
||||
wechat: 'cron.channels.wechat',
|
||||
wecom: 'cron.channels.wecom',
|
||||
};
|
||||
|
||||
const FALLBACK_CRON_JOBS: CronJob[] = [
|
||||
{
|
||||
id: 'cron-morning-briefing',
|
||||
name: '晨间营业播报',
|
||||
message: '每天开店前提醒值班同事检查渠道状态和当日房态。',
|
||||
schedule: '0 9 * * *',
|
||||
agentId: 'main',
|
||||
delivery: {
|
||||
mode: 'announce',
|
||||
channel: 'wecom',
|
||||
accountId: 'default',
|
||||
to: '早班值守群',
|
||||
function buildFallbackCronJobs(t: Translate): CronJob[] {
|
||||
return [
|
||||
{
|
||||
id: 'cron-morning-briefing',
|
||||
name: t('cron.fallback.jobs.morningBriefing.name'),
|
||||
message: t('cron.fallback.jobs.morningBriefing.message'),
|
||||
schedule: '0 9 * * *',
|
||||
agentId: 'main',
|
||||
delivery: {
|
||||
mode: 'announce',
|
||||
channel: 'wecom',
|
||||
accountId: 'default',
|
||||
to: t('cron.fallback.jobs.morningBriefing.target'),
|
||||
},
|
||||
enabled: true,
|
||||
createdAt: '2026-04-10T09:00:00.000Z',
|
||||
updatedAt: '2026-04-16T09:00:00.000Z',
|
||||
nextRun: new Date(Date.now() + 1000 * 60 * 60 * 7).toISOString(),
|
||||
lastRun: {
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 15).toISOString(),
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
createdAt: '2026-04-10T09:00:00.000Z',
|
||||
updatedAt: '2026-04-16T09:00:00.000Z',
|
||||
nextRun: new Date(Date.now() + 1000 * 60 * 60 * 7).toISOString(),
|
||||
lastRun: {
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 15).toISOString(),
|
||||
success: true,
|
||||
{
|
||||
id: 'cron-channel-check',
|
||||
name: t('cron.fallback.jobs.channelCheck.name'),
|
||||
message: t('cron.fallback.jobs.channelCheck.message'),
|
||||
schedule: '*/15 * * * *',
|
||||
agentId: 'main',
|
||||
delivery: { mode: 'none' },
|
||||
enabled: true,
|
||||
createdAt: '2026-04-11T08:30:00.000Z',
|
||||
updatedAt: '2026-04-16T08:30:00.000Z',
|
||||
nextRun: new Date(Date.now() + 1000 * 60 * 12).toISOString(),
|
||||
lastRun: {
|
||||
time: new Date(Date.now() - 1000 * 60 * 6).toISOString(),
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cron-channel-check',
|
||||
name: '渠道异常巡检',
|
||||
message: '每 15 分钟轮询飞猪、美团和抖音渠道在线状态。',
|
||||
schedule: '*/15 * * * *',
|
||||
agentId: 'main',
|
||||
delivery: { mode: 'none' },
|
||||
enabled: true,
|
||||
createdAt: '2026-04-11T08:30:00.000Z',
|
||||
updatedAt: '2026-04-16T08:30:00.000Z',
|
||||
nextRun: new Date(Date.now() + 1000 * 60 * 12).toISOString(),
|
||||
lastRun: {
|
||||
time: new Date(Date.now() - 1000 * 60 * 6).toISOString(),
|
||||
success: true,
|
||||
{
|
||||
id: 'cron-review-summary',
|
||||
name: t('cron.fallback.jobs.reviewSummary.name'),
|
||||
message: t('cron.fallback.jobs.reviewSummary.message'),
|
||||
schedule: '0 18 * * *',
|
||||
agentId: 'main',
|
||||
delivery: {
|
||||
mode: 'announce',
|
||||
channel: 'feishu',
|
||||
accountId: 'default',
|
||||
to: t('cron.fallback.jobs.reviewSummary.target'),
|
||||
},
|
||||
enabled: false,
|
||||
createdAt: '2026-04-09T18:00:00.000Z',
|
||||
updatedAt: '2026-04-15T18:00:00.000Z',
|
||||
lastRun: {
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
|
||||
success: false,
|
||||
error: t('cron.fallback.jobs.reviewSummary.error'),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cron-review-summary',
|
||||
name: '评论汇总提醒',
|
||||
message: '每日晚间生成评论待处理清单并推送给运营。',
|
||||
schedule: '0 18 * * *',
|
||||
agentId: 'main',
|
||||
delivery: {
|
||||
mode: 'announce',
|
||||
channel: 'feishu',
|
||||
accountId: 'default',
|
||||
to: '运营复盘群',
|
||||
},
|
||||
enabled: false,
|
||||
createdAt: '2026-04-09T18:00:00.000Z',
|
||||
updatedAt: '2026-04-15T18:00:00.000Z',
|
||||
lastRun: {
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
|
||||
success: false,
|
||||
error: '渠道响应超时,晚间汇总未完成,请检查网关与服务状态。',
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
}
|
||||
|
||||
function cn(...tokens: Array<string | false | null | undefined>): string {
|
||||
return tokens.filter(Boolean).join(' ');
|
||||
@@ -390,37 +394,38 @@ function getScheduleExpression(schedule: CronJob['schedule']): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseCronSchedule(schedule: CronJob['schedule']): string {
|
||||
function parseCronSchedule(schedule: CronJob['schedule'], t: Translate, locale: LanguageCode): string {
|
||||
if (typeof schedule !== 'string') {
|
||||
if (schedule.kind === 'at') {
|
||||
return `单次执行 · ${formatDateTime(schedule.at)}`;
|
||||
return t('cron.schedule.once', { time: formatDateTime(schedule.at, locale) });
|
||||
}
|
||||
|
||||
if (schedule.kind === 'every') {
|
||||
const everyMs = schedule.everyMs;
|
||||
if (everyMs < 60_000) return `每 ${Math.round(everyMs / 1000)} 秒`;
|
||||
if (everyMs < 3_600_000) return `每 ${Math.round(everyMs / 60_000)} 分钟`;
|
||||
if (everyMs < 86_400_000) return `每 ${Math.round(everyMs / 3_600_000)} 小时`;
|
||||
return `每 ${Math.round(everyMs / 86_400_000)} 天`;
|
||||
if (everyMs < 60_000) return t('cron.schedule.everySeconds', { count: Math.round(everyMs / 1000) });
|
||||
if (everyMs < 3_600_000) return t('cron.schedule.everyMinutes', { count: Math.round(everyMs / 60_000) });
|
||||
if (everyMs < 86_400_000) return t('cron.schedule.everyHours', { count: Math.round(everyMs / 3_600_000) });
|
||||
return t('cron.schedule.everyDays', { count: Math.round(everyMs / 86_400_000) });
|
||||
}
|
||||
|
||||
return schedule.expr;
|
||||
}
|
||||
|
||||
const preset = SCHEDULE_PRESETS.find((item) => item.value === schedule);
|
||||
if (preset) return preset.label;
|
||||
if (preset) return t(`cron.schedule.presets.${preset.key}`);
|
||||
|
||||
const parts = schedule.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return schedule;
|
||||
|
||||
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
||||
const time = `${hour}:${minute.padStart(2, '0')}`;
|
||||
|
||||
if (minute === '*' && hour === '*') return '每分钟';
|
||||
if (minute.startsWith('*/')) return `每 ${minute.slice(2)} 分钟`;
|
||||
if (hour === '*' && minute === '0') return '每小时整点';
|
||||
if (dayOfWeek !== '*' && dayOfMonth === '*') return `每周 ${formatWeekday(dayOfWeek)} ${hour}:${minute.padStart(2, '0')}`;
|
||||
if (dayOfMonth !== '*') return `每月 ${dayOfMonth} 日 ${hour}:${minute.padStart(2, '0')}`;
|
||||
if (hour !== '*') return `每天 ${hour}:${minute.padStart(2, '0')}`;
|
||||
if (minute === '*' && hour === '*') return t('cron.schedule.everyMinute');
|
||||
if (minute.startsWith('*/')) return t('cron.schedule.everyNMinutes', { count: minute.slice(2) });
|
||||
if (hour === '*' && minute === '0') return t('cron.schedule.hourly');
|
||||
if (dayOfWeek !== '*' && dayOfMonth === '*') return t('cron.schedule.weekly', { weekday: formatWeekday(dayOfWeek, t), time });
|
||||
if (dayOfMonth !== '*') return t('cron.schedule.monthly', { day: dayOfMonth, time });
|
||||
if (hour !== '*') return t('cron.schedule.daily', { time });
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@@ -481,29 +486,36 @@ function estimateNextRunDate(scheduleExpr: string): Date | null {
|
||||
|
||||
function estimateNextRun(scheduleExpr: string): string | null {
|
||||
const next = estimateNextRunDate(scheduleExpr);
|
||||
return next ? formatDateTime(next.toISOString()) : null;
|
||||
return next ? next.toISOString() : null;
|
||||
}
|
||||
|
||||
function formatWeekday(weekday: string): string {
|
||||
function formatWeekday(weekday: string, t: Translate): string {
|
||||
const weekdayMap: Record<string, string> = {
|
||||
'0': '周日',
|
||||
'1': '周一',
|
||||
'2': '周二',
|
||||
'3': '周三',
|
||||
'4': '周四',
|
||||
'5': '周五',
|
||||
'6': '周六',
|
||||
'7': '周日',
|
||||
'0': 'sun',
|
||||
'1': 'mon',
|
||||
'2': 'tue',
|
||||
'3': 'wed',
|
||||
'4': 'thu',
|
||||
'5': 'fri',
|
||||
'6': 'sat',
|
||||
'7': 'sun',
|
||||
};
|
||||
|
||||
return weekdayMap[weekday] ?? weekday;
|
||||
const key = weekdayMap[weekday];
|
||||
return key ? t(`cron.weekdays.${key}`) : weekday;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
function resolveDateLocale(locale: LanguageCode): string {
|
||||
if (locale === 'zh') return 'zh-CN';
|
||||
if (locale === 'ja') return 'ja-JP';
|
||||
return 'en-US';
|
||||
}
|
||||
|
||||
function formatDateTime(value: string, locale: LanguageCode): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
return date.toLocaleString(resolveDateLocale(locale), {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -511,7 +523,7 @@ function formatDateTime(value: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string): string {
|
||||
function formatRelativeTime(value: string, t: Translate, locale: LanguageCode): string {
|
||||
const target = new Date(value).getTime();
|
||||
if (Number.isNaN(target)) return value;
|
||||
|
||||
@@ -521,12 +533,12 @@ function formatRelativeTime(value: string): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 10) return '刚刚';
|
||||
if (seconds < 60) return `${seconds} 秒前`;
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
if (days < 30) return `${days} 天前`;
|
||||
return formatDateTime(value);
|
||||
if (seconds < 10) return t('cron.time.justNow');
|
||||
if (seconds < 60) return t('cron.time.secondsAgo', { count: seconds });
|
||||
if (minutes < 60) return t('cron.time.minutesAgo', { count: minutes });
|
||||
if (hours < 24) return t('cron.time.hoursAgo', { count: hours });
|
||||
if (days < 30) return t('cron.time.daysAgo', { count: days });
|
||||
return formatDateTime(value, locale);
|
||||
}
|
||||
|
||||
function normalizeCronScheduleValue(value: unknown): CronJob['schedule'] {
|
||||
@@ -580,7 +592,7 @@ function normalizeCronJob(value: unknown): CronJob | null {
|
||||
const id = getString(value.id);
|
||||
if (!id) return null;
|
||||
|
||||
const name = getString(value.name) || '未命名任务';
|
||||
const name = getString(value.name);
|
||||
const message = getString(value.message);
|
||||
const schedule = normalizeCronScheduleValue(value.schedule);
|
||||
const enabled = value.enabled !== false;
|
||||
@@ -887,6 +899,7 @@ function normalizeDeliveryTargetOptions(payload: unknown): ChannelTargetCatalogI
|
||||
function mergeDeliveryTargetOptions(
|
||||
options: ChannelTargetCatalogItem[],
|
||||
currentValue: string,
|
||||
t: Translate,
|
||||
): ChannelTargetCatalogItem[] {
|
||||
const trimmedValue = currentValue.trim();
|
||||
if (!trimmedValue) {
|
||||
@@ -901,7 +914,7 @@ function mergeDeliveryTargetOptions(
|
||||
{
|
||||
value: trimmedValue,
|
||||
label: trimmedValue,
|
||||
description: '当前任务里已经保存的自定义目标',
|
||||
description: t('cron.deliveryTarget.savedCustom'),
|
||||
source: 'fallback',
|
||||
},
|
||||
...options,
|
||||
@@ -929,10 +942,15 @@ function ensureChannelGroupSelection(
|
||||
];
|
||||
}
|
||||
|
||||
function getChannelDisplayName(channelType: string): string {
|
||||
function getChannelDisplayName(channelType: string, t: Translate): string {
|
||||
const normalized = normalizeChannelType(channelType);
|
||||
if (!normalized) return '未命名渠道';
|
||||
if (CHANNEL_DISPLAY_NAMES[normalized]) return CHANNEL_DISPLAY_NAMES[normalized];
|
||||
if (!normalized) return t('cron.channels.unnamed');
|
||||
|
||||
const key = CHANNEL_NAME_KEYS[normalized];
|
||||
if (key) {
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[-_]/)
|
||||
@@ -941,9 +959,9 @@ function getChannelDisplayName(channelType: string): string {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getDeliveryAccountDisplayName(account: CronDeliveryChannelAccount | undefined): string {
|
||||
if (!account) return '主账号';
|
||||
if (account.accountId === 'default' && (account.name === 'default' || !account.name.trim())) return '主账号';
|
||||
function getDeliveryAccountDisplayName(account: CronDeliveryChannelAccount | undefined, t: Translate): string {
|
||||
if (!account) return t('cron.channels.mainAccount');
|
||||
if (account.accountId === 'default' && (account.name === 'default' || !account.name.trim())) return t('cron.channels.mainAccount');
|
||||
return account.name;
|
||||
}
|
||||
|
||||
@@ -951,11 +969,12 @@ function getAgentLabel(
|
||||
agentId: string | null | undefined,
|
||||
agentsById: Map<string, AgentSummary>,
|
||||
defaultAgentId: string,
|
||||
t: Translate,
|
||||
): string {
|
||||
const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID);
|
||||
const resolvedAgent = agentsById.get(resolvedId);
|
||||
if (resolvedAgent?.name) return resolvedAgent.name;
|
||||
if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID)) return '主 Agent';
|
||||
if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID)) return t('cron.agent.main');
|
||||
return resolvedId;
|
||||
}
|
||||
|
||||
@@ -972,36 +991,38 @@ function getAgentDetail(
|
||||
agentId: string | null | undefined,
|
||||
agentsById: Map<string, AgentSummary>,
|
||||
defaultAgentId: string,
|
||||
t: Translate,
|
||||
): string {
|
||||
const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID);
|
||||
const resolvedAgent = agentsById.get(resolvedId);
|
||||
|
||||
if (resolvedAgent?.workspace) {
|
||||
return `工作区 ${formatPathTail(resolvedAgent.workspace)}`;
|
||||
return t('cron.agent.workspace', { path: formatPathTail(resolvedAgent.workspace) });
|
||||
}
|
||||
|
||||
if (resolvedAgent?.agentDir) {
|
||||
return `目录 ${formatPathTail(resolvedAgent.agentDir)}`;
|
||||
return t('cron.agent.dir', { path: formatPathTail(resolvedAgent.agentDir) });
|
||||
}
|
||||
|
||||
if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID) || resolvedAgent?.isDefault) {
|
||||
return '共享主工作区';
|
||||
return t('cron.agent.sharedMainWorkspace');
|
||||
}
|
||||
|
||||
return '工作区待同步';
|
||||
return t('cron.agent.syncPending');
|
||||
}
|
||||
|
||||
function describeDelivery(
|
||||
delivery: CronJobDelivery | undefined,
|
||||
channelGroups: CronDeliveryChannelGroup[],
|
||||
t: Translate,
|
||||
): string {
|
||||
if (!delivery || delivery.mode !== 'announce') {
|
||||
return '仅执行任务,不额外发送';
|
||||
return t('cron.delivery.none');
|
||||
}
|
||||
|
||||
const channelType = normalizeChannelType(delivery.channel);
|
||||
if (!channelType) {
|
||||
return '公告发送待配置';
|
||||
return t('cron.delivery.pending');
|
||||
}
|
||||
|
||||
const channelGroup = channelGroups.find((group) => group.channelType === channelType);
|
||||
@@ -1010,9 +1031,9 @@ function describeDelivery(
|
||||
const target = getString(delivery.to);
|
||||
|
||||
return [
|
||||
getChannelDisplayName(channelType),
|
||||
account ? getDeliveryAccountDisplayName(account) : null,
|
||||
target || '目标待填写',
|
||||
getChannelDisplayName(channelType, t),
|
||||
account ? getDeliveryAccountDisplayName(account, t) : null,
|
||||
target || t('cron.delivery.targetPending'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
@@ -1121,8 +1142,10 @@ function CronJobCard({
|
||||
onDelete,
|
||||
onTrigger,
|
||||
}: JobCardProps) {
|
||||
const { t, locale } = useI18n();
|
||||
const disabled = busyAction !== null;
|
||||
const isAnnouncement = job.delivery?.mode === 'announce';
|
||||
const jobName = job.name.trim() ? job.name : t('cron.common.unnamedJob');
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -1136,13 +1159,13 @@ function CronJobCard({
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="truncate text-[16px] font-semibold text-[#171717] dark:text-[#f3f4f6]">{job.name}</h3>
|
||||
<h3 className="truncate text-[16px] font-semibold text-[#171717] dark:text-[#f3f4f6]">{jobName}</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
job.enabled ? 'bg-green-500' : 'bg-[#99A0AE] dark:bg-gray-500',
|
||||
)}
|
||||
aria-label={job.enabled ? '启用中' : '已暂停'}
|
||||
aria-label={job.enabled ? t('cron.card.enabled') : t('cron.card.paused')}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -1152,12 +1175,12 @@ function CronJobCard({
|
||||
: 'bg-[#E8E6DE] text-[#7A7668] dark:bg-[#2a2a2d] dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
{isAnnouncement ? '执行并发送' : '仅执行'}
|
||||
{isAnnouncement ? t('cron.card.modeAnnounce') : t('cron.card.modeNone')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[13px] text-[#525866] dark:text-gray-400">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
<span>{parseCronSchedule(job.schedule)}</span>
|
||||
<span>{parseCronSchedule(job.schedule, t, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1169,7 +1192,7 @@ function CronJobCard({
|
||||
job.enabled ? 'bg-[#2B7FFF]' : 'bg-[#d1d5db] dark:bg-[#3b3b40]',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
aria-label={job.enabled ? '暂停任务' : '启用任务'}
|
||||
aria-label={job.enabled ? t('cron.card.pauseTask') : t('cron.card.enableTask')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!disabled) {
|
||||
@@ -1196,7 +1219,7 @@ function CronJobCard({
|
||||
<div className="flex items-start gap-2 text-[#525866] dark:text-gray-400">
|
||||
<BotIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
|
||||
<span className="min-w-0">
|
||||
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75">Agent</span>
|
||||
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75">{t('cron.card.agentLabel')}</span>
|
||||
<span className="font-medium text-[#171717] dark:text-[#f3f4f6]">{agentLabel}</span>
|
||||
<span className="ml-2 text-[#99A0AE] dark:text-gray-500">{agentDetail}</span>
|
||||
</span>
|
||||
@@ -1204,7 +1227,7 @@ function CronJobCard({
|
||||
<div className="flex items-start gap-2 text-[#525866] dark:text-gray-400">
|
||||
<SendIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
|
||||
<span className="min-w-0">
|
||||
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75">送达</span>
|
||||
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75">{t('cron.card.deliveryLabel')}</span>
|
||||
<span className="font-medium text-[#171717] dark:text-[#f3f4f6]">{deliverySummary}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1214,16 +1237,16 @@ function CronJobCard({
|
||||
{job.lastRun ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HistoryIcon className="h-3.5 w-3.5" />
|
||||
<span>最近执行:{formatRelativeTime(job.lastRun.time)}</span>
|
||||
<span>{t('cron.card.lastRun', { time: formatRelativeTime(job.lastRun.time, t, locale) })}</span>
|
||||
<span className={job.lastRun.success ? 'text-green-500' : 'text-red-500'}>
|
||||
{job.lastRun.success ? '成功' : '失败'}
|
||||
{job.lastRun.success ? t('cron.card.success') : t('cron.card.failed')}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{job.nextRun && job.enabled ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CalendarIcon className="h-3.5 w-3.5" />
|
||||
<span>下次执行:{formatDateTime(job.nextRun)}</span>
|
||||
<span>{t('cron.card.nextRun', { time: formatDateTime(job.nextRun, locale) })}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1247,7 +1270,7 @@ function CronJobCard({
|
||||
}}
|
||||
>
|
||||
{busyAction === 'trigger' ? <SpinnerIcon className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <PlayIcon className="mr-1.5 h-3.5 w-3.5" />}
|
||||
立即执行
|
||||
{t('cron.actions.runNow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1260,7 +1283,7 @@ function CronJobCard({
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
删除
|
||||
{t('cron.actions.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1279,6 +1302,7 @@ function CronTaskDialog({
|
||||
onClose,
|
||||
onSave,
|
||||
}: DialogProps) {
|
||||
const { t, locale } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [schedule, setSchedule] = useState('0 9 * * *');
|
||||
@@ -1329,8 +1353,8 @@ function CronTaskDialog({
|
||||
);
|
||||
|
||||
const deliveryTargetOptions = useMemo(
|
||||
() => mergeDeliveryTargetOptions(loadedDeliveryTargetOptions, deliveryTarget),
|
||||
[deliveryTarget, loadedDeliveryTargetOptions],
|
||||
() => mergeDeliveryTargetOptions(loadedDeliveryTargetOptions, deliveryTarget, t),
|
||||
[deliveryTarget, loadedDeliveryTargetOptions, t],
|
||||
);
|
||||
|
||||
const targetListId = useMemo(
|
||||
@@ -1460,33 +1484,34 @@ function CronTaskDialog({
|
||||
to: deliveryTarget,
|
||||
},
|
||||
availableChannelGroups,
|
||||
t,
|
||||
)
|
||||
: '仅执行任务,不额外发送';
|
||||
: t('cron.delivery.none');
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!name.trim()) {
|
||||
setValidationError('请填写任务名称。');
|
||||
setValidationError(t('cron.validation.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim()) {
|
||||
setValidationError('请填写提醒内容。');
|
||||
setValidationError(t('cron.validation.messageRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finalSchedule) {
|
||||
setValidationError('请填写执行计划。');
|
||||
setValidationError(t('cron.validation.scheduleRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (deliveryMode === 'announce') {
|
||||
if (!deliveryChannel) {
|
||||
setValidationError('请选择一个发送渠道。');
|
||||
setValidationError(t('cron.validation.channelRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deliveryTarget.trim()) {
|
||||
setValidationError('请填写发送目标,例如群组名、用户标识或 Webhook。');
|
||||
setValidationError(t('cron.validation.targetRequired'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1520,10 +1545,10 @@ function CronTaskDialog({
|
||||
className="mb-2 text-[24px] font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{job ? '编辑任务' : '创建任务'}
|
||||
{job ? t('cron.dialog.editTitle') : t('cron.dialog.createTitle')}
|
||||
</h2>
|
||||
<p className="text-[14px] text-[#99A0AE] dark:text-gray-500">
|
||||
安排自动化的 AI 任务
|
||||
{t('cron.dialog.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -1539,29 +1564,29 @@ function CronTaskDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">任务名称</label>
|
||||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.nameLabel')}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:晨间播报"
|
||||
placeholder={t('cron.dialog.namePlaceholder')}
|
||||
className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">消息/提示词</label>
|
||||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.messageLabel')}</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="描述任务要执行或广播的内容"
|
||||
placeholder={t('cron.dialog.messagePlaceholder')}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-xl border border-transparent bg-[#EDECE4] px-4 py-3 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">调度计划</label>
|
||||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.scheduleLabel')}</label>
|
||||
{!useCustom ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{SCHEDULE_PRESETS.map((preset) => {
|
||||
@@ -1579,7 +1604,7 @@ function CronTaskDialog({
|
||||
onClick={() => setSchedule(preset.value)}
|
||||
>
|
||||
<ClockIcon className="mr-2 h-4 w-4 opacity-80" />
|
||||
{preset.label}
|
||||
{t(`cron.schedule.presets.${preset.key}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -1588,14 +1613,16 @@ function CronTaskDialog({
|
||||
<input
|
||||
value={customSchedule}
|
||||
onChange={(event) => setCustomSchedule(event.target.value)}
|
||||
placeholder="输入 Cron 表达式,例如 0 9 * * *"
|
||||
placeholder={t('cron.dialog.customCronPlaceholder')}
|
||||
className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-4">
|
||||
<p className="text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
|
||||
{nextRunPreview ? `下次执行:${nextRunPreview}` : '选择预设或填写自定义 Cron 表达式'}
|
||||
{nextRunPreview
|
||||
? t('cron.dialog.nextRun', { time: formatDateTime(nextRunPreview, locale) })
|
||||
: t('cron.dialog.scheduleHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1610,18 +1637,18 @@ function CronTaskDialog({
|
||||
setUseCustom((current) => !current);
|
||||
}}
|
||||
>
|
||||
{useCustom ? '使用预设' : '使用自定义 Cron'}
|
||||
{useCustom ? t('cron.dialog.usePreset') : t('cron.dialog.useCustom')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">投递设置</div>
|
||||
<p className="mb-3 text-[12px] text-[#99A0AE] dark:text-gray-500">选择仅在 NIANXX 内保留结果,或把最终结果推送到外部通道。</p>
|
||||
<div className="mb-1 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.deliveryTitle')}</div>
|
||||
<p className="mb-3 text-[12px] text-[#99A0AE] dark:text-gray-500">{t('cron.dialog.deliverySubtitle')}</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{[
|
||||
{ key: 'none', title: '仅在NIANXX内', desc: '任务照常运行,结果只保留在应用内。' },
|
||||
{ key: 'announce', title: '发送到外部通道', desc: '将最终结果投递到已配置的消息通道。' },
|
||||
{ key: 'none', title: t('cron.dialog.deliveryOptions.noneTitle'), desc: t('cron.dialog.deliveryOptions.noneDesc') },
|
||||
{ key: 'announce', title: t('cron.dialog.deliveryOptions.announceTitle'), desc: t('cron.dialog.deliveryOptions.announceDesc') },
|
||||
].map((item) => {
|
||||
const active = deliveryMode === item.key;
|
||||
return (
|
||||
@@ -1646,7 +1673,7 @@ function CronTaskDialog({
|
||||
{deliveryMode === 'announce' ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">发送渠道</label>
|
||||
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.channelLabel')}</label>
|
||||
<SelectField
|
||||
value={deliveryChannel}
|
||||
onChange={(event) => setDeliveryChannel(event.target.value)}
|
||||
@@ -1655,17 +1682,17 @@ function CronTaskDialog({
|
||||
{availableChannelGroups.length > 0 ? (
|
||||
availableChannelGroups.map((group) => (
|
||||
<option key={group.channelType} value={group.channelType}>
|
||||
{getChannelDisplayName(group.channelType)}
|
||||
{getChannelDisplayName(group.channelType, t)}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">暂无可用渠道</option>
|
||||
<option value="">{t('cron.dialog.channelEmpty')}</option>
|
||||
)}
|
||||
</SelectField>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">账号</label>
|
||||
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.accountLabel')}</label>
|
||||
<SelectField
|
||||
value={selectedDeliveryAccountId}
|
||||
onChange={(event) => setSelectedDeliveryAccountId(event.target.value)}
|
||||
@@ -1674,22 +1701,24 @@ function CronTaskDialog({
|
||||
{(selectedChannelGroup?.accounts.length ?? 0) > 0 ? (
|
||||
selectedChannelGroup?.accounts.map((account) => (
|
||||
<option key={account.accountId} value={account.accountId}>
|
||||
{getDeliveryAccountDisplayName(account)}
|
||||
{getDeliveryAccountDisplayName(account, t)}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">{channelsLoading ? '正在加载渠道账号…' : '当前渠道没有账号列表'}</option>
|
||||
<option value="">
|
||||
{channelsLoading ? t('cron.dialog.accountLoading') : t('cron.dialog.accountEmpty')}
|
||||
</option>
|
||||
)}
|
||||
</SelectField>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">目标</label>
|
||||
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.targetLabel')}</label>
|
||||
<input
|
||||
list={deliveryTargetOptions.length > 0 ? targetListId : undefined}
|
||||
value={deliveryTarget}
|
||||
onChange={(event) => setDeliveryTarget(event.target.value)}
|
||||
placeholder="例如:值班群、room-ops、https://example.com/webhook"
|
||||
placeholder={t('cron.dialog.targetPlaceholder')}
|
||||
className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
/>
|
||||
{deliveryTargetOptions.length > 0 ? (
|
||||
@@ -1721,21 +1750,21 @@ function CronTaskDialog({
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] text-[#99A0AE] dark:text-gray-500">
|
||||
{targetsLoading
|
||||
? '正在为当前渠道账号加载推荐目标...'
|
||||
? t('cron.dialog.targetsLoading')
|
||||
: deliveryTargetOptions.length > 0
|
||||
? '推荐目标已就绪,也可以继续手工输入群组、用户标识或 Webhook。'
|
||||
: '暂未发现推荐目标,仍可手工输入群组、用户标识或 Webhook。'}
|
||||
? t('cron.dialog.targetsReady')
|
||||
: t('cron.dialog.targetsNone')}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
|
||||
<span>预览:{deliveryPreview}</span>
|
||||
{selectedAccount ? <span>当前账号:{getDeliveryAccountDisplayName(selectedAccount)}</span> : null}
|
||||
<span>{t('cron.dialog.preview', { preview: deliveryPreview })}</span>
|
||||
{selectedAccount ? <span>{t('cron.dialog.currentAccount', { account: getDeliveryAccountDisplayName(selectedAccount, t) })}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channelsError ? <Notice tone="warning" message={`渠道账号加载失败,仍可保留现有配置。${channelsError}`} /> : null}
|
||||
{targetsError ? <Notice tone="warning" message={`目标候选加载失败,仍可手工输入。${targetsError}`} /> : null}
|
||||
{channelsError ? <Notice tone="warning" message={t('cron.warnings.channelsCatalogFailed', { error: channelsError })} /> : null}
|
||||
{targetsError ? <Notice tone="warning" message={t('cron.warnings.targetsLoadFailed', { error: targetsError })} /> : null}
|
||||
{!channelsError && !channelsLoading && availableChannelGroups.length === 0 ? (
|
||||
<Notice tone="warning" message="当前没有可用的渠道账号,暂时无法新建发送型任务。" />
|
||||
<Notice tone="warning" message={t('cron.warnings.noChannelsAvailable')} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1743,8 +1772,8 @@ function CronTaskDialog({
|
||||
|
||||
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-[#E8E6DE]/50 p-4 dark:border-[#2a2a2d] dark:bg-[#222225]">
|
||||
<div>
|
||||
<div className="text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">立即启用</div>
|
||||
<div className="mt-0.5 text-[13px] text-[#99A0AE] dark:text-gray-500">创建后立即开始运行此任务。</div>
|
||||
<div className="text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.enableTitle')}</div>
|
||||
<div className="mt-0.5 text-[13px] text-[#99A0AE] dark:text-gray-500">{t('cron.dialog.enableDesc')}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1777,7 +1806,7 @@ function CronTaskDialog({
|
||||
className="h-[42px] rounded-full bg-[#EDECE4] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1788,7 +1817,11 @@ function CronTaskDialog({
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? <SpinnerIcon className="mr-2 h-4 w-4 animate-spin" /> : <PlusIcon className="mr-2 h-4 w-4" />}
|
||||
{saving ? '保存中...' : job ? '保存修改' : '创建任务'}
|
||||
{saving
|
||||
? t('cron.dialog.saving')
|
||||
: job
|
||||
? t('cron.dialog.saveEdit')
|
||||
: t('cron.dialog.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1797,6 +1830,7 @@ function CronTaskDialog({
|
||||
}
|
||||
|
||||
export default function CronPage() {
|
||||
const { t } = useI18n();
|
||||
const agents = useAgentsStore((state) => state.agents);
|
||||
const agentsLoading = useAgentsStore((state) => state.loading);
|
||||
const agentsError = useAgentsStore((state) => state.error);
|
||||
@@ -1835,7 +1869,7 @@ export default function CronPage() {
|
||||
const normalizedJobs = normalizeCronJobs(response);
|
||||
setJobs(normalizedJobs);
|
||||
} catch (requestError) {
|
||||
setJobs((currentJobs) => (currentJobs.length > 0 ? currentJobs : FALLBACK_CRON_JOBS));
|
||||
setJobs((currentJobs) => (currentJobs.length > 0 ? currentJobs : buildFallbackCronJobs(t)));
|
||||
setError(requestError instanceof Error ? requestError.message : String(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -1896,7 +1930,7 @@ export default function CronPage() {
|
||||
setJobs((currentJobs) => currentJobs.map((job) => (job.id === editingJob.id ? fallback : job)));
|
||||
}
|
||||
|
||||
setFeedback({ tone: 'success', message: '定时任务已更新。' });
|
||||
setFeedback({ tone: 'success', message: t('cron.feedback.updated') });
|
||||
} else {
|
||||
try {
|
||||
const response = await hostApiFetch<CronJob | { job?: CronJob }>('/api/cron/jobs', {
|
||||
@@ -1911,7 +1945,7 @@ export default function CronPage() {
|
||||
setJobs((currentJobs) => [...currentJobs, buildLocalCronJob(input)]);
|
||||
}
|
||||
|
||||
setFeedback({ tone: 'success', message: '定时任务已创建。' });
|
||||
setFeedback({ tone: 'success', message: t('cron.feedback.created') });
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
@@ -1947,7 +1981,7 @@ export default function CronPage() {
|
||||
);
|
||||
}
|
||||
|
||||
setFeedback({ tone: 'success', message: enabled ? '任务已启用。' : '任务已暂停。' });
|
||||
setFeedback({ tone: 'success', message: enabled ? t('cron.feedback.enabled') : t('cron.feedback.paused') });
|
||||
} catch (toggleError) {
|
||||
setFeedback({
|
||||
tone: 'error',
|
||||
@@ -1987,7 +2021,7 @@ export default function CronPage() {
|
||||
);
|
||||
}
|
||||
|
||||
setFeedback({ tone: 'success', message: '任务已触发执行。' });
|
||||
setFeedback({ tone: 'success', message: t('cron.feedback.triggered') });
|
||||
} catch (triggerError) {
|
||||
setFeedback({
|
||||
tone: 'error',
|
||||
@@ -2000,7 +2034,8 @@ export default function CronPage() {
|
||||
}
|
||||
|
||||
async function handleDelete(job: CronJob): Promise<void> {
|
||||
const confirmed = window.confirm(`确认删除“${job.name}”吗?删除后将无法恢复。`);
|
||||
const jobName = job.name.trim() ? job.name : t('cron.common.unnamedJob');
|
||||
const confirmed = window.confirm(t('cron.confirmDelete', { name: jobName }));
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyJobId(job.id);
|
||||
@@ -2015,7 +2050,7 @@ export default function CronPage() {
|
||||
setJobs((currentJobs) => currentJobs.filter((currentJob) => currentJob.id !== job.id));
|
||||
}
|
||||
|
||||
setFeedback({ tone: 'success', message: '任务已删除。' });
|
||||
setFeedback({ tone: 'success', message: t('cron.feedback.deleted') });
|
||||
} catch (deleteError) {
|
||||
setFeedback({
|
||||
tone: 'error',
|
||||
@@ -2037,10 +2072,10 @@ export default function CronPage() {
|
||||
className="mb-3 text-5xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6] md:text-6xl"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
定时任务
|
||||
{t('cron.title')}
|
||||
</h1>
|
||||
<p className="text-[17px] font-medium text-[#171717]/70 dark:text-[#9ca3af]">
|
||||
为 Cron 任务配置执行计划与发送渠道,让调度、执行和投递信息在一个页面里闭环。
|
||||
{t('cron.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2053,7 +2088,7 @@ export default function CronPage() {
|
||||
}}
|
||||
>
|
||||
<RefreshIcon className={cn('mr-2 h-4 w-4', refreshing && 'animate-spin')} />
|
||||
刷新
|
||||
{t('cron.actions.refresh')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2064,7 +2099,7 @@ export default function CronPage() {
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
新建任务
|
||||
{t('cron.actions.newTask')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2072,30 +2107,30 @@ export default function CronPage() {
|
||||
<div className="-mr-2 min-h-0 flex-1 overflow-y-auto pb-10 pr-2">
|
||||
{feedback ? <Notice tone={feedback.tone} message={feedback.message} /> : null}
|
||||
{error ? <Notice tone="error" message={error} /> : null}
|
||||
{agentsError ? <Notice tone="warning" message={`Agents 数据加载失败,任务仍会使用当前快照继续展示。${agentsError}`} /> : null}
|
||||
{channelsError ? <Notice tone="warning" message={`渠道账号加载失败,发送配置将以占位信息展示。${channelsError}`} /> : null}
|
||||
{agentsError ? <Notice tone="warning" message={t('cron.warnings.agentsLoadFailed', { error: agentsError })} /> : null}
|
||||
{channelsError ? <Notice tone="warning" message={t('cron.warnings.channelsLoadFailed', { error: channelsError })} /> : null}
|
||||
{agentsWarning ? <Notice tone="warning" message={agentsWarning} /> : null}
|
||||
|
||||
{loading && jobs.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-[#525866] dark:text-gray-400">
|
||||
<SpinnerIcon className="mb-4 h-10 w-10 animate-spin" />
|
||||
<p>正在加载任务...</p>
|
||||
<p>{t('cron.loading')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="全部任务" value={jobs.length} tone="neutral" icon={ClockIcon} />
|
||||
<StatCard label="启用中" value={activeJobs.length} tone="green" icon={PlayIcon} />
|
||||
<StatCard label="已暂停" value={pausedJobs.length} tone="amber" icon={PauseIcon} />
|
||||
<StatCard label="最近失败" value={failedJobs.length} tone="red" icon={AlertIcon} />
|
||||
<StatCard label={t('cron.stats.total')} value={jobs.length} tone="neutral" icon={ClockIcon} />
|
||||
<StatCard label={t('cron.stats.active')} value={activeJobs.length} tone="green" icon={PlayIcon} />
|
||||
<StatCard label={t('cron.stats.paused')} value={pausedJobs.length} tone="amber" icon={PauseIcon} />
|
||||
<StatCard label={t('cron.stats.failed')} value={failedJobs.length} tone="red" icon={AlertIcon} />
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-transparent bg-white /40 py-20 text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
||||
<ClockIcon className="mb-4 h-10 w-10 opacity-50" />
|
||||
<h3 className="mb-2 text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">还没有定时任务</h3>
|
||||
<h3 className="mb-2 text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{t('cron.empty.title')}</h3>
|
||||
<p className="mb-6 max-w-md text-center text-[14px]">
|
||||
现在可以创建任务并预留好渠道账号和发送目标。
|
||||
{t('cron.empty.description')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2106,7 +2141,7 @@ export default function CronPage() {
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
创建任务
|
||||
{t('cron.actions.createTask')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2115,9 +2150,9 @@ export default function CronPage() {
|
||||
<CronJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
agentLabel={getAgentLabel(job.agentId, agentsById, defaultAgentId)}
|
||||
agentDetail={getAgentDetail(job.agentId, agentsById, defaultAgentId)}
|
||||
deliverySummary={describeDelivery(job.delivery, channelGroups)}
|
||||
agentLabel={getAgentLabel(job.agentId, agentsById, defaultAgentId, t)}
|
||||
agentDetail={getAgentDetail(job.agentId, agentsById, defaultAgentId, t)}
|
||||
deliverySummary={describeDelivery(job.delivery, channelGroups, t)}
|
||||
busyAction={busyJobId === job.id ? busyAction : null}
|
||||
onToggle={(enabled) => {
|
||||
void handleToggle(job, enabled);
|
||||
|
||||
Reference in New Issue
Block a user