diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index f57230a..6dd0752 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -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: 'チャンネル設定', diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index beba7ce..acd1999 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -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; + 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 & { }; 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 = { - douyin: '抖音', - feishu: '飞书', - fliggy: '飞猪', - meituan: '美团', - qqbot: 'QQ 机器人', - telegram: 'Telegram', - wechat: '微信', - wecom: '企业微信', +const CHANNEL_NAME_KEYS: Record = { + 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 { 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 = { - '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, 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, 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 (
-

{job.name}

+

{jobName}

- {isAnnouncement ? '执行并发送' : '仅执行'} + {isAnnouncement ? t('cron.card.modeAnnounce') : t('cron.card.modeNone')}
- {parseCronSchedule(job.schedule)} + {parseCronSchedule(job.schedule, t, locale)}
@@ -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({
- Agent + {t('cron.card.agentLabel')} {agentLabel} {agentDetail} @@ -1204,7 +1227,7 @@ function CronJobCard({
- 送达 + {t('cron.card.deliveryLabel')} {deliverySummary}
@@ -1214,16 +1237,16 @@ function CronJobCard({ {job.lastRun ? ( - 最近执行:{formatRelativeTime(job.lastRun.time)} + {t('cron.card.lastRun', { time: formatRelativeTime(job.lastRun.time, t, locale) })} - {job.lastRun.success ? '成功' : '失败'} + {job.lastRun.success ? t('cron.card.success') : t('cron.card.failed')} ) : null} {job.nextRun && job.enabled ? ( - 下次执行:{formatDateTime(job.nextRun)} + {t('cron.card.nextRun', { time: formatDateTime(job.nextRun, locale) })} ) : null}
@@ -1247,7 +1270,7 @@ function CronJobCard({ }} > {busyAction === 'trigger' ? : } - 立即执行 + {t('cron.actions.runNow')} @@ -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 { 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')}

- 安排自动化的 AI 任务 + {t('cron.dialog.subtitle')}