- 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 集成回归测试通过
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
// ScriptManager — 脚本库主组件 // Logan.Li
|
|
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';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { toast } from 'sonner';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import { scriptApi } from '@/api';
|
|
import type { ScriptRuntimeInfo } from '@/api/task/script';
|
|
import { usePermission } from '@/hooks/usePermission';
|
|
import {
|
|
type Script, mapScript, scriptTypes, genCode, availableScriptTypes,
|
|
} from './script-helpers';
|
|
import { ScriptRuntimeBanner } from './ScriptRuntimeBanner';
|
|
import type { ScriptFormData } from './ScriptFormDialog';
|
|
import ScriptFormDialog from './ScriptFormDialog';
|
|
import { ScriptTable } from './ScriptTable';
|
|
import { PageShell } from '@/components/layout/PageShell';
|
|
import { ContentPanel } from '@/components/layout/ContentPanel';
|
|
import { PageEmpty } from '@/components/layout/PageEmpty';
|
|
import type { JsonData } from '@/types/common';
|
|
|
|
export function ScriptManager() {
|
|
const { t } = useTranslation();
|
|
const _confirm = useConfirm();
|
|
const { hasAnyPermission, isAdmin } = usePermission();
|
|
const canWrite = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
|
|
const [scripts, setScripts] = useState<Script[]>([]);
|
|
const [runtime, setRuntime] = useState<ScriptRuntimeInfo | null>(null);
|
|
const [runtimeLoading, setRuntimeLoading] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
|
|
|
|
const [formData, setFormData] = useState<ScriptFormData>({
|
|
name: '', description: '', content: '', type: 'shell', timeout: 300,
|
|
});
|
|
|
|
const fetchScripts = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await scriptApi.getScripts({ page_size: 100 });
|
|
const items = (res as JsonData)?.list || [];
|
|
setScripts(items.map(mapScript));
|
|
} catch {
|
|
toast.error(t('task.scriptList.toastFetchFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchRuntime = async () => {
|
|
setRuntimeLoading(true);
|
|
try {
|
|
const rt = await scriptApi.getRuntime();
|
|
setRuntime(rt);
|
|
} catch {
|
|
// 静默失败
|
|
} finally {
|
|
setRuntimeLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchScripts();
|
|
fetchRuntime();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const openCreate = () => {
|
|
const types = availableScriptTypes(runtime);
|
|
const first = types.find((t) => !t.disabled) ?? types[0] ?? scriptTypes[0];
|
|
setEditingId(null);
|
|
setFormData({
|
|
name: '', description: '', content: first.sample,
|
|
type: first.value, timeout: 300,
|
|
});
|
|
setEditorOpen(true);
|
|
};
|
|
|
|
const openEdit = (sc: Script) => {
|
|
setEditingId(sc.id);
|
|
setSelectedScript(sc);
|
|
setFormData({
|
|
name: sc.name,
|
|
description: sc.description,
|
|
content: sc.content,
|
|
type: sc.type,
|
|
timeout: sc.timeout,
|
|
});
|
|
setEditorOpen(true);
|
|
};
|
|
|
|
const handleTypeChange = (t: string) => {
|
|
setFormData((prev) => {
|
|
const isDefaultSample = scriptTypes.some(s => s.sample === prev.content) || prev.content === '';
|
|
const match = scriptTypes.find(s => s.value === t);
|
|
return {
|
|
...prev,
|
|
type: t,
|
|
content: editingId === null && isDefaultSample && match ? match.sample : prev.content,
|
|
};
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!formData.name.trim() || !formData.content.trim()) {
|
|
toast.error(t('task.scriptList.toastNameRequired'));
|
|
return;
|
|
}
|
|
const payload = {
|
|
name: formData.name.trim(),
|
|
code: genCode(),
|
|
description: formData.description,
|
|
type: formData.type,
|
|
content: formData.content,
|
|
timeout: Number(formData.timeout) || 300,
|
|
};
|
|
try {
|
|
if (editingId !== null) {
|
|
await scriptApi.updateScript(editingId, payload);
|
|
toast.success(t('task.scriptList.toastSaved'));
|
|
} else {
|
|
await scriptApi.createScript(payload);
|
|
toast.success(t('task.scriptList.toastSaved'));
|
|
}
|
|
setEditorOpen(false);
|
|
fetchScripts();
|
|
} catch (e) {
|
|
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} ${t('task.scriptList.copySuffix')}`,
|
|
code: genCode(),
|
|
description: sc.description,
|
|
type: sc.type,
|
|
content: sc.content,
|
|
timeout: sc.timeout,
|
|
});
|
|
toast.success(t('common.copySuccess'));
|
|
fetchScripts();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t('monitor.logPanel.copyFailed'));
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (sc: Script) => {
|
|
const ok = await _confirm({
|
|
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(t('kubernetes.msg.deleteSuccess'));
|
|
fetchScripts();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t('kubernetes.msg.deleteFailed2'));
|
|
}
|
|
};
|
|
|
|
const filteredScripts = scripts.filter(sc => {
|
|
const q = searchQuery.toLowerCase();
|
|
const matchSearch = sc.name.toLowerCase().includes(q) ||
|
|
sc.description.toLowerCase().includes(q);
|
|
const matchType = typeFilter === 'all' || sc.type === typeFilter;
|
|
return matchSearch && matchType;
|
|
});
|
|
|
|
const toggleSelect = (id: number) => {
|
|
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
|
|
};
|
|
|
|
const allSelected = filteredScripts.length > 0 && selectedIds.length === filteredScripts.length;
|
|
|
|
const toggleSelectAll = () => {
|
|
setSelectedIds(allSelected ? [] : filteredScripts.map(sc => sc.id));
|
|
};
|
|
|
|
const handleBatchDelete = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
const ok = await _confirm({
|
|
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(t('task.scriptList.toastDeleted', { count: selectedIds.length }));
|
|
setSelectedIds([]);
|
|
fetchScripts();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t('system.batchDeleteFailed'));
|
|
}
|
|
};
|
|
|
|
const toolbar = (
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
|
<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={t('deploy.label.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 dg-input"
|
|
/>
|
|
</div>
|
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
<SelectTrigger className="w-[140px] dg-input">
|
|
<SelectValue placeholder={t('system.credentialType')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all"> {t('k8s.topology.allTypes')} </SelectItem>
|
|
{scriptTypes.map(t => (
|
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageShell
|
|
variant="list"
|
|
title={t('automation.scripts')}
|
|
subtitle={t('task.scriptList.subtitle')}
|
|
icon={<FileCode className="h-5 w-5" />}
|
|
toolbar={toolbar}
|
|
loading={loading}
|
|
actions={(
|
|
<>
|
|
<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>
|
|
)}
|
|
</>
|
|
)}
|
|
>
|
|
<div className="mb-4">
|
|
<ScriptRuntimeBanner runtime={runtime} loading={runtimeLoading} />
|
|
</div>
|
|
{selectedIds.length > 0 && (
|
|
<div className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<Checkbox
|
|
checked={allSelected}
|
|
onCheckedChange={toggleSelectAll}
|
|
className="border-[var(--dg-border)]"
|
|
/>
|
|
<span className="text-sm dg-text-secondary">
|
|
{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"
|
|
size="sm"
|
|
className="dg-btn-danger dg-btn-sm"
|
|
onClick={handleBatchDelete}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
{t('task.scriptList.batchDelete')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{filteredScripts.length === 0 ? (
|
|
<PageEmpty
|
|
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}
|
|
/>
|
|
) : (
|
|
<ContentPanel>
|
|
<ScriptTable
|
|
scripts={filteredScripts}
|
|
selectedIds={selectedIds}
|
|
onToggleSelect={toggleSelect}
|
|
onToggleSelectAll={toggleSelectAll}
|
|
allSelected={allSelected}
|
|
onEdit={openEdit}
|
|
onClone={handleClone}
|
|
onDelete={handleDelete}
|
|
/>
|
|
</ContentPanel>
|
|
)}
|
|
</PageShell>
|
|
|
|
<ScriptFormDialog
|
|
open={editorOpen}
|
|
onOpenChange={setEditorOpen}
|
|
editingId={editingId}
|
|
editingCode={selectedScript?.code || ''}
|
|
formData={formData}
|
|
setFormData={setFormData}
|
|
onTypeChange={handleTypeChange}
|
|
onSave={handleSave}
|
|
runtime={runtime}
|
|
/>
|
|
</>
|
|
);
|
|
} |