refactor(i18n): U23 Task 模块 i18n 治理 — 4 文件 phantom key + hash key + 硬编码中文清零

- CronJobManager/CronJobFormDialog/ScriptManager/RemoteExecPage
  从 import i18n 全局调用改为 useTranslation() hook
- 42 处 phantom key (i18n.t('Chinese text') → 语义化键)
- 28 个孤儿 hash key 清理 (cronjobformdial.s*/remoteexecpaget.s*)
- 50+ 处硬编码 JSX 中文 → t() 调用
- 新增 task.cronJob.*/task.scriptList.*/task.remoteExec.* 子命名空间
- zh/en 6825→6886 keys 对齐
- tsc -b=0, npm build✓ (17.46s), ESLint=0/45/0, go build=0
- 17/17 集成回归测试通过
This commit is contained in:
attacker committed 2026-07-11 17:48:04 +08:00
1 parent a8f6fd190b
commit de6f9454fc
7 files changed
+361 -229

No files matched your search

+1
View File
@@ -514,6 +514,7 @@ services/api.ts (1324L)
| U17 | K8s 残余组件 i18n 补全(NodeDetailPage 16 处硬编码中文清零 + 8 个 hash 键替换为语义化 k8s.nodeDetail.* 键 + DeploymentWorkload 12 处 toast/confirm 硬编码中文清零,zh/en 7564→7597 +33 键对齐) | P3 | **done** |
| U19 | i18n hash 键清理(删除 866 个死 hash 键 + 重命名 21 个高频 live hash 键为语义化键名,zh/en 7597→6731 keys 对齐,文件 328KB→276KB (-16%),tsc -b=0,npm build✓,ESLint=0/66/0,17/17 测试通过) | P3 | **done** |
| U20 | ESLint static-components 清零 + CredDetailDialog 内联组件修复 + i18n 批量迁移损坏修复(CredDetailDialog.tsx `Field` 内联组件提取为模块级 `CredField` — 23 处 react-hooks/static-components 警告清零;修复 8 处被批量正则损坏的 `i18n.t()` 调用(`i18n.t('>名称:</span>')` 等 → 语义化键);6 处硬编码中文清零;新增 `deploy.cred.sensitiveWarn` 键;ESLint warnings 66→43 (-35%),tsc -b=0,npm build✓ (17.29s),17/17 测试通过) | P3 | **done** |
| U23 | Task 模块 i18n 治理(CronJobManager + CronJobFormDialog + ScriptManager + RemoteExecPage 4 文件 — `import i18n` 全局调用 → `useTranslation()` hook;42 处 phantom key (`i18n.t('Chinese text')` → 语义化 `task.cronJob.*`/`task.scriptList.*`/`task.remoteExec.*` 键);28 个孤儿 hash 键清理 (cronjobformdial.s*/remoteexecpaget.s*/systemtaskspanel.s*);50+ 处硬编码 JSX 中文 → t() 调用;zh/en 6825→6886 +61 键对齐,tsc -b=0,npm build✓ (17.46s),ESLint=0/45/0,go build=0,17/17 测试通过) | P3 | **done** |
---
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -22,15 +23,6 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { cronJobApi, scriptApi } from '@/api';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
// 定时任务类型定义 // Logan.Li
const jobTypes = [
{ value: 'shell', label: 'Shell', desc: i18n.t('关联脚本库中的 Shell 脚本') },
{ value: 'python', label: 'Python', desc: i18n.t('关联脚本库中的 Python 脚本') },
{ value: 'powershell', label: 'PowerShell', desc: i18n.t('关联脚本库中的 PowerShell 脚本') },
{ value: 'command', label: i18n.t('自定义命令'), desc: i18n.t('手动输入命令或脚本路径') },
];
const scriptJobTypes = ['shell', 'python', 'powershell'];
@@ -51,6 +43,7 @@ export function CronJobFormDialog({
editJob: JsonData | null;
onSuccess: () => void;
}) {
const { t } = useTranslation();
const [form, setForm] = useState({
name: '',
description: '',
@@ -63,10 +56,15 @@ export function CronJobFormDialog({
const [selectedScript, setSelectedScript] = useState<JsonData>(null);
const [submitting, setSubmitting] = useState(false);
// 弹窗打开时:加载脚本库 + 初始化表单 // Logan.Li
const jobTypes = useMemo(() => [
{ value: 'shell', label: 'Shell', desc: t('task.cronJob.typeShell') },
{ value: 'python', label: 'Python', desc: t('task.cronJob.typePython') },
{ value: 'powershell', label: 'PowerShell', desc: t('task.cronJob.typePowershell') },
{ value: 'command', label: t('task.cronJob.typeCommandLabel'), desc: t('task.cronJob.typeCommandDesc') },
], [t]);
useEffect(() => {
if (!open) return;
// 拉取脚本库
scriptApi.getScripts({ page_size: 200 }).then((res: JsonData) => {
setScripts(res?.list || []);
}).catch(() => setScripts([]));
@@ -80,7 +78,6 @@ export function CronJobFormDialog({
content: editJob.content || '',
script_id: editJob.script_id || null,
});
// 如果关联了脚本,拉取脚本详情
if (editJob.script_id) {
scriptApi.getScript(editJob.script_id).then((res: JsonData) => {
setSelectedScript(res);
@@ -94,12 +91,10 @@ export function CronJobFormDialog({
}
}, [open, editJob]);
// 按类型过滤脚本库 // Logan.Li
const filteredScripts = scriptJobTypes.includes(form.type)
? scripts.filter(s => s.type === form.type)
: [];
// 选中脚本时更新关联
const handleSelectScript = (scriptId: string) => {
const sc = scripts.find(s => String(s.id) === scriptId);
if (sc) {
@@ -108,7 +103,6 @@ export function CronJobFormDialog({
}
};
// 切换类型时重置脚本关联
const handleTypeChange = (type: string) => {
setForm(prev => ({
...prev,
@@ -121,12 +115,11 @@ export function CronJobFormDialog({
const handleSave = async () => {
if (!form.name || !form.cron_expr) {
toast.error(i18n.t('请填写任务名称和 Cron 表达式'));
toast.error(t('task.cronJob.form.toastNameRequired'));
return;
}
// 脚本类型必须选关联脚本 // Logan.Li
if (scriptJobTypes.includes(form.type) && !form.script_id) {
toast.error(i18n.t('请填写任务名称和 Cron 表达式'));
toast.error(t('task.cronJob.form.toastNameRequired'));
return;
}
try {
@@ -141,16 +134,16 @@ export function CronJobFormDialog({
};
if (editJob) {
await cronJobApi.updateJob(editJob.id, payload);
toast.success(i18n.t('任务更新成功'));
toast.success(t('task.cronJob.form.toastSaved'));
} else {
await cronJobApi.createJob(payload);
toast.success(i18n.t('任务更新成功'));
toast.success(t('task.cronJob.form.toastSaved'));
}
onOpenChange(false);
onSuccess();
} catch (error) {
console.error('Failed to save cron job:', error);
toast.error(editJob ? i18n.t('kubernetes.deployment.imageUpdateFailed') : i18n.t('monitor.dashboard.custom.toastCreateFailed'));
toast.error(editJob ? t('kubernetes.deployment.imageUpdateFailed') : t('monitor.dashboard.custom.toastCreateFailed'));
} finally {
setSubmitting(false);
}
@@ -160,36 +153,33 @@ export function CronJobFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle>{editJob ? i18n.t('automation.editCronJob') : i18n.t('编辑定时任务')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]"> {i18n.t('task.cronjobformdial.s2e10ee70')} </DialogDescription>
<DialogTitle>{editJob ? t('task.cronJob.form.titleEdit') : t('task.cronJob.form.titleCreate')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]"> {t('task.cronJob.form.desc')} </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto pr-1">
{/* 任务名称 */}
<div>
<Label className="text-[var(--dg-text-primary)]"> {i18n.t('automation.cronJobName')} </Label>
<Label className="text-[var(--dg-text-primary)]"> {t('task.cronJob.form.labelName')} </Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={i18n.t('例如: 数据库备份')}
placeholder={t('task.cronJob.form.placeholderName')}
className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1"
/>
</div>
{/* 描述 */}
<div>
<Label className="text-[var(--dg-text-primary)]"> {i18n.t('aiops.llmModelDesc')} </Label>
<Label className="text-[var(--dg-text-primary)]"> {t('task.cronJob.form.labelDesc')} </Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder={i18n.t('例如: 数据库备份')}
placeholder={t('task.cronJob.form.placeholderName')}
rows={2}
className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1"
/>
</div>
{/* Cron 表达式 */}
<div>
<Label className="text-[var(--dg-text-primary)]">Cron 表达式</Label>
<Label className="text-[var(--dg-text-primary)]">{t('task.cronJob.form.labelCronExpr')}</Label>
<div className="flex gap-2">
<Input
value={form.cron_expr}
@@ -199,58 +189,56 @@ export function CronJobFormDialog({
/>
<Select onValueChange={(v) => setForm({ ...form, cron_expr: v })}>
<SelectTrigger className="w-48 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1">
<SelectValue placeholder={i18n.t('快速选择')} />
<SelectValue placeholder={t('task.cronJob.form.quickSelect')} />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<SelectItem value="*/5 * * * *" className="text-[var(--dg-text-primary)]"> {i18n.t('task.cronJob.cron.every5min')} </SelectItem>
<SelectItem value="0 * * * *" className="text-[var(--dg-text-primary)]"> {i18n.t('task.cronJob.cron.everyHour')} </SelectItem>
<SelectItem value="0 2 * * *" className="text-[var(--dg-text-primary)]"> {i18n.t('task.cronJob.cron.daily2')} </SelectItem>
<SelectItem value="0 3 * * 0" className="text-[var(--dg-text-primary)]"> {i18n.t('task.cronJob.cron.weeklySun3')} </SelectItem>
<SelectItem value="0 9 * * 1" className="text-[var(--dg-text-primary)]"> {i18n.t('task.cronJob.cron.weeklyMon9')} </SelectItem>
<SelectItem value="*/5 * * * *" className="text-[var(--dg-text-primary)]"> {t('task.cronJob.cron.every5min')} </SelectItem>
<SelectItem value="0 * * * *" className="text-[var(--dg-text-primary)]"> {t('task.cronJob.cron.everyHour')} </SelectItem>
<SelectItem value="0 2 * * *" className="text-[var(--dg-text-primary)]"> {t('task.cronJob.cron.daily2')} </SelectItem>
<SelectItem value="0 3 * * 0" className="text-[var(--dg-text-primary)]"> {t('task.cronJob.cron.weeklySun3')} </SelectItem>
<SelectItem value="0 9 * * 1" className="text-[var(--dg-text-primary)]"> {t('task.cronJob.cron.weeklyMon9')} </SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-[var(--dg-text-muted)] mt-1"> {i18n.t('task.cronjobformdial.s3b56b1f9')} </p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1"> {t('task.cronJob.form.cronHint')} </p>
</div>
{/* 执行类型 */}
<div>
<Label className="text-[var(--dg-text-primary)]"> {i18n.t('task.cronjobformdial.s15d864c1')} </Label>
<Label className="text-[var(--dg-text-primary)]"> {t('task.cronJob.form.labelJobType')} </Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-1">
{jobTypes.map(t => (
{jobTypes.map(jt => (
<button
key={t.value}
key={jt.value}
type="button"
onClick={() => handleTypeChange(t.value)}
onClick={() => handleTypeChange(jt.value)}
className={`px-3 py-2 rounded-md border text-sm transition-colors text-left ${
form.type === t.value
form.type === jt.value
? 'border-[var(--dg-blue)] bg-[var(--dg-blue)]/10 text-[var(--dg-blue)]'
: 'border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<div className="font-medium">{t.label}</div>
<div className="font-medium">{jt.label}</div>
</button>
))}
</div>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">
{jobTypes.find(t => t.value === form.type)?.desc}
{jobTypes.find(jt => jt.value === form.type)?.desc}
</p>
</div>
{/* 脚本选择 (shell/python/powershell) 或 命令输入 (command) */}
{scriptJobTypes.includes(form.type) ? (
<div className="space-y-3">
<div>
<Label className="text-[var(--dg-text-primary)]">
选择脚本 <span className="dg-text-red">*</span>
{t('task.cronJob.form.labelScript')} <span className="dg-text-red">*</span>
</Label>
{filteredScripts.length === 0 ? (
<div className="mt-1 px-3 py-4 rounded-md border border-dashed border-[var(--dg-border)] text-center">
<p className="text-sm text-[var(--dg-text-muted)]">
脚本库中没有 {scriptTypeLabel(form.type)} 脚本
{t('task.cronJob.form.noScriptType', { type: scriptTypeLabel(form.type) })}
</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">
请先到「脚本库」创建 {scriptTypeLabel(form.type)} 脚本
{t('task.cronJob.form.noScriptHint', { type: scriptTypeLabel(form.type) })}
</p>
</div>
) : (
@@ -259,7 +247,7 @@ export function CronJobFormDialog({
onValueChange={handleSelectScript}
>
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1">
<SelectValue placeholder={i18n.t('选择脚本库中的脚本...')} />
<SelectValue placeholder={t('task.cronJob.form.selectScriptPlaceholder')} />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
{filteredScripts.map(sc => (
@@ -272,7 +260,6 @@ export function CronJobFormDialog({
</Select>
)}
</div>
{/* 脚本预览 */}
{selectedScript && (
<div>
<div className="flex items-center gap-2 mb-1">
@@ -280,22 +267,22 @@ export function CronJobFormDialog({
{scriptTypeLabel(selectedScript.type || form.type)}
</Badge>
<span className="text-xs text-[var(--dg-text-muted)]">
{(selectedScript.content || '').split('\n').length} 行
{t('task.cronJob.form.scriptLines', { count: (selectedScript.content || '').split('\n').length })}
</span>
</div>
<pre className="max-h-48 overflow-auto rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] p-3 text-xs font-mono text-[var(--dg-text-secondary)] whitespace-pre-wrap break-all">
{selectedScript.content || i18n.t('(空脚本)')}
{selectedScript.content || t('task.cronJob.form.emptyScript')}
</pre>
</div>
)}
</div>
) : (
<div>
<Label className="text-[var(--dg-text-primary)]"> {i18n.t('aiops.assistantCommand')} </Label>
<Label className="text-[var(--dg-text-primary)]"> {t('task.cronJob.form.labelCommand')} </Label>
<Textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="/scripts/backup.sh arg1 arg2"
placeholder={t('task.cronJob.form.placeholderCommand')}
rows={4}
className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1 font-mono"
/>
@@ -304,14 +291,14 @@ export function CronJobFormDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
取消
{t('task.cronJob.form.cancel')}
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={handleSave}
disabled={submitting}
>
{submitting ? i18n.t('system.savingDot') : editJob ? i18n.t('cloud.saveChanges') : i18n.t('保存中...')}
{submitting ? t('system.savingDot') : editJob ? t('cloud.saveChanges') : t('task.script.save')}
</Button>
</DialogFooter>
</DialogContent>
+46 -43
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CronJobStatsPanel } from './CronJobStatsPanel';
import { CronJobTable } from './CronJobTable';
import { Button } from '@/components/ui/button';
@@ -24,13 +25,13 @@ import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { CronJobRecord } from '@/types';
import i18n from '@/i18n';
type StatusFilter = 'all' | 'enabled' | 'disabled';
type HealthFilter = 'all' | 'healthy' | 'failed';
type TypeFilter = 'all' | 'shell' | 'python' | 'powershell' | 'command';
export function CronJobManager() {
const { t } = useTranslation();
const { hasAnyPermission, isAdmin } = usePermission();
const canWrite = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
const [searchQuery, setSearchQuery] = useState('');
@@ -55,9 +56,10 @@ export function CronJobManager() {
const _confirm = useConfirm();
// eslint-disable-next-line react-hooks/exhaustive-deps
const pageTabs = useMemo(() => [
{ id: 'user', label: i18n.t('task.cronJobStats.userJobs'), badge: jobs.length || undefined },
{ id: 'system', label: i18n.t('automation.systemTask'), badge: systemTaskCount || undefined },
{ id: 'user', label: t('task.cronJobStats.userJobs'), badge: jobs.length || undefined },
{ id: 'system', label: t('automation.systemTask'), badge: systemTaskCount || undefined },
], [jobs.length, systemTaskCount]);
const openCreate = () => {
@@ -81,11 +83,12 @@ export function CronJobManager() {
setSystemTaskCount(sysRes.list?.length ?? 0);
} catch (error) {
console.error('Failed to load cron jobs:', error);
toast.error(i18n.t('kubernetes.msg.loadFailed'));
toast.error(t('kubernetes.msg.loadFailed'));
} finally {
setRefreshing(false);
setInitialLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -121,11 +124,11 @@ export function CronJobManager() {
const handleToggleJob = async (id: number) => {
try {
await cronJobApi.toggleJob(id);
toast.success(i18n.t('状态已切换'));
toast.success(t('task.cronJob.list.toastToggled'));
refreshData();
} catch (error) {
console.error('Failed to toggle job:', error);
toast.error(i18n.t('cloud.operationFailed'));
toast.error(t('cloud.operationFailed'));
}
};
@@ -135,14 +138,14 @@ export function CronJobManager() {
const result = await cronJobApi.executeJob(id);
const status = result?.status || 'success';
if (status === 'success') {
toast.success(i18n.t('automation.systemTaskSuccess'), { description: `耗时 ${result?.duration || 0}ms` });
toast.success(t('automation.systemTaskSuccess'), { description: t('task.cronJob.list.execDuration', { ms: result?.duration || 0 }) });
} else {
toast.error(i18n.t('automation.systemTaskFailed'), { description: (result?.output || '').slice(0, 100) });
toast.error(t('automation.systemTaskFailed'), { description: (result?.output || '').slice(0, 100) });
}
refreshData();
} catch (error) {
console.error('Failed to execute job:', error);
toast.error(i18n.t('automation.systemTaskFailed'));
toast.error(t('automation.systemTaskFailed'));
} finally {
setExecutingId(null);
}
@@ -150,18 +153,18 @@ export function CronJobManager() {
const handleDeleteJob = async (job: CronJobRecord) => {
const ok = await _confirm({
title: i18n.t('automation.deleteCronJob'),
description: `确定删除「${job.name}」吗?此操作不可恢复。`,
title: t('task.cronJob.list.delete'),
description: t('task.cronJob.list.confirmDelete', { name: job.name }),
variant: 'destructive',
});
if (!ok) return;
try {
await cronJobApi.deleteJob(job.id);
toast.success(i18n.t('状态已切换'));
toast.success(t('task.cronJob.list.toastToggled'));
refreshData();
} catch (error) {
console.error('Failed to delete job:', error);
toast.error(i18n.t('kubernetes.msg.deleteFailed2'));
toast.error(t('kubernetes.msg.deleteFailed2'));
}
};
@@ -178,20 +181,20 @@ export function CronJobManager() {
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return;
const ok = await _confirm({
title: i18n.t('删除定时任务'),
description: `确定删除选中的 ${selectedIds.length} 个任务吗?此操作不可恢复。`,
confirmText: i18n.t('system.terminalBatchDelete'),
title: t('task.cronJob.list.delete'),
description: t('task.cronJob.list.batchDeleteConfirm', { count: selectedIds.length }),
confirmText: t('system.terminalBatchDelete'),
variant: 'destructive',
});
if (!ok) return;
try {
await cronJobApi.batchDelete(selectedIds);
toast.success(`已删除 ${selectedIds.length} 个任务`);
toast.success(t('task.cronJob.list.toastDeleted', { count: selectedIds.length }));
setSelectedIds([]);
refreshData();
} catch (error) {
console.error('Failed to batch delete jobs:', error);
toast.error(i18n.t('system.batchDeleteFailed'));
toast.error(t('system.batchDeleteFailed'));
}
};
@@ -203,7 +206,7 @@ export function CronJobManager() {
<div className="relative flex-1 min-w-[180px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input
placeholder={i18n.t('搜索名称、描述、脚本…')}
placeholder={t('task.cronJob.list.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 dg-input h-9"
@@ -211,34 +214,34 @@ export function CronJobManager() {
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<SelectTrigger className="w-[108px] dg-input h-9 text-xs">
<SelectValue placeholder={i18n.t('system.terminalStatus')} />
<SelectValue placeholder={t('system.terminalStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('cloud.allStatus')} </SelectItem>
<SelectItem value="enabled"> {i18n.t('aiops.llmEnabled')} </SelectItem>
<SelectItem value="disabled"> {i18n.t('aiops.llmDisabled')} </SelectItem>
<SelectItem value="all"> {t('cloud.allStatus')} </SelectItem>
<SelectItem value="enabled"> {t('aiops.llmEnabled')} </SelectItem>
<SelectItem value="disabled"> {t('aiops.llmDisabled')} </SelectItem>
</SelectContent>
</Select>
<Select value={healthFilter} onValueChange={(v) => setHealthFilter(v as HealthFilter)}>
<SelectTrigger className="w-[108px] dg-input h-9 text-xs">
<SelectValue placeholder={i18n.t('k8s.clusters.statusHealthy')} />
<SelectValue placeholder={t('k8s.clusters.statusHealthy')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('状态')} </SelectItem>
<SelectItem value="healthy"> {i18n.t('agent.heartbeatNormal')} </SelectItem>
<SelectItem value="failed"> {i18n.t('monitor.siteProtocol.tcp.lastFail')} </SelectItem>
<SelectItem value="all"> {t('task.cronJob.list.filterStatus')} </SelectItem>
<SelectItem value="healthy"> {t('agent.heartbeatNormal')} </SelectItem>
<SelectItem value="failed"> {t('monitor.siteProtocol.tcp.lastFail')} </SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as TypeFilter)}>
<SelectTrigger className="w-[108px] dg-input h-9 text-xs">
<SelectValue placeholder={i18n.t('system.credentialType')} />
<SelectValue placeholder={t('system.credentialType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('k8s.topology.allTypes')} </SelectItem>
<SelectItem value="all"> {t('k8s.topology.allTypes')} </SelectItem>
<SelectItem value="shell">Shell</SelectItem>
<SelectItem value="python">Python</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
<SelectItem value="command"> {i18n.t('system.terminalCommand')} </SelectItem>
<SelectItem value="command"> {t('system.terminalCommand')} </SelectItem>
</SelectContent>
</Select>
{filteredJobs.length > 0 && (
@@ -248,22 +251,22 @@ export function CronJobManager() {
onCheckedChange={toggleSelectAll}
className="border-[var(--dg-border)]"
/>
全选
{t('task.cronJob.list.selectAll')}
</label>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{hasActiveFilters && (
<span className="text-xs dg-text-muted">
筛选结果 {filteredJobs.length}/{jobs.length}
{t('task.cronJob.list.filterResult', { shown: filteredJobs.length, total: jobs.length })}
</span>
)}
{selectedIds.length > 0 && (
<>
<span className="text-xs dg-text-secondary">已选 {selectedIds.length}</span>
<span className="text-xs dg-text-secondary">{t('task.cronJob.list.selectedCount', { count: selectedIds.length })}</span>
<Button variant="outline" size="sm" className="dg-btn-danger h-8" onClick={handleBatchDelete}>
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除
{t('task.cronJob.list.batchDelete')}
</Button>
</>
)}
@@ -275,8 +278,8 @@ export function CronJobManager() {
<>
<PageShell
variant="list"
title={i18n.t('automation.cronJobs')}
subtitle={i18n.t('Cron 调度 · 脚本执行 · 内置系统任务')}
title={t('automation.cronJobs')}
subtitle={t('task.cronJob.list.subtitle')}
icon={<Clock className="h-5 w-5" />}
tabs={pageTabs}
activeTab={activeTab}
@@ -293,7 +296,7 @@ export function CronJobManager() {
onClick={() => { setHistoryJob(null); setIsLogDialogOpen(true); }}
>
<History className="h-4 w-4 mr-2" />
全部历史
{t('task.cronJob.list.allHistory')}
</Button>
)}
<Button
@@ -303,12 +306,12 @@ export function CronJobManager() {
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
刷新
{t('task.cronJob.list.refresh')}
</Button>
{activeTab === 'user' && canWrite && (
<Button className="dg-btn-primary" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建
{t('task.cronJob.list.create')}
</Button>
)}
</>
@@ -324,17 +327,17 @@ export function CronJobManager() {
{jobs.length === 0 ? (
<PageEmpty
icon={<PackageOpen className="h-12 w-12" />}
title={i18n.t('automation.noCronJob')}
title={t('automation.noCronJob')}
action={canWrite ? (
<Button className="dg-btn-primary" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建任务
{t('task.cronJob.list.createTask')}
</Button>
) : undefined}
/>
) : filteredJobs.length === 0 ? (
<PageEmpty
title={i18n.t('rbac.jobRole.patternHint.noMatch')}
title={t('rbac.jobRole.patternHint.noMatch')}
action={(
<Button
variant="outline"
@@ -346,7 +349,7 @@ export function CronJobManager() {
setTypeFilter('all');
}}
>
清除筛选
{t('task.cronJob.list.clearFilter')}
</Button>
)}
/>
+39 -38
View File
@@ -1,6 +1,7 @@
// 远程执行页 — 关联主机后批量执行命令 / 上传文件(Go-native SSH) // Logan.Li
// 对接后端 /remote-exec/*(controller.RemoteExecHandler + pkg/remoteexec),替代已移除的 Ansible。
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Terminal, Upload, RefreshCw, Loader2, Server as ServerIcon, CheckCircle2, XCircle, FileUp,
} from 'lucide-react';
@@ -15,7 +16,6 @@ import { serverApi, remoteExecApi, type RemoteExecResult, type RemoteUploadResul
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface RemoteServer {
id: number; hostname: string; ip: string; username: string; port: number;
@@ -23,6 +23,7 @@ interface RemoteServer {
}
export function RemoteExecPage() {
const { t } = useTranslation();
const _confirm = useConfirm();
const [servers, setServers] = useState<RemoteServer[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -42,7 +43,7 @@ export function RemoteExecPage() {
try {
const res = await serverApi.getServers({ page: 1, page_size: 500, keyword });
setServers(((res as JsonData)?.list as RemoteServer[]) || []);
} catch { toast.error(i18n.t('加载服务器列表失败')); }
} catch { toast.error(t('task.remoteExec.toastFetchFailed')); }
finally { setLoading(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -64,12 +65,12 @@ export function RemoteExecPage() {
};
const doExec = async () => {
if (!command.trim()) { toast.error(i18n.t('请输入要执行的命令')); return; }
if (selCount === 0) { toast.error(i18n.t('请至少选择一台主机')); return; }
if (!command.trim()) { toast.error(t('task.remoteExec.toastCommandRequired')); return; }
if (selCount === 0) { toast.error(t('task.remoteExec.toastHostRequired')); return; }
const ok = await _confirm({
title: i18n.t('确认批量执行命令'),
description: `将在 ${selCount} 台主机上执行该命令,请确认命令安全:\n\n${command}`,
confirmText: i18n.t('deploy.label.execute'), variant: 'destructive',
title: t('task.remoteExec.toastExecConfirm'),
description: t('task.remoteExec.toastExecConfirmDesc', { count: selCount, command }),
confirmText: t('deploy.label.execute'), variant: 'destructive',
});
if (!ok) return;
setRunning(true); setExecResults([]);
@@ -77,38 +78,38 @@ export function RemoteExecPage() {
const res = await remoteExecApi.execute({ server_ids: selIds, command, timeout: timeoutSec });
setExecResults(res || []);
const succ = (res || []).filter(r => r.success).length;
toast.success(`执行完成:${succ}/${selCount} 成功`);
} catch { toast.error(i18n.t('automation.systemTaskFailed')); }
toast.success(t('task.remoteExec.toastExecResult', { success: succ, total: selCount }));
} catch { toast.error(t('automation.systemTaskFailed')); }
finally { setRunning(false); }
};
const doUpload = async () => {
if (!file) { toast.error(i18n.t('请选择要上传的文件')); return; }
if (!remotePath.trim()) { toast.error(i18n.t('请填写远程目标路径')); return; }
if (selCount === 0) { toast.error(i18n.t('请至少选择一台主机')); return; }
if (!file) { toast.error(t('task.remoteExec.toastFileRequired')); return; }
if (!remotePath.trim()) { toast.error(t('task.remoteExec.toastPathRequired')); return; }
if (selCount === 0) { toast.error(t('task.remoteExec.toastHostRequired')); return; }
const dst = remotePath.trim().endsWith('/') ? remotePath.trim() + file.name : remotePath.trim();
setRunning(true); setUploadResults([]);
try {
const res = await remoteExecApi.uploadFile({ server_ids: selIds, remote_path: dst, file });
setUploadResults(res || []);
const succ = (res || []).filter(r => r.success).length;
toast.success(`上传完成:${succ}/${selCount} 成功`);
} catch { toast.error(i18n.t('common.uploadFailed')); }
toast.success(t('task.remoteExec.toastUploadResult', { success: succ, total: selCount }));
} catch { toast.error(t('common.uploadFailed')); }
finally { setRunning(false); }
};
return (
<PageShell
variant="workspace"
title={i18n.t('automation.remoteExec')}
subtitle={i18n.t('关联主机后批量执行命令 / 上传文件(Go-native SSH)')}
title={t('automation.remoteExec')}
subtitle={t('task.remoteExec.subtitle')}
icon={<Terminal className="h-5 w-5" />}
contentClassName="flex-1 min-h-0"
loading={loading}
actions={(
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={load}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
刷新
{t('task.remoteExec.refresh')}
</Button>
)}
>
@@ -117,9 +118,9 @@ export function RemoteExecPage() {
<Card className="col-span-5 dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center justify-between text-[var(--dg-text-primary)]">
<span className="flex items-center gap-2"><ServerIcon className="h-4 w-4 dg-text-blue" /> {i18n.t('automation.remoteSelectHost')} </span>
<span className="flex items-center gap-2"><ServerIcon className="h-4 w-4 dg-text-blue" /> {t('automation.remoteSelectHost')} </span>
<Input
placeholder={i18n.t('搜索 主机名/IP')}
placeholder={t('task.remoteExec.searchPlaceholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && load()}
@@ -130,16 +131,16 @@ export function RemoteExecPage() {
<CardContent className="max-h-[520px] overflow-y-auto space-y-1">
{servers.length === 0 ? (
<PageEmpty
title={i18n.t('dashboard.serversStatus.empty')}
description={i18n.t('请先在「服务器管理」添加')}
title={t('dashboard.serversStatus.empty')}
description={t('task.remoteExec.emptyHint')}
className="py-10"
/>
) : (
<>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs dg-text-secondary border-b border-[var(--dg-border)]">
<Checkbox checked={selected.size === servers.length && servers.length > 0} onCheckedChange={toggleAll} />
全选({servers.length} 台)
{selCount > 0 && <Badge className="ml-auto bg-blue-500/20 dg-text-blue">已选 {selCount}</Badge>}
{t('task.remoteExec.selectAllCount', { count: servers.length })}
{selCount > 0 && <Badge className="ml-auto bg-blue-500/20 dg-text-blue">{t('task.remoteExec.selectedCount', { count: selCount })}</Badge>}
</div>
{servers.map(s => (
<label key={s.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-[var(--dg-bg-tertiary)] cursor-pointer">
@@ -149,8 +150,8 @@ export function RemoteExecPage() {
<div className="text-[10px] text-[var(--dg-text-muted)] font-mono">{s.ip} · {s.username || 'root'}</div>
</div>
{s.has_auth === false
? <Badge className="bg-amber-500/20 dg-text-yellow text-[10px]"> {i18n.t('密钥')} </Badge>
: <Badge className="bg-emerald-500/20 dg-text-green text-[10px]">{s.auth_type === 'key' ? i18n.t('system.credentialKey') : i18n.t('system.passwordLabel')}</Badge>}
? <Badge className="bg-amber-500/20 dg-text-yellow text-[10px]"> {t('system.credentialKey')} </Badge>
: <Badge className="bg-emerald-500/20 dg-text-green text-[10px]">{s.auth_type === 'key' ? t('system.credentialKey') : t('system.passwordLabel')}</Badge>}
</label>
))}
</>
@@ -167,7 +168,7 @@ export function RemoteExecPage() {
onClick={() => setTab('exec')}
className={tab === 'exec' ? 'dg-btn-primary h-8 text-xs gap-1' : 'dg-btn-secondary h-8 text-xs gap-1'}
>
<Terminal className="w-3.5 h-3.5" />执行命令
<Terminal className="w-3.5 h-3.5" />{t('task.remoteExec.tabExec')}
</Button>
<Button
variant={tab === 'upload' ? 'default' : 'outline'}
@@ -175,50 +176,50 @@ export function RemoteExecPage() {
onClick={() => setTab('upload')}
className={tab === 'upload' ? 'dg-btn-primary h-8 text-xs gap-1' : 'dg-btn-secondary h-8 text-xs gap-1'}
>
<Upload className="w-3.5 h-3.5" />上传文件
<Upload className="w-3.5 h-3.5" />{t('task.remoteExec.tabUpload')}
</Button>
</div>
{tab === 'exec' ? (
<Card className="dg-card">
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {i18n.t('task.remoteexecpaget.seb6e67f1')} </CardTitle></CardHeader>
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {t('task.remoteExec.execTitle')} </CardTitle></CardHeader>
<CardContent className="space-y-3">
<textarea
value={command}
onChange={(e) => setCommand(e.target.value)}
rows={5}
placeholder={i18n.t('输入要在选中主机上执行的 shell 命令,例如:df -h | head -5')}
placeholder={t('task.remoteExec.execPlaceholder')}
className="w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md px-3 py-2 text-xs font-mono text-[var(--dg-text-primary)] resize-none"
/>
<div className="flex items-center gap-3">
<span className="text-xs dg-text-secondary"> {i18n.t('automation.remoteTimeout')} </span>
<span className="text-xs dg-text-secondary"> {t('automation.remoteTimeout')} </span>
<Input type="number" min={1} value={timeoutSec} onChange={(e) => setTimeoutSec(Number(e.target.value) || 60)}
className="h-8 w-24 text-xs dg-input" />
<Button onClick={doExec} disabled={running} className="ml-auto dg-btn-primary h-8 text-xs">
{running ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Terminal className="w-3.5 h-3.5 mr-1" />}
执行({selCount} 台)
{t('task.remoteExec.execButton', { count: selCount })}
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="dg-card">
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {i18n.t('task.remoteexecpaget.s30048caa')} </CardTitle></CardHeader>
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {t('task.remoteExec.uploadTitle')} </CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<span className="text-xs dg-text-secondary"> {i18n.t('task.remoteexecpaget.sb7e21a1e')} </span>
<span className="text-xs dg-text-secondary"> {t('task.remoteExec.uploadFileLabel')} </span>
<Input type="file" onChange={(e) => setFile(e.target.files?.[0] || null)}
className="h-8 text-xs dg-input" />
{file && <span className="text-[10px] text-[var(--dg-text-muted)]">{file.name} ({(file.size / 1024).toFixed(1)} KB)</span>}
</div>
<div className="space-y-1.5">
<span className="text-xs dg-text-secondary"> {i18n.t('task.remoteexecpaget.sb47f0323')} </span>
<Input value={remotePath} onChange={(e) => setRemotePath(e.target.value)} placeholder={i18n.t('/tmp/ 或 /opt/app/config.yaml')}
<span className="text-xs dg-text-secondary"> {t('task.remoteExec.uploadPathLabel')} </span>
<Input value={remotePath} onChange={(e) => setRemotePath(e.target.value)} placeholder={t('task.remoteExec.uploadPathPlaceholder')}
className="h-8 text-xs font-mono dg-input" />
</div>
<Button onClick={doUpload} disabled={running} className="dg-btn-primary h-8 text-xs">
{running ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileUp className="w-3.5 h-3.5 mr-1" />}
上传({selCount} 台)
{t('task.remoteExec.uploadButton', { count: selCount })}
</Button>
</CardContent>
</Card>
@@ -226,7 +227,7 @@ export function RemoteExecPage() {
{(execResults.length > 0 || uploadResults.length > 0) && (
<Card className="dg-card">
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {i18n.t('automation.remoteExecResult')} </CardTitle></CardHeader>
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]"> {t('automation.remoteExecResult')} </CardTitle></CardHeader>
<CardContent className="space-y-2 max-h-[300px] overflow-y-auto">
{execResults.map((r, i) => (
<div key={i} className="p-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
@@ -238,7 +239,7 @@ export function RemoteExecPage() {
</Badge>
</div>
<pre className="text-[10px] font-mono dg-text-secondary whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
{r.error || r.output || i18n.t('(无输出)')}
{r.error || r.output || t('task.remoteExec.noOutput')}
</pre>
</div>
))}
+35 -33
View File
@@ -1,5 +1,6 @@
// ScriptManager — 脚本库主组件 // Logan.Li
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Search, RotateCcw, Trash2, FileCode } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -23,9 +24,9 @@ import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
export function ScriptManager() {
const { t } = useTranslation();
const _confirm = useConfirm();
const { hasAnyPermission, isAdmin } = usePermission();
const canWrite = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
@@ -52,7 +53,7 @@ export function ScriptManager() {
const items = (res as JsonData)?.list || [];
setScripts(items.map(mapScript));
} catch {
toast.error(i18n.t('获取脚本列表失败'));
toast.error(t('task.scriptList.toastFetchFailed'));
} finally {
setLoading(false);
}
@@ -73,6 +74,7 @@ export function ScriptManager() {
useEffect(() => {
fetchScripts();
fetchRuntime();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openCreate = () => {
@@ -113,7 +115,7 @@ export function ScriptManager() {
const handleSave = async () => {
if (!formData.name.trim() || !formData.content.trim()) {
toast.error(i18n.t('获取脚本列表失败'));
toast.error(t('task.scriptList.toastNameRequired'));
return;
}
const payload = {
@@ -127,49 +129,49 @@ export function ScriptManager() {
try {
if (editingId !== null) {
await scriptApi.updateScript(editingId, payload);
toast.success(i18n.t('脚本更新成功'));
toast.success(t('task.scriptList.toastSaved'));
} else {
await scriptApi.createScript(payload);
toast.success(i18n.t('脚本更新成功'));
toast.success(t('task.scriptList.toastSaved'));
}
setEditorOpen(false);
fetchScripts();
} catch (e) {
toast.error(e instanceof Error ? e.message : (editingId !== null ? i18n.t('kubernetes.deployment.imageUpdateFailed') : i18n.t('monitor.dashboard.custom.toastCreateFailed')));
toast.error(e instanceof Error ? e.message : (editingId !== null ? t('kubernetes.deployment.imageUpdateFailed') : t('monitor.dashboard.custom.toastCreateFailed')));
}
};
const handleClone = async (sc: Script) => {
try {
await scriptApi.createScript({
name: `${sc.name} (副本)`,
name: `${sc.name} ${t('task.scriptList.copySuffix')}`,
code: genCode(),
description: sc.description,
type: sc.type,
content: sc.content,
timeout: sc.timeout,
});
toast.success(i18n.t('common.copySuccess'));
toast.success(t('common.copySuccess'));
fetchScripts();
} catch (e) {
toast.error(e instanceof Error ? e.message : i18n.t('monitor.logPanel.copyFailed'));
toast.error(e instanceof Error ? e.message : t('monitor.logPanel.copyFailed'));
}
};
const handleDelete = async (sc: Script) => {
const ok = await _confirm({
title: i18n.t('automation.deleteScript'),
description: `确定删除「${sc.name}」吗?此操作不可恢复。`,
confirmText: i18n.t('kubernetes.storage.delete2'),
title: t('task.scriptList.delete'),
description: t('task.scriptList.confirmDelete', { name: sc.name }),
confirmText: t('kubernetes.storage.delete2'),
variant: 'destructive',
});
if (!ok) return;
try {
await scriptApi.deleteScript(sc.id);
toast.success(i18n.t('kubernetes.msg.deleteSuccess'));
toast.success(t('kubernetes.msg.deleteSuccess'));
fetchScripts();
} catch (e) {
toast.error(e instanceof Error ? e.message : i18n.t('kubernetes.msg.deleteFailed2'));
toast.error(e instanceof Error ? e.message : t('kubernetes.msg.deleteFailed2'));
}
};
@@ -194,19 +196,19 @@ export function ScriptManager() {
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return;
const ok = await _confirm({
title: i18n.t('删除脚本'),
description: `确定删除选中的 ${selectedIds.length} 个脚本吗?此操作不可恢复。`,
confirmText: i18n.t('system.terminalBatchDelete'),
title: t('task.scriptList.delete'),
description: t('task.scriptList.batchDeleteConfirm', { count: selectedIds.length }),
confirmText: t('system.terminalBatchDelete'),
variant: 'destructive',
});
if (!ok) return;
try {
await scriptApi.batchDelete(selectedIds);
toast.success(`已删除 ${selectedIds.length} 个脚本`);
toast.success(t('task.scriptList.toastDeleted', { count: selectedIds.length }));
setSelectedIds([]);
fetchScripts();
} catch (e) {
toast.error(e instanceof Error ? e.message : i18n.t('system.batchDeleteFailed'));
toast.error(e instanceof Error ? e.message : t('system.batchDeleteFailed'));
}
};
@@ -216,7 +218,7 @@ export function ScriptManager() {
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 dg-text-muted" />
<Input
placeholder={i18n.t('deploy.label.searchPlaceholder')}
placeholder={t('deploy.label.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 dg-input"
@@ -224,10 +226,10 @@ export function ScriptManager() {
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px] dg-input">
<SelectValue placeholder={i18n.t('system.credentialType')} />
<SelectValue placeholder={t('system.credentialType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> {i18n.t('k8s.topology.allTypes')} </SelectItem>
<SelectItem value="all"> {t('k8s.topology.allTypes')} </SelectItem>
{scriptTypes.map(t => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
@@ -241,8 +243,8 @@ export function ScriptManager() {
<>
<PageShell
variant="list"
title={i18n.t('automation.scripts')}
subtitle={i18n.t('脚本编辑与管理')}
title={t('automation.scripts')}
subtitle={t('task.scriptList.subtitle')}
icon={<FileCode className="h-5 w-5" />}
toolbar={toolbar}
loading={loading}
@@ -250,12 +252,12 @@ export function ScriptManager() {
<>
<Button variant="outline" className="dg-btn-secondary" onClick={fetchScripts}>
<RotateCcw className="w-4 h-4 mr-2" />
刷新
{t('task.scriptList.refresh')}
</Button>
{canWrite && (
<Button className="dg-btn-primary" onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" />
新建脚本
{t('task.scriptList.create')}
</Button>
)}
</>
@@ -273,12 +275,12 @@ export function ScriptManager() {
className="border-[var(--dg-border)]"
/>
<span className="text-sm dg-text-secondary">
已选择 {selectedIds.length} 项 / 共 {filteredScripts.length} 项
{t('task.scriptList.selectedCount', { selected: selectedIds.length, total: filteredScripts.length })}
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => setSelectedIds([])}>
取消选择
{t('task.scriptList.cancelSelect')}
</Button>
<Button
variant="outline"
@@ -287,7 +289,7 @@ export function ScriptManager() {
onClick={handleBatchDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
批量删除
{t('task.scriptList.batchDelete')}
</Button>
</div>
</div>
@@ -295,12 +297,12 @@ export function ScriptManager() {
{filteredScripts.length === 0 ? (
<PageEmpty
title={scripts.length === 0 ? i18n.t('automation.noScript') : i18n.t('暂无脚本')}
description={scripts.length === 0 ? i18n.t('点击「新建脚本」创建第一个') : undefined}
title={scripts.length === 0 ? t('automation.noScript') : t('task.scriptList.empty')}
description={scripts.length === 0 ? t('task.scriptList.emptyHint') : undefined}
action={scripts.length === 0 && canWrite ? (
<Button className="dg-btn-primary" onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" />
新建脚本
{t('task.scriptList.create')}
</Button>
) : undefined}
/>
+97 -28
View File
@@ -4520,6 +4520,56 @@
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago"
},
"form": {
"titleCreate": "Create Cron Job",
"titleEdit": "Edit Cron Job",
"desc": "Configure scheduled tasks with script association or custom commands",
"labelName": "Job Name",
"labelDesc": "Description",
"labelCronExpr": "Cron Expression",
"labelJobType": "Execution Type",
"labelScript": "Select Script",
"labelCommand": "Command",
"placeholderName": "e.g., Database backup",
"placeholderCommand": "/scripts/backup.sh arg1 arg2",
"quickSelect": "Quick Select",
"cronHint": "Format: minute hour day month weekday",
"noScriptType": "No {{type}} scripts found in script library",
"noScriptHint": "Please create a {{type}} script in Script Library first",
"selectScriptPlaceholder": "Select a script from library...",
"emptyScript": "(empty script)",
"scriptLines": "{{count}} lines",
"cancel": "Cancel",
"toastNameRequired": "Please fill in job name and Cron expression",
"toastSaved": "Job saved successfully"
},
"typeShell": "Link to Shell scripts from the script library",
"typePython": "Link to Python scripts from the script library",
"typePowershell": "Link to PowerShell scripts from the script library",
"typeCommandDesc": "Manually enter command or script path",
"typeCommandLabel": "Custom Command",
"list": {
"subtitle": "Cron Scheduler · Script Execution · Built-in System Tasks",
"searchPlaceholder": "Search name, description, script...",
"filterStatus": "Status",
"filterHealth": "Health",
"filterType": "Type",
"selectAll": "Select All",
"batchDelete": "Delete",
"clearFilter": "Clear Filters",
"filterResult": "Filtered {{shown}}/{{total}}",
"selectedCount": "{{count}} selected",
"allHistory": "All History",
"refresh": "Refresh",
"create": "Create",
"createTask": "New Task",
"delete": "Delete Cron Job",
"toastToggled": "Status toggled",
"toastDeleted": "{{count}} jobs deleted",
"batchDeleteConfirm": "Delete {{count}} selected jobs? This cannot be undone.",
"confirmDelete": "Delete \"{{name}}\"? This cannot be undone.",
"execDuration": "Duration: {{ms}}ms"
}
},
"script": {
@@ -4566,34 +4616,53 @@
"noPython": "Python is not installed. Python scripts will not be executable.",
"partialUnavailable": "Some interpreters are unavailable; scripts of those types cannot run after creation."
},
"remoteexecpaget.seb6e67f1": "Batch Execute Commands",
"remoteexecpaget.s30048caa": "Batch Upload Files",
"remoteexecpaget.sb7e21a1e": "Select Local File",
"remoteexecpaget.sb47f0323": "Remote target path (filename auto-appended for directories)",
"cronjobformdial.s2e10ee70": "Configure Cron Job",
"cronjobformdial.s3b56b1f9": "Format: min hour day month weekday",
"cronjobformdial.s15d864c1": "Execution Type",
"systemtaskspanel.s4d5e6f7g": "Built-in",
"systemtaskspanel.s5e6f7g8h": "Process-level built-in scheduling, not editable; cleanup and cost tasks support manual trigger",
"systemtaskspanel.s6f7g8h9i": "{{count}} tasks",
"systemtaskspanel.s7g8h9i0j": "{{count}} failed",
"systemtaskspanel.s8h9i0j1k": "Task",
"systemtaskspanel.s9i0j1k2l": "Schedule / Description",
"systemtaskspanel.s0j1k2l3m": "Last Run",
"systemtaskspanel.s1k2l3m4n": "Actions",
"systemtaskspanel.s2l3m4n5o": "Retain {{days}} days",
"systemtaskspanel.s3m4n5o6p": "{{count}} runs",
"systemtaskspanel.s4n5o6p7q": "Auto",
"systemtaskspanel.s5o6p7q8r": "Run Now",
"systemtaskspanel.s6p7q8r9s": "Manually run system task '{{name}}'?",
"systemtaskspanel.s7q8r9s0t": "Execute",
"systemtaskspanel.s8r9s0t1u": "Execution completed",
"systemtaskspanel.s9s0t1u2v": "Execution failed",
"systemtaskspanel.s0t1u2v3w": "Failed to load system tasks",
"systemtaskspanel.s1u2v3w4x": "No system tasks",
"systemtaskspanel.monitor": "Monitor",
"systemtaskspanel.cleanup": "Cleanup",
"systemtaskspanel.finops": "Cost"
"scriptList": {
"subtitle": "Script editing and management",
"refresh": "Refresh",
"create": "New Script",
"batchDelete": "Batch Delete",
"cancelSelect": "Cancel Selection",
"selectedCount": "{{selected}} selected / {{total}} total",
"empty": "No scripts",
"emptyHint": "Click \"New Script\" to create your first one",
"delete": "Delete Script",
"confirmDelete": "Delete \"{{name}}\"? This cannot be undone.",
"batchDeleteConfirm": "Delete {{count}} selected scripts? This cannot be undone.",
"toastDeleted": "{{count}} scripts deleted",
"toastFetchFailed": "Failed to fetch script list",
"toastSaved": "Script saved successfully",
"toastNameRequired": "Please fill in script name and content",
"copySuffix": "(copy)"
},
"remoteExec": {
"subtitle": "Batch execute commands / upload files via native SSH",
"refresh": "Refresh",
"searchPlaceholder": "Search hostname/IP",
"emptyHint": "Add servers in Server Management first",
"selectAllCount": "All ({{count}})",
"selectedCount": "{{count}} selected",
"tabExec": "Execute Command",
"tabUpload": "Upload File",
"execTitle": "Batch Execute Commands",
"execPlaceholder": "Enter shell command to run on selected hosts, e.g.: df -h | head -5",
"execButton": "Execute ({{count}} hosts)",
"execResultTitle": "Results",
"uploadTitle": "Batch Upload Files",
"uploadFileLabel": "Select Local File",
"uploadPathLabel": "Remote target path (directories auto-append filename)",
"uploadPathPlaceholder": "/tmp/ or /opt/app/config.yaml",
"uploadButton": "Upload ({{count}} hosts)",
"toastFetchFailed": "Failed to load server list",
"toastCommandRequired": "Please enter a command to execute",
"toastHostRequired": "Please select at least one host",
"toastFileRequired": "Please select a file to upload",
"toastPathRequired": "Please enter a remote target path",
"toastExecResult": "Execution complete: {{success}}/{{total}} succeeded",
"toastUploadResult": "Upload complete: {{success}}/{{total}} succeeded",
"toastExecConfirm": "Confirm batch execution",
"toastExecConfirmDesc": "This will execute the command on {{count}} hosts. Please verify the command is safe:\n\n{{command}}",
"noOutput": "(no output)"
}
},
"rbac": {
"logRole": {
+97 -28
View File
@@ -4520,6 +4520,56 @@
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
},
"form": {
"titleCreate": "新建定时任务",
"titleEdit": "编辑定时任务",
"desc": "配置 Cron 定时任务,支持脚本关联和自定义命令",
"labelName": "任务名称",
"labelDesc": "描述",
"labelCronExpr": "Cron 表达式",
"labelJobType": "执行类型",
"labelScript": "选择脚本",
"labelCommand": "命令",
"placeholderName": "例如: 数据库备份",
"placeholderCommand": "/scripts/backup.sh arg1 arg2",
"quickSelect": "快速选择",
"cronHint": "格式: 分 时 日 月 周",
"noScriptType": "脚本库中没有 {{type}} 脚本",
"noScriptHint": "请先到「脚本库」创建 {{type}} 脚本",
"selectScriptPlaceholder": "选择脚本库中的脚本...",
"emptyScript": "(空脚本)",
"scriptLines": "{{count}} 行",
"cancel": "取消",
"toastNameRequired": "请填写任务名称和 Cron 表达式",
"toastSaved": "任务更新成功"
},
"typeShell": "关联脚本库中的 Shell 脚本",
"typePython": "关联脚本库中的 Python 脚本",
"typePowershell": "关联脚本库中的 PowerShell 脚本",
"typeCommandDesc": "手动输入命令或脚本路径",
"typeCommandLabel": "自定义命令",
"list": {
"subtitle": "Cron 调度 · 脚本执行 · 内置系统任务",
"searchPlaceholder": "搜索名称、描述、脚本…",
"filterStatus": "状态",
"filterHealth": "健康",
"filterType": "类型",
"selectAll": "全选",
"batchDelete": "删除",
"clearFilter": "清除筛选",
"filterResult": "筛选结果 {{shown}}/{{total}}",
"selectedCount": "已选 {{count}}",
"allHistory": "全部历史",
"refresh": "刷新",
"create": "新建",
"createTask": "新建任务",
"delete": "删除定时任务",
"toastToggled": "状态已切换",
"toastDeleted": "已删除 {{count}} 个任务",
"batchDeleteConfirm": "确定删除选中的 {{count}} 个任务吗?此操作不可恢复。",
"confirmDelete": "确定删除「{{name}}」吗?此操作不可恢复。",
"execDuration": "耗时 {{ms}}ms"
}
},
"script": {
@@ -4566,34 +4616,53 @@
"noPython": "Python 未安装,Python 类型脚本将无法执行。",
"partialUnavailable": "部分解释器不可用,对应类型的脚本创建后无法执行。"
},
"remoteexecpaget.seb6e67f1": "批量执行命令",
"remoteexecpaget.s30048caa": "批量上传文件",
"remoteexecpaget.sb7e21a1e": "选择本地文件",
"remoteexecpaget.sb47f0323": "远程目标路径(目录则自动追加文件名)",
"cronjobformdial.s2e10ee70": "配置 Cron 定时任务",
"cronjobformdial.s3b56b1f9": "格式: 分 时 日 月 周",
"cronjobformdial.s15d864c1": "执行类型",
"systemtaskspanel.s4d5e6f7g": "内置",
"systemtaskspanel.s5e6f7g8h": "进程内置调度,不可编辑;清理与成本类任务支持手动触发",
"systemtaskspanel.s6f7g8h9i": "{{count}} 个任务",
"systemtaskspanel.s7g8h9i0j": "{{count}} 个失败",
"systemtaskspanel.s8h9i0j1k": "任务",
"systemtaskspanel.s9i0j1k2l": "调度 / 说明",
"systemtaskspanel.s0j1k2l3m": "最近执行",
"systemtaskspanel.s1k2l3m4n": "操作",
"systemtaskspanel.s2l3m4n5o": "保留 {{days}} 天",
"systemtaskspanel.s3m4n5o6p": "{{count}} 次",
"systemtaskspanel.s4n5o6p7q": "自动",
"systemtaskspanel.s5o6p7q8r": "立即执行",
"systemtaskspanel.s6p7q8r9s": "确定手动执行系统任务「{{name}}」吗?",
"systemtaskspanel.s7q8r9s0t": "执行",
"systemtaskspanel.s8r9s0t1u": "执行完成",
"systemtaskspanel.s9s0t1u2v": "执行失败",
"systemtaskspanel.s0t1u2v3w": "加载系统任务失败",
"systemtaskspanel.s1u2v3w4x": "暂无系统任务",
"systemtaskspanel.monitor": "监控",
"systemtaskspanel.cleanup": "清理",
"systemtaskspanel.finops": "成本"
"scriptList": {
"subtitle": "脚本编辑与管理",
"refresh": "刷新",
"create": "新建脚本",
"batchDelete": "批量删除",
"cancelSelect": "取消选择",
"selectedCount": "已选择 {{selected}} 项 / 共 {{total}} 项",
"empty": "暂无脚本",
"emptyHint": "点击「新建脚本」创建第一个",
"delete": "删除脚本",
"confirmDelete": "确定删除「{{name}}」吗?此操作不可恢复。",
"batchDeleteConfirm": "确定删除选中的 {{count}} 个脚本吗?此操作不可恢复。",
"toastDeleted": "已删除 {{count}} 个脚本",
"toastFetchFailed": "获取脚本列表失败",
"toastSaved": "脚本更新成功",
"toastNameRequired": "请填写脚本名称和内容",
"copySuffix": "(副本)"
},
"remoteExec": {
"subtitle": "关联主机后批量执行命令 / 上传文件(Go-native SSH)",
"refresh": "刷新",
"searchPlaceholder": "搜索 主机名/IP",
"emptyHint": "请先在「服务器管理」添加",
"selectAllCount": "全选({{count}} 台)",
"selectedCount": "已选 {{count}}",
"tabExec": "执行命令",
"tabUpload": "上传文件",
"execTitle": "批量执行命令",
"execPlaceholder": "输入要在选中主机上执行的 shell 命令,例如:df -h | head -5",
"execButton": "执行({{count}} 台)",
"execResultTitle": "执行结果",
"uploadTitle": "批量上传文件",
"uploadFileLabel": "选择本地文件",
"uploadPathLabel": "远程目标路径(目录则自动追加文件名)",
"uploadPathPlaceholder": "/tmp/ 或 /opt/app/config.yaml",
"uploadButton": "上传({{count}} 台)",
"toastFetchFailed": "加载服务器列表失败",
"toastCommandRequired": "请输入要执行的命令",
"toastHostRequired": "请至少选择一台主机",
"toastFileRequired": "请选择要上传的文件",
"toastPathRequired": "请填写远程目标路径",
"toastExecResult": "执行完成:{{success}}/{{total}} 成功",
"toastUploadResult": "上传完成:{{success}}/{{total}} 成功",
"toastExecConfirm": "确认批量执行命令",
"toastExecConfirmDesc": "将在 {{count}} 台主机上执行该命令,请确认命令安全:\n\n{{command}}",
"noOutput": "(无输出)"
}
},
"rbac": {
"logRole": {