From bcf245410a5aa8fd0db4fcae068117c67a31d300 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 09:38:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20=E6=96=87=E4=BB=B6=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=85=B3=E9=97=AD=E6=88=96=E7=A6=BB=E5=BC=80?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=89=8D=E7=A1=AE=E8=AE=A4=E6=94=BE=E5=BC=83?= =?UTF-8?q?=E6=9C=AA=E4=BF=9D=E5=AD=98=E4=BF=AE=E6=94=B9=EF=BC=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=BD=AE=E8=AF=A2=E5=A4=B1=E8=B4=A5=E5=8F=AA=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E4=B8=8D=E5=86=8D=E5=8D=B8=E8=BD=BD=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/src/i18n/resources/en-US/files.json | 6 +- panel/src/i18n/resources/zh-CN/files.json | 6 +- panel/src/lib/hooks.ts | 16 ++++ panel/src/pages/ServerFiles.tsx | 93 +++++++++++++++++------ 4 files changed, 95 insertions(+), 26 deletions(-) diff --git a/panel/src/i18n/resources/en-US/files.json b/panel/src/i18n/resources/en-US/files.json index 0cb6f53..9e1c496 100644 --- a/panel/src/i18n/resources/en-US/files.json +++ b/panel/src/i18n/resources/en-US/files.json @@ -25,5 +25,9 @@ "conflict_reload": "Load latest", "conflict_reload_hint": "Discard your edits and show the file as it is now", "conflict_overwrite": "Overwrite anyway", - "conflict_overwrite_hint": "Replace their change with your version" + "conflict_overwrite_hint": "Replace their change with your version", + "discard_prompt": "You have unsaved changes. Discard them?", + "discard": "Discard", + "keep_editing": "Keep editing", + "status_refresh_failed": "Could not refresh the server's status, so what you see may be out of date: {{reason}}" } diff --git a/panel/src/i18n/resources/zh-CN/files.json b/panel/src/i18n/resources/zh-CN/files.json index 8c630ea..d6488e2 100644 --- a/panel/src/i18n/resources/zh-CN/files.json +++ b/panel/src/i18n/resources/zh-CN/files.json @@ -25,5 +25,9 @@ "conflict_reload": "载入最新内容", "conflict_reload_hint": "放弃你的修改,改看现在的文件", "conflict_overwrite": "仍然覆盖", - "conflict_overwrite_hint": "用你的版本替换对方的修改" + "conflict_overwrite_hint": "用你的版本替换对方的修改", + "discard_prompt": "有未保存的修改,要放弃吗?", + "discard": "放弃修改", + "keep_editing": "继续编辑", + "status_refresh_failed": "刷新服务器状态失败,页面显示的可能不是最新状态:{{reason}}" } diff --git a/panel/src/lib/hooks.ts b/panel/src/lib/hooks.ts index 4e34374..6552fe5 100644 --- a/panel/src/lib/hooks.ts +++ b/panel/src/lib/hooks.ts @@ -65,3 +65,19 @@ export function useAsync(fn: () => Promise, deps: unknown[] = []): AsyncSt return { data, error, loading, reload }; } + +/** useUnsavedGuard asks the browser to confirm leaving the page (reload, tab + * close, typing another URL) while `dirty` holds, so unsaved edits are not + * dropped without a prompt. */ +export function useUnsavedGuard(dirty: boolean): void { + useEffect(() => { + if (!dirty) return; + const onBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + // Older Chromium and Safari need returnValue set to show the prompt. + e.returnValue = ""; + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [dirty]); +} diff --git a/panel/src/pages/ServerFiles.tsx b/panel/src/pages/ServerFiles.tsx index 4612179..3348c6e 100644 --- a/panel/src/pages/ServerFiles.tsx +++ b/panel/src/pages/ServerFiles.tsx @@ -17,6 +17,7 @@ import { BackLink } from "@/components/BackLink"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { MessageLine } from "@/components/MessageLine"; +import { InlineConfirm } from "@/components/InlineConfirm"; import { Dialog, DialogContent, @@ -28,7 +29,7 @@ import { PhaseBadge } from "@/components/PhaseBadge"; import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States"; import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; -import { useAsync } from "@/lib/hooks"; +import { useAsync, useUnsavedGuard } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import { canManage, ownershipPending } from "@/lib/ownership"; import { formatBytes, formatRelative } from "@/lib/format"; @@ -157,10 +158,30 @@ export function ServerFiles() { const [saving, setSaving] = useState(false); const [reloading, setReloading] = useState(false); const [stopping, setStopping] = useState(false); + // Closing an editor with unsaved text (Esc, the overlay, ✕, Cancel) asks + // first; leaving the page asks through the browser. + const [confirmDiscard, setConfirmDiscard] = useState(false); + const dirty = open !== null && open.editable && open.text !== open.original; + useUnsavedGuard(dirty); + + function requestClose() { + if (saving || reloading) return; + if (dirty) { + setConfirmDiscard(true); + return; + } + setOpen(null); + } + + function discardEdits() { + setConfirmDiscard(false); + setOpen(null); + } async function readInto(p: string) { const r = await api.readServerFile(name, p); const text = decodeText(base64ToBytes(r.content ?? "")); + setConfirmDiscard(false); setOpen({ path: p, text: text ?? "", @@ -213,6 +234,7 @@ export function ServerFiles() { overwrite ? undefined : open.sha256 || undefined, ); setMsg({ kind: "success", text: t("saved", { path: open.path }) }); + setConfirmDiscard(false); setOpen(null); void load(dir); } catch (e) { @@ -249,7 +271,9 @@ export function ServerFiles() { ); } - if (statusQ.error) { + // Only a failed first load replaces the page: a later poll that fails keeps + // the page (and an open editor) mounted and says so above the listing. + if (statusQ.error && !statusQ.data) { return ( <> {back} @@ -261,7 +285,6 @@ export function ServerFiles() { const now = Date.now(); const segments = dir === "" ? [] : dir.split("/"); - const dirty = open !== null && open.text !== open.original; const dirtyBytes = open ? new TextEncoder().encode(open.text).length : 0; const tooLarge = dirtyBytes > MAX_WRITE_BYTES; @@ -288,6 +311,12 @@ export function ServerFiles() { ) : (
{msg && } + {statusQ.error != null && ( + + )} {!stopped ? ( @@ -431,7 +460,7 @@ export function ServerFiles() { )} {/* File editor dialog */} - !v && !saving && !reloading && setOpen(null)}> + !v && requestClose()}> @@ -501,26 +530,42 @@ export function ServerFiles() { ? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) }) : formatBytes(dirtyBytes)} -
- - -
+ {confirmDiscard ? ( +
+ {t("discard_prompt")} + setConfirmDiscard(false)} + confirmLabel={t("discard")} + cancelLabel={t("keep_editing")} + size="default" + className="flex items-center gap-2" + /> +
+ ) : ( +
+ + +
+ )} )}