Merge pull request #1175 from pikasTech/fix/v03-r6-architecture-1156

fix: 收敛 v0.3 Vue common architecture parity
This commit is contained in:
Lyon
2026-06-14 05:41:07 +08:00
committed by GitHub
5 changed files with 156 additions and 5 deletions
+14
View File
@@ -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");
@@ -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<string, string>();
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, /<table\s/u, `${relativePath} should not regress to local raw table markup`);
}
});
test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => {
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");
}
+26 -3
View File
@@ -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<string, unknown>;
const nested = record.error && typeof record.error === "object" ? record.error as Record<string, unknown> : 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<string | null>(null);
const notice = ref<string | null>(null);
const app = useAppStore();
async function submit(action: () => Promise<void>, successMessage?: string): Promise<boolean> {
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;
+28 -1
View File
@@ -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<Storage, "getItem" | "setItem"> = 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);
}
+17 -1
View File
@@ -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 };
});