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:
DEV_DSW
2026-04-20 16:00:52 +08:00
parent 7dc37b26b0
commit 44dfe51eaf
2 changed files with 801 additions and 205 deletions

View File

@@ -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);