feat: 全站 PageShell 设计系统与专业 UI 统一

- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架
- index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩)
- 全站路由页接入 PageShell,云账单三 Tab 为标杆
- 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化
- 登录页/侧栏/顶栏去渐变光球,企业克制风
- 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
Logan committed 2026-07-01 12:45:38 +08:00
1 parent 1acb0c49af
commit e363ded445
213 files changed
+7148 -6818

No files matched your search

+60 -55
View File
@@ -12,6 +12,8 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { serverApi, remoteExecApi, type RemoteExecResult, type RemoteUploadResult } from '@/api';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
interface RemoteServer {
@@ -26,13 +28,10 @@ export function RemoteExecPage() {
const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'exec' | 'upload'>('exec');
// 执行命令
const [command, setCommand] = useState('');
const [timeoutSec, setTimeoutSec] = useState(60);
// 上传文件
const [file, setFile] = useState<File | null>(null);
const [remotePath, setRemotePath] = useState('/tmp/');
// 结果 + 运行态
const [execResults, setExecResults] = useState<RemoteExecResult[]>([]);
const [uploadResults, setUploadResults] = useState<RemoteUploadResult[]>([]);
const [running, setRunning] = useState(false);
@@ -58,7 +57,6 @@ export function RemoteExecPage() {
setSelected(selected.size === servers.length ? new Set() : new Set(servers.map(s => s.id)));
};
// 批量执行命令(高危,需确认)
const doExec = async () => {
if (!command.trim()) { toast.error('请输入要执行的命令'); return; }
if (selCount === 0) { toast.error('请至少选择一台主机'); return; }
@@ -78,12 +76,10 @@ export function RemoteExecPage() {
finally { setRunning(false); }
};
// 批量上传文件
const doUpload = async () => {
if (!file) { toast.error('请选择要上传的文件'); return; }
if (!remotePath.trim()) { toast.error('请填写远程目标路径'); return; }
if (selCount === 0) { toast.error('请至少选择一台主机'); return; }
// 远程路径若是目录则追加文件名
const dst = remotePath.trim().endsWith('/') ? remotePath.trim() + file.name : remotePath.trim();
setRunning(true); setUploadResults([]);
try {
@@ -96,47 +92,48 @@ export function RemoteExecPage() {
};
return (
<div className="space-y-4">
{/* 标题栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[var(--dg-text-primary)]">
<Terminal className="h-5 w-5 text-blue-400" />
<span className="text-lg font-medium">远程执行</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 关联主机后批量执行命令 / 上传文件(Go-native SSH)</span>
</div>
<Button variant="outline" size="sm" onClick={load} className="h-8 text-xs gap-1">
<RefreshCw className="w-3.5 h-3.5" />刷新
<PageShell
variant="workspace"
title="远程执行"
subtitle="关联主机后批量执行命令 / 上传文件(Go-native SSH)"
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" />
刷新
</Button>
</div>
<div className="grid grid-cols-12 gap-4">
)}
>
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
{/* 左侧:主机选择 */}
<Card className="col-span-5 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<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 text-blue-400" />选择目标主机</span>
<span className="flex items-center gap-2"><ServerIcon className="h-4 w-4 dg-text-blue" />选择目标主机</span>
<Input
placeholder="搜索 主机名/IP"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && load()}
className="h-7 w-40 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
className="h-7 w-40 text-xs dg-input"
/>
</CardTitle>
</CardHeader>
<CardContent className="max-h-[520px] overflow-y-auto space-y-1">
{loading ? (
<div className="flex items-center justify-center py-10 text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
) : servers.length === 0 ? (
<div className="text-center py-10 text-[var(--dg-text-muted)] text-xs">暂无服务器,请先在「服务器管理」添加</div>
{servers.length === 0 ? (
<PageEmpty
title="暂无服务器"
description="请先在「服务器管理」添加"
className="py-10"
/>
) : (
<>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
<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 text-blue-400">已选 {selCount}</Badge>}
{selCount > 0 && <Badge className="ml-auto bg-blue-500/20 dg-text-blue">已选 {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">
@@ -146,8 +143,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 text-amber-400 text-[10px]">无凭据</Badge>
: <Badge className="bg-emerald-500/20 text-emerald-400 text-[10px]">{s.auth_type === 'key' ? '密钥' : '密码'}</Badge>}
? <Badge className="bg-amber-500/20 dg-text-yellow text-[10px]">无凭据</Badge>
: <Badge className="bg-emerald-500/20 dg-text-green text-[10px]">{s.auth_type === 'key' ? '密钥' : '密码'}</Badge>}
</label>
))}
</>
@@ -155,20 +152,29 @@ export function RemoteExecPage() {
</CardContent>
</Card>
{/* 右侧:操作 + 结果 */}
{/* 右侧:操作 + 结果(内部子 Tab 保留按钮切换) // Logan.Li */}
<div className="col-span-7 space-y-4">
{/* Tab 切换 */}
<div className="flex gap-2">
<Button variant={tab === 'exec' ? 'default' : 'outline'} size="sm" onClick={() => setTab('exec')} className="h-8 text-xs gap-1">
<Button
variant={tab === 'exec' ? 'default' : 'outline'}
size="sm"
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" />执行命令
</Button>
<Button variant={tab === 'upload' ? 'default' : 'outline'} size="sm" onClick={() => setTab('upload')} className="h-8 text-xs gap-1">
<Button
variant={tab === 'upload' ? 'default' : 'outline'}
size="sm"
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" />上传文件
</Button>
</div>
{tab === 'exec' ? (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<Card className="dg-card">
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]">批量执行命令</CardTitle></CardHeader>
<CardContent className="space-y-3">
<textarea
@@ -179,10 +185,10 @@ export function RemoteExecPage() {
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 text-[var(--dg-text-secondary)]">超时(秒)</span>
<span className="text-xs dg-text-secondary">超时(秒)</span>
<Input type="number" min={1} value={timeoutSec} onChange={(e) => setTimeoutSec(Number(e.target.value) || 60)}
className="h-8 w-24 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Button onClick={doExec} disabled={running} className="ml-auto bg-blue-600 hover:bg-blue-700 h-8 text-xs">
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} 台)
</Button>
@@ -190,21 +196,21 @@ export function RemoteExecPage() {
</CardContent>
</Card>
) : (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<Card className="dg-card">
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]">批量上传文件</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<span className="text-xs text-[var(--dg-text-secondary)]">选择本地文件</span>
<span className="text-xs dg-text-secondary">选择本地文件</span>
<Input type="file" onChange={(e) => setFile(e.target.files?.[0] || null)}
className="h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
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 text-[var(--dg-text-secondary)]">远程目标路径(目录则自动追加文件名)</span>
<span className="text-xs dg-text-secondary">远程目标路径(目录则自动追加文件名)</span>
<Input value={remotePath} onChange={(e) => setRemotePath(e.target.value)} placeholder="/tmp/ 或 /opt/app/config.yaml"
className="h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
className="h-8 text-xs font-mono dg-input" />
</div>
<Button onClick={doUpload} disabled={running} className="bg-blue-600 hover:bg-blue-700 h-8 text-xs">
<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} 台)
</Button>
@@ -212,21 +218,20 @@ export function RemoteExecPage() {
</Card>
)}
{/* 结果区 */}
{(execResults.length > 0 || uploadResults.length > 0) && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<Card className="dg-card">
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]">执行结果</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)]">
<div className="flex items-center gap-2 mb-1">
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> : <XCircle className="w-3.5 h-3.5 text-red-400" />}
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 dg-text-green" /> : <XCircle className="w-3.5 h-3.5 dg-text-red" />}
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{r.host}</span>
<Badge className={r.success ? 'bg-emerald-500/20 text-emerald-400 text-[10px]' : 'bg-red-500/20 text-red-400 text-[10px]'}>
<Badge className={r.success ? 'bg-emerald-500/20 dg-text-green text-[10px]' : 'bg-red-500/20 dg-text-red text-[10px]'}>
exit={r.exit_code} · {r.duration}ms
</Badge>
</div>
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
<pre className="text-[10px] font-mono dg-text-secondary whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
{r.error || r.output || '(无输出)'}
</pre>
</div>
@@ -234,10 +239,10 @@ export function RemoteExecPage() {
{uploadResults.map((r, i) => (
<div key={i} className="p-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2">
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> : <XCircle className="w-3.5 h-3.5 text-red-400" />}
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 dg-text-green" /> : <XCircle className="w-3.5 h-3.5 dg-text-red" />}
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{r.host}</span>
<span className="text-[10px] text-[var(--dg-text-muted)] truncate">{r.remote_path}</span>
{r.error && <span className="text-[10px] text-red-400 ml-auto">{r.error}</span>}
{r.error && <span className="text-[10px] dg-text-red ml-auto">{r.error}</span>}
</div>
</div>
))}
@@ -246,6 +251,6 @@ export function RemoteExecPage() {
)}
</div>
</div>
</div>
</PageShell>
);
}
}