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:
1 parent
a8f6fd190b
commit
de6f9454fc
7 files changed
+361
-229
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user