diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 5b2ccac0..787128ed 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -13,6 +13,7 @@ const requiredFiles = Object.freeze([ "src/App.vue", "src/router/index.ts", "src/router/guards.ts", + "src/router/title.ts", "src/api/client.ts", "src/api/auth.ts", "src/api/workbench.ts", @@ -25,7 +26,14 @@ const requiredFiles = Object.freeze([ "src/composables/useWorkspaceBootstrap.ts", "src/composables/useAutoRefresh.ts", "src/composables/useClipboard.ts", + "src/composables/useForm.ts", + "src/composables/useTableLoader.ts", + "src/components/common/BaseDialog.vue", + "src/components/common/ConfirmDialog.vue", + "src/components/common/DataTable.vue", + "src/components/common/Pagination.vue", "src/components/layout/AppShell.vue", + "src/components/layout/TablePageLayout.vue", "src/components/common/EmptyState.vue", "src/views/workbench/CodeWorkbenchView.vue", "src/views/admin/AccessView.vue", @@ -73,6 +81,12 @@ assertIncludes(appSource, "createApp", "Vue app must use createApp"); assertIncludes(appSource, "createPinia", "Pinia must be installed"); assertIncludes(appSource, "createRouter", "Vue Router must be installed"); assertIncludes(appSource, "defineStore", "Pinia stores must exist"); +assertIncludes(appSource, "installChunkRecovery", "router must recover from stale dynamic route chunks"); +assertIncludes(appSource, "resolveDocumentTitle", "route meta title handling must remain centralized"); +assertIncludes(appSource, "showSuccess", "app store must expose Sub2API-style success toast helper"); +assertIncludes(appSource, "showError", "app store must expose Sub2API-style error toast helper"); +assertIncludes(appSource, "useTableLoader", "table loader composable must remain available for route tables"); +assertIncludes(appSource, "useForm", "form composable must remain available for CRUD dialogs"); assertIncludes(appSource, "hwlab_session", "auth comments/code must preserve Web session cookie boundary"); assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activityRef must be preserved"); assertIncludes(appSource, "activityRef: () => activityRef.value", "Workbench must pass activityRef into trace subscription"); diff --git a/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts b/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts new file mode 100644 index 00000000..1a74fe4e --- /dev/null +++ b/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { messageFromError } from "../src/composables/useForm.ts"; +import { isChunkLoadError, shouldReloadForChunkError } from "../src/router/guards.ts"; +import { resolveDocumentTitle } from "../src/router/title.ts"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("R6 route meta title and chunk recovery match Sub2API navigation pattern", () => { + assert.equal(resolveDocumentTitle("Gate"), "Gate - HWLAB Cloud Web"); + assert.equal(resolveDocumentTitle(" "), "平台控制台 - HWLAB Cloud Web"); + assert.equal(isChunkLoadError(new Error("Failed to fetch dynamically imported module")), true); + assert.equal(isChunkLoadError(new Error("Loading CSS chunk 42 failed")), true); + assert.equal(isChunkLoadError(new Error("ordinary route guard failure")), false); + + const memory = new Map(); + const storage = { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => { memory.set(key, value); } + }; + assert.equal(shouldReloadForChunkError(100_000, storage), true); + assert.equal(shouldReloadForChunkError(105_000, storage), false); + assert.equal(shouldReloadForChunkError(111_000, storage), true); +}); + +test("R6 useForm normalizes nested API errors for toast/error surfaces", () => { + assert.equal(messageFromError(new Error("plain failure")), "plain failure"); + assert.equal(messageFromError({ error: { message: "nested message" } }), "nested message"); + assert.equal(messageFromError({ error: { code: "permission_denied" } }), "permission_denied"); + assert.equal(messageFromError({ reason: "blocked_by_gate" }), "blocked_by_gate"); +}); + +test("R6 common module map keeps HWLAB views on shared table/dialog/form primitives", () => { + const expectations: Array<[string, string[]]> = [ + ["src/views/admin/ProviderProfilesView.vue", ["DataTable", "Pagination", "BaseDialog", "ConfirmDialog", "TablePageLayout", "useTableLoader", "useForm"]], + ["src/components/access/AccessMatrixPanel.vue", ["DataTable", "TablePageLayout"]], + ["src/views/GateView.vue", ["DataTable", "Pagination", "BaseDialog", "TablePageLayout", "useTableLoader"]], + ["src/views/PerformanceView.vue", ["DataTable", "TablePageLayout", "useTableLoader"]], + ["src/views/SkillsView.vue", ["DataTable", "Pagination", "BaseDialog", "TablePageLayout", "useTableLoader", "useForm"]] + ]; + + for (const [relativePath, terms] of expectations) { + const source = read(relativePath); + for (const term of terms) assert.match(source, new RegExp(`\\b${term}\\b`, "u"), `${relativePath} should use ${term}`); + assert.doesNotMatch(source, / { + const issueBody = readIssueBodyFixture(); + assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u); + assert.match(issueBody, /router\/index\.ts/u); + assert.match(issueBody, /components\/common/u); + assert.match(issueBody, /useTableLoader\.ts/u); + assert.match(issueBody, /useForm\.ts/u); +}); + +function read(relativePath: string): string { + return fs.readFileSync(path.join(rootDir, relativePath), "utf8"); +} + +function readIssueBodyFixture(): string { + return [ + "Sub2API 对照: Wei-Shaw/sub2api@v0.1.136=a2f76e4", + "router/index.ts api/client.ts components/common/* composables/useTableLoader.ts useForm.ts admin/user CRUD views" + ].join("\n"); +} diff --git a/web/hwlab-cloud-web/src/composables/useForm.ts b/web/hwlab-cloud-web/src/composables/useForm.ts index 96039a4f..d6f1ffa7 100644 --- a/web/hwlab-cloud-web/src/composables/useForm.ts +++ b/web/hwlab-cloud-web/src/composables/useForm.ts @@ -1,9 +1,28 @@ import { ref } from "vue"; +import { useAppStore } from "@/stores/app"; -export function useForm() { +export interface UseFormOptions { + successMessage?: string; + errorMessage?: string; + toast?: boolean; +} + +export function messageFromError(err: unknown): string { + if (err instanceof Error && err.message.trim()) return err.message; + if (err && typeof err === "object") { + const record = err as Record; + const nested = record.error && typeof record.error === "object" ? record.error as Record : null; + const candidate = nested?.message ?? nested?.code ?? record.message ?? record.reason ?? record.error; + if (typeof candidate === "string" && candidate.trim()) return candidate; + } + return String(err || "操作失败"); +} + +export function useForm(options: UseFormOptions = {}) { const loading = ref(false); const error = ref(null); const notice = ref(null); + const app = useAppStore(); async function submit(action: () => Promise, successMessage?: string): Promise { if (loading.value) return false; @@ -12,10 +31,14 @@ export function useForm() { notice.value = null; try { await action(); - notice.value = successMessage ?? null; + const message = successMessage ?? options.successMessage ?? null; + notice.value = message; + if (options.toast !== false && message) app.showSuccess(message); return true; } catch (err) { - error.value = err instanceof Error ? err.message : String(err); + const message = options.errorMessage ?? messageFromError(err); + error.value = message; + if (options.toast !== false) app.showError(message); return false; } finally { loading.value = false; diff --git a/web/hwlab-cloud-web/src/router/guards.ts b/web/hwlab-cloud-web/src/router/guards.ts index 59070cbf..f259e887 100644 --- a/web/hwlab-cloud-web/src/router/guards.ts +++ b/web/hwlab-cloud-web/src/router/guards.ts @@ -1,7 +1,31 @@ import type { Router } from "vue-router"; +import { isNavigationFailure } from "vue-router"; import { useAuthStore } from "@/stores/auth"; import { resolveDocumentTitle } from "./title"; +const CHUNK_RELOAD_KEY = "hwlab:v03:chunk-reload-at"; +const CHUNK_RELOAD_WINDOW_MS = 10_000; + +export function isChunkLoadError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ""); + return /Loading chunk|Loading CSS chunk|Failed to fetch dynamically imported module|Importing a module script failed|dynamically imported module/u.test(message); +} + +export function shouldReloadForChunkError(now = Date.now(), storage: Pick = window.sessionStorage): boolean { + const lastReload = Number(storage.getItem(CHUNK_RELOAD_KEY) ?? 0); + if (Number.isFinite(lastReload) && now - lastReload < CHUNK_RELOAD_WINDOW_MS) return false; + storage.setItem(CHUNK_RELOAD_KEY, String(now)); + return true; +} + +export function installChunkRecovery(router: Router): void { + router.onError((error) => { + if (!isChunkLoadError(error)) return; + if (typeof window === "undefined") return; + if (shouldReloadForChunkError()) window.location.reload(); + }); +} + export function installRouterGuards(router: Router): void { router.beforeEach(async (to) => { const auth = useAuthStore(); @@ -11,7 +35,10 @@ export function installRouterGuards(router: Router): void { return true; }); - router.afterEach((to) => { + router.afterEach((to, _from, failure) => { + if (failure && isNavigationFailure(failure)) return; document.title = resolveDocumentTitle(to.meta.title); }); + + installChunkRecovery(router); } diff --git a/web/hwlab-cloud-web/src/stores/app.ts b/web/hwlab-cloud-web/src/stores/app.ts index 0dfaa381..08007900 100644 --- a/web/hwlab-cloud-web/src/stores/app.ts +++ b/web/hwlab-cloud-web/src/stores/app.ts @@ -25,6 +25,22 @@ export const useAppStore = defineStore("app", () => { return id; } + function showSuccess(message: string, duration = 3000): string { + return showToast("success", message, duration); + } + + function showError(message: string, duration = 5000): string { + return showToast("error", message, duration); + } + + function showInfo(message: string, duration = 4000): string { + return showToast("info", message, duration); + } + + function showWarning(message: string, duration = 5000): string { + return showToast("warning", message, duration); + } + function hideToast(id: string): void { toasts.value = toasts.value.filter((toast) => toast.id !== id); } @@ -34,5 +50,5 @@ export const useAppStore = defineStore("app", () => { if (title) siteName.value = title; } - return { sidebarCollapsed, mobileOpen, loading, hasActiveToasts, toasts, siteName, siteLogo, setLoading, showToast, hideToast, initFromInjectedConfig }; + return { sidebarCollapsed, mobileOpen, loading, hasActiveToasts, toasts, siteName, siteLogo, setLoading, showToast, showSuccess, showError, showInfo, showWarning, hideToast, initFromInjectedConfig }; });