Merge pull request #1175 from pikasTech/fix/v03-r6-architecture-1156
fix: 收敛 v0.3 Vue common architecture parity
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user