feat: redesign responsive console and fix restore regressions

This commit is contained in:
2026-10-15 01:08:55 +08:00
parent 3bf197206a
commit 56c8f76e32
49 changed files with 3455 additions and 447 deletions
+102 -16
View File
@@ -1,26 +1,112 @@
import { useState } from 'react';
import { useState, type FormEvent, type ReactNode } from 'react';
import { Download, HardDriveDownload, RotateCcw, Upload } from 'lucide-react';
import { api, instancePath, type Instance, type Backup } from './api';
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
import { useConfirm } from './components/ConfirmDialog';
import { AlertBox } from './components/ui/alert';
import { DialogContent, DialogFooter, DialogRoot } from './components/ui/dialog';
import { buttonVariants } from './components/ui/button';
import { inputClass } from './components/ui/input';
import { cn } from './lib/utils';
export function BackupPanel({ instance, refresh }: { instance: Instance; refresh: () => Promise<void> }) {
export function BackupPanel({instance, refresh}: {instance: Instance; refresh: () => Promise<void>}) {
const base = instancePath(instance.id);
const backups = useResource(() => api.request<Backup[]>(base + '/backups'));
const action = useAction();
const [password, setPassword] = useState(''), [originalPassword, setOriginalPassword] = useState(''), [file, setFile] = useState<File>();
return <section aria-label="备份与恢复">
<p className="callout">操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。</p>
{action.feedback}
<form onSubmit={e => { e.preventDefault(); void action.run(async () => { try { await api.request(base + '/backups', 'POST', { password }); await backups.refresh(); } finally { setPassword(''); } }, '加密备份已创建'); }}>
<label>当前登录密码<input type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} /></label>
<button disabled={action.busy || !password}>创建加密备份</button>
</form>
<label>原备份密码(旧平台密钥包留空)<input type="password" autoComplete="off" value={originalPassword} onChange={e => setOriginalPassword(e.target.value)} /></label>
<form onSubmit={e => { e.preventDefault(); if (!file) return; void action.run(async () => { try { if (file.size > 44743364) throw new Error('备份包超过大小限制'); const form = new FormData(); form.set('file', file); form.set('password', originalPassword); await api.request(base + '/backups', 'POST', form); await backups.refresh(); } finally { setOriginalPassword(''); } }, '备份已验证并导入;尚未覆盖当前配置'); }}>
<label>加密备份文件<input type="file" accept=".dshbak" onChange={e => setFile(e.target.files?.[0])} /></label>
<button disabled={action.busy || !file}>验证并导入</button>
</form>
const confirm = useConfirm();
const [password, setPassword] = useState('');
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
const [restoreTarget, setRestoreTarget] = useState<Backup>();
function createBackup(e: FormEvent) {
e.preventDefault();
void action.run(async () => {
try { await api.request(base + '/backups', 'POST', {password}); await backups.refresh(); }
finally { setPassword(''); }
}, '加密备份已创建');
}
function importBackup(e: FormEvent) {
e.preventDefault();
if (!file) return;
void action.run(async () => {
try {
if (file.size > 44743364) throw new Error('备份包超过大小限制');
const form = new FormData();
form.set('file', file);
form.set('password', importPassword);
await api.request(base + '/backups', 'POST', form);
await backups.refresh();
} finally { setImportPassword(''); }
}, '备份已验证并导入;尚未覆盖当前配置');
}
async function restore(backup: Backup, originalPassword: string) {
if (!await confirm.ask({
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
})) return false;
return action.run(async () => { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await refresh(); }, '配置恢复及插件验证完成,已恢复原运行状态。');
}
return <section aria-label="备份与恢复" className="grid gap-5">
{confirm.element}
<AlertBox variant="info">操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。</AlertBox>
<div className="grid gap-5 lg:grid-cols-2">
<form onSubmit={createBackup} className="grid content-start gap-3 rounded-lg border border-border bg-muted/40 p-4">
<h3 className="m-0 flex items-center gap-1.5 text-[14px] font-semibold"><HardDriveDownload className="size-4 text-primary" aria-hidden />创建加密备份</h3>
<label className="grid gap-1.5 text-[13px] font-medium">当前登录密码
<input className={inputClass} type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} /></label>
<div><button className={cn(buttonVariants({size: 'sm'}))} disabled={action.busy || !password}>创建加密备份</button></div>
</form>
<form onSubmit={importBackup} className="grid content-start gap-3 rounded-lg border border-border bg-muted/40 p-4">
<h3 className="m-0 flex items-center gap-1.5 text-[14px] font-semibold"><Upload className="size-4 text-primary" aria-hidden />导入外部备份</h3>
<label className="grid gap-1.5 text-[13px] font-medium">加密备份文件
<input type="file" accept=".dshbak" onChange={e => setFile(e.target.files?.[0])}
className="w-full cursor-pointer rounded-md border border-input bg-card px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-secondary file:px-2.5 file:py-1 file:text-xs file:text-secondary-foreground" /></label>
<label className="grid gap-1.5 text-[13px] font-medium">原备份密码(旧平台密钥包留空)
<input className={inputClass} type="password" autoComplete="off" value={importPassword} onChange={e => setImportPassword(e.target.value)} /></label>
<div><button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy || !file}>验证并导入</button></div>
</form>
</div>
{!restoreTarget && action.feedback}
<ResourceState resource={backups} />
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
{backups.data?.map(b => <div className="backup-row" key={b.id}><span>{b.id}</span><span>{date(b.created_at)}</span><span>{bytes(b.size)}</span><a className="button" href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}>下载</a><button disabled={action.busy} onClick={() => action.run(async () => { try { await api.request(base + '/restore', 'POST', { backup_id: b.id, password: originalPassword }); await refresh(); } finally { setOriginalPassword(''); } }, '配置恢复及插件验证完成,已恢复原运行状态。', '确认覆盖当前配置?服务器将先保留当前 home 快照。')}>恢复</button></div>)}
<div className="grid gap-2">
{backups.data?.map(b => <div key={b.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-border bg-card px-3.5 py-3">
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
</div>
<div className="flex gap-1.5">
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
</div>
</div>)}
</div>
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
const ok = await restore(restoreTarget, password);
if (ok) setRestoreTarget(undefined);
}} />}
</section>;
}
function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise<void>}) {
const [password, setPassword] = useState('');
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent title={'恢复备份 · ' + backup.id}>
<form className="grid gap-4" onSubmit={e => { e.preventDefault(); void onSubmit(password); }}>
{feedback}
<AlertBox variant="info">创建于 {date(backup.created_at)} · {bytes(backup.size)}。输入创建该备份时使用的登录密码;旧平台密钥包可留空。</AlertBox>
<label className="grid gap-1.5 text-[13px] font-medium">原备份密码
<input className={inputClass} type="password" autoComplete="off" value={password} onChange={e => setPassword(e.target.value)} disabled={busy} autoFocus /></label>
<DialogFooter className="border-t-0 pt-0">
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={busy} onClick={onClose}>取消</button>
<button className={cn(buttonVariants({variant: 'destructive'}))} disabled={busy}>{busy ? '正在恢复…' : '恢复此备份'}</button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>;
}