From 009083468af1a5e98ce7c47f056b8a0c04144406 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 20:33:31 +0200 Subject: [PATCH] fix: resolve MDTODO review blockers --- .../scripts/admin-r4-crud-parity.test.ts | 27 ++- web/hwlab-cloud-web/src/api/usage.ts | 4 +- .../components/access/AccessMatrixPanel.vue | 26 +-- .../access/AccessPermissionWorkspace.vue | 8 +- .../components/access/AccessUserInspector.vue | 6 +- .../src/components/common/BaseDialog.vue | 4 +- .../src/components/common/ContentViewer.vue | 60 ++++++- .../src/components/common/OverlaySurface.vue | 1 + .../hwpod/HwpodDevicesWorkspace.vue | 13 +- .../components/hwpod/HwpodNodesWorkspace.vue | 10 +- .../components/mdtodo/MdtodoReportPanel.vue | 9 +- .../src/components/mdtodo/MdtodoTaskTree.vue | 60 ++++++- .../mdtodo/mdtodoTaskTreeModel.test.ts | 5 - .../composables/mdtodo/mdtodoTaskTreeModel.ts | 12 +- .../src/stores/admin-access-view.ts | 158 ++++++------------ web/hwlab-cloud-web/src/stores/auth.ts | 12 +- .../src/styles/console-foundation.css | 9 +- web/hwlab-cloud-web/src/types/index.ts | 21 ++- .../src/views/admin/HwpodGroupsView.vue | 9 +- 19 files changed, 259 insertions(+), 195 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/admin-r4-crud-parity.test.ts b/web/hwlab-cloud-web/scripts/admin-r4-crud-parity.test.ts index bc98b7ff..0d6e705a 100644 --- a/web/hwlab-cloud-web/scripts/admin-r4-crud-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/admin-r4-crud-parity.test.ts @@ -1,18 +1,25 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; -import type { AdminAccessMatrixResponse, AdminAccessUsersResponse } from "../src/types/index.ts"; +import type { AdminAccessMatrixResponse, AdminAccessOpenFga, AdminAccessSummaryResponse, AdminAccessUser, AdminAccessUsersResponse } from "../src/types/index.ts"; import { normalizeAccessDetail, normalizeAccessSummary, normalizeAccessUsers } from "../src/stores/admin-access-view.ts"; import { mergeProviderRow, normalizeProviderConfig, normalizeProviderProfiles, normalizeProviderValidation } from "../src/stores/provider-profiles-view.ts"; -test("R4 Access helpers map runtime rows and OpenFGA summary", () => { - const summary = normalizeAccessSummary({ contractVersion: "admin-access-v1", counts: { users: 3, tuples: 5, toolTuples: 4 }, openfga: { ready: true, status: "ready", storeName: "hwlab-v03" }, supported: { toolIds: ["hwpod", "github_pr"] } }); +const actor: AdminAccessUser = { id: "usr_admin", username: "admin", displayName: "Admin", role: "admin", status: "active" }; +const user: AdminAccessUser = { id: "usr_1", username: "alice", displayName: "Alice", role: "user", status: "active" }; +const openfga: AdminAccessOpenFga = { contractVersion: "hwlab-openfga-authorization-v1", mode: "enforce", configured: true, ready: true, status: "ready", apiUrlConfigured: true, storeName: "hwlab-v03", storeId: "sto…1234", modelId: "mod…1234", degradedReason: null, valuesRedacted: true }; +const tools = { hwpod: true, unidesk_ssh: false, trans_cmd: false, github_pr: false }; + +test("R4 Access helpers map the fixed admin-access-v1 DTO", () => { + const payload: AdminAccessSummaryResponse = { contractVersion: "admin-access-v1", actor, counts: { users: 3, tuples: 5, toolTuples: 4 }, openfga, supported: { toolIds: ["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"] } }; + const summary = normalizeAccessSummary(payload); assert.equal(summary.users, 3); assert.equal(summary.toolTuples, 4); - assert.deepEqual(summary.toolIds, ["hwpod", "github_pr"]); + assert.deepEqual(summary.toolIds, ["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"]); assert.equal(summary.openfga.ready, true); - const usersPayload = { users: [{ user: { id: "usr_1", username: "alice", role: "member", status: "active" }, tools: { hwpod: true, github_pr: false }, tupleCount: 1 }] } as unknown as AdminAccessUsersResponse; + const usersPayload: AdminAccessUsersResponse = { contractVersion: "admin-access-v1", actor, openfga, users: [{ user, tools, tupleCount: 1 }], count: 1 }; const users = normalizeAccessUsers(usersPayload); assert.equal(users.length, 1); assert.equal(users[0]?.id, "usr_1"); @@ -21,7 +28,7 @@ test("R4 Access helpers map runtime rows and OpenFGA summary", () => { }); test("R4 Access detail maps tool object into toggles", () => { - const detailPayload = { user: { id: "usr_1", username: "alice" }, tools: { hwpod: true, trans_cmd: false }, tuples: [{ userId: "usr_1", relation: "can_use", object: "tool:hwpod" }], openfga: { valuesRedacted: true } } as unknown as AdminAccessMatrixResponse; + const detailPayload: AdminAccessMatrixResponse = { contractVersion: "admin-access-v1", actor, user, tools, tuples: [{ userId: "usr_1", relation: "can_use", object: "tool:hwpod", createdByAdminId: actor.id, createdAt: "2026-07-14T00:00:00.000Z" }], openfga }; const detail = normalizeAccessDetail(detailPayload); assert.equal(detail.user?.id, "usr_1"); assert.ok(detail.tools.some((tool) => tool.id === "hwpod" && tool.allowed)); @@ -29,6 +36,14 @@ test("R4 Access detail maps tool object into toggles", () => { assert.equal(detail.tuples.length, 1); }); +test("ContentViewer keeps wrap available and hides maximize when fullscreen is disabled", () => { + const source = readFileSync(new URL("../src/components/common/ContentViewer.vue", import.meta.url), "utf8"); + assert.match(source, / { const rows = normalizeProviderProfiles({ data: { items: [{ profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v02", name: "agentrun-v02-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc123", configHashSuffix: "def456", bridge: { route: "Moon Bridge -> DeepSeek" }, valuesPrinted: false }] } }); assert.equal(rows.length, 1); diff --git a/web/hwlab-cloud-web/src/api/usage.ts b/web/hwlab-cloud-web/src/api/usage.ts index 7b1067e9..af5eb112 100644 --- a/web/hwlab-cloud-web/src/api/usage.ts +++ b/web/hwlab-cloud-web/src/api/usage.ts @@ -1,5 +1,5 @@ import { fetchJson } from "./client"; -import type { AccessUserStatus, AdminAuditResponse, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, AdminUsageRow, ApiResult, UsageLedgerRow, UsageSummary } from "@/types"; +import type { AdminAuditResponse, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, AdminUsageRow, ApiResult, BillingUserStatus, UsageLedgerRow, UsageSummary } from "@/types"; export const usageAPI = { summary: (): Promise> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }), @@ -20,7 +20,7 @@ export const usageAPI = { createAdminUser: (body: Record): Promise> => fetchJson("/v1/admin/billing/users", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "create admin user" }), updateAdminUser: (userId: string, body: Record): Promise> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "update admin user" }), adjustAdminCredits: (userId: string, body: Record): Promise> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/credits/adjust`, { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "adjust admin credits" }), - updateAdminUserStatus: (userId: string, status: AccessUserStatus): Promise> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }), + updateAdminUserStatus: (userId: string, status: BillingUserStatus): Promise> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }), performance: (): Promise> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }) }; diff --git a/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue b/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue index 534ee282..a2786223 100644 --- a/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue +++ b/web/hwlab-cloud-web/src/components/access/AccessMatrixPanel.vue @@ -16,7 +16,7 @@ import { accessUserLabel, type AccessUserRow, } from "@/stores/admin-access-view"; -import type { AccessUserRole, AccessUserStatus } from "@/types"; +import type { AccessToolId, AccessUserRole, AccessUserStatus } from "@/types"; const access = useAccessStore(); const route = useRoute(); @@ -141,7 +141,7 @@ function onRoleChange(role: AccessUserRole): void { function onStatusChange(status: AccessUserStatus): void { void access.updateSelectedUser({ status }); } -function onToolChange(toolId: string, allowed: boolean): void { +function onToolChange(toolId: AccessToolId, allowed: boolean): void { void access.setTool(toolId, allowed); } function valueText(value: unknown): string { @@ -234,7 +234,7 @@ function valueText(value: unknown): string { overflow: auto; } .permission-matrix-head, -.permission-matrix > button { +.permission-matrix > [role="row"] { display: grid; grid-template-columns: minmax(180px, 1.6fr) repeat( var(--permission-tool-count), @@ -250,27 +250,33 @@ function valueText(value: unknown): string { background: #eef3f4; } .permission-matrix-head span, -.permission-matrix > button > span { +.permission-matrix > [role="row"] > [role="gridcell"] { padding: 9px; border-right: 1px solid #d8e1e4; border-bottom: 1px solid #d8e1e4; text-align: center; } .permission-matrix-head span:first-child, -.permission-matrix > button > span:first-child { +.permission-matrix > [role="row"] > [role="gridcell"]:first-child { text-align: left; } -.permission-matrix > button { +.permission-matrix > [role="row"]:not(.permission-matrix-head) { width: 100%; - border: 0; background: white; color: inherit; - cursor: pointer; } -.permission-matrix > button:hover, -.permission-matrix > button[data-selected="true"] { +.permission-matrix > [role="row"]:not(.permission-matrix-head):hover, +.permission-matrix > [role="row"][data-selected="true"] { background: #e8f6f5; } +.permission-matrix > [role="row"] > [role="gridcell"]:first-child button { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} .permission-matrix strong, .permission-matrix small { display: block; diff --git a/web/hwlab-cloud-web/src/components/access/AccessPermissionWorkspace.vue b/web/hwlab-cloud-web/src/components/access/AccessPermissionWorkspace.vue index e020da87..08317d33 100644 --- a/web/hwlab-cloud-web/src/components/access/AccessPermissionWorkspace.vue +++ b/web/hwlab-cloud-web/src/components/access/AccessPermissionWorkspace.vue @@ -43,7 +43,13 @@ function valueText(value: unknown): string { 用户 {{ tool.label }} -
+
{{ row.tools[tool.id] ? "允许" : "—" }}
diff --git a/web/hwlab-cloud-web/src/components/access/AccessUserInspector.vue b/web/hwlab-cloud-web/src/components/access/AccessUserInspector.vue index bcc787b2..2e9389f1 100644 --- a/web/hwlab-cloud-web/src/components/access/AccessUserInspector.vue +++ b/web/hwlab-cloud-web/src/components/access/AccessUserInspector.vue @@ -17,7 +17,7 @@ const emit = defineEmits<{ close: []; role: [value: AccessUserRole]; status: [value: AccessUserStatus]; - tool: [toolId: string, allowed: boolean]; + tool: [toolId: AccessToolRow["id"], allowed: boolean]; }>(); @@ -29,13 +29,13 @@ const emit = defineEmits<{
diff --git a/web/hwlab-cloud-web/src/components/common/BaseDialog.vue b/web/hwlab-cloud-web/src/components/common/BaseDialog.vue index 0352dbb8..2987f8c9 100644 --- a/web/hwlab-cloud-web/src/components/common/BaseDialog.vue +++ b/web/hwlab-cloud-web/src/components/common/BaseDialog.vue @@ -9,12 +9,14 @@ const props = withDefaults(defineProps<{ title: string; description?: string; wide?: boolean; + fullscreen?: boolean; closeOnBackdrop?: boolean; busy?: boolean; initialFocus?: string; }>(), { description: "", wide: false, + fullscreen: false, closeOnBackdrop: true, busy: false, initialFocus: "" @@ -24,5 +26,5 @@ defineEmits<{ close: [] }>(); diff --git a/web/hwlab-cloud-web/src/components/common/ContentViewer.vue b/web/hwlab-cloud-web/src/components/common/ContentViewer.vue index 0185044d..bc513a56 100644 --- a/web/hwlab-cloud-web/src/components/common/ContentViewer.vue +++ b/web/hwlab-cloud-web/src/components/common/ContentViewer.vue @@ -21,11 +21,13 @@ const props = withDefaults(defineProps<{ title?: string; filename?: string; lineNumbers?: boolean; + fullscreenEnabled?: boolean; }>(), { mode: "text", title: "内容", filename: "hwlab-content.txt", - lineNumbers: true + lineNumbers: true, + fullscreenEnabled: true }); const query = ref(""); @@ -36,6 +38,15 @@ const text = computed(() => normalizeContent(props.content, props.mode)); const lines = computed(() => text.value.split("\n")); const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase()); const markdownHtml = computed(() => DOMPurify.sanitize(marked.parse(text.value, { async: false }) as string)); +const markdownMatches = computed(() => { + if (props.mode !== "markdown" || !normalizedQuery.value) return []; + return lines.value.flatMap((line, index) => + line.toLocaleLowerCase().includes(normalizedQuery.value) + ? [{ lineNumber: index + 1, text: line }] + : [], + ); +}); +const visibleMarkdownMatches = computed(() => markdownMatches.value.slice(0, 30)); async function copy(): Promise { await navigator.clipboard.writeText(text.value); @@ -58,6 +69,24 @@ function lineMatches(line: string): boolean { return Boolean(normalizedQuery.value) && line.toLocaleLowerCase().includes(normalizedQuery.value); } +function matchParts(line: string): Array<{ text: string; match: boolean }> { + const needle = normalizedQuery.value; + if (!needle) return [{ text: line, match: false }]; + const parts: Array<{ text: string; match: boolean }> = []; + const normalizedLine = line.toLocaleLowerCase(); + let offset = 0; + let matchIndex = normalizedLine.indexOf(needle); + while (matchIndex >= 0) { + if (matchIndex > offset) parts.push({ text: line.slice(offset, matchIndex), match: false }); + const end = matchIndex + needle.length; + parts.push({ text: line.slice(matchIndex, end), match: true }); + offset = end; + matchIndex = normalizedLine.indexOf(needle, offset); + } + if (offset < line.length) parts.push({ text: line.slice(offset), match: false }); + return parts.length ? parts : [{ text: line, match: false }]; +} + function normalizeContent(content: unknown, mode: string): string { if (mode === "json" || mode === "trace") { if (typeof content === "string") { @@ -118,6 +147,7 @@ function normalizeContent(content: unknown, mode: string): string {