From 2667b7e18fc94db7c403a7634af5ec154ff6f006 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Jul 2026 03:10:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=94=B6=E6=95=9B=20MDTODO=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=8B=86?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/project-management/mdtodo.test.ts | 8 +- internal/project-management/mdtodo.ts | 20 +- internal/project-management/server.test.ts | 4 +- web/hwlab-cloud-web/src/api/index.ts | 2 +- .../src/api/projectManagement.ts | 10 +- .../components/mdtodo/MdtodoInfoDialog.vue | 5 +- .../src/components/mdtodo/MdtodoTaskPanel.vue | 10 +- .../src/components/mdtodo/MdtodoTaskTree.vue | 10 +- .../composables/admin/useBillingWorkspace.ts | 225 +++++ .../admin/useUserAuditWorkspace.ts | 129 +++ .../composables/admin/useUsersWorkspace.ts | 382 ++++++++ .../admin/usersWorkspacePresentation.ts | 59 ++ .../composables/mdtodo/useMdtodoDisplay.ts | 2 +- .../composables/mdtodo/useMdtodoTaskData.ts | 4 +- .../mdtodo/useMdtodoViewState.test.ts | 2 +- .../composables/mdtodo/useMdtodoViewState.ts | 12 +- .../mdtodo/useMdtodoWorkspaceActions.ts | 293 ++++++ .../mdtodo/useMdtodoWorkspaceController.ts | 874 +----------------- .../mdtodo/useMdtodoWorkspaceNavigation.ts | 329 +++++++ .../mdtodo/useMdtodoWorkspaceState.ts | 305 ++++++ .../src/views/admin/BillingView.css | 114 +++ .../src/views/admin/BillingView.vue | 413 +-------- .../src/views/admin/UsersView.css | 131 +++ .../src/views/admin/UsersView.vue | 756 +-------------- .../src/views/projects/ProjectsView.vue | 27 +- 25 files changed, 2137 insertions(+), 1989 deletions(-) create mode 100644 web/hwlab-cloud-web/src/composables/admin/useBillingWorkspace.ts create mode 100644 web/hwlab-cloud-web/src/composables/admin/useUserAuditWorkspace.ts create mode 100644 web/hwlab-cloud-web/src/composables/admin/useUsersWorkspace.ts create mode 100644 web/hwlab-cloud-web/src/composables/admin/usersWorkspacePresentation.ts create mode 100644 web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceActions.ts create mode 100644 web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceNavigation.ts create mode 100644 web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceState.ts create mode 100644 web/hwlab-cloud-web/src/views/admin/BillingView.css create mode 100644 web/hwlab-cloud-web/src/views/admin/UsersView.css diff --git a/internal/project-management/mdtodo.test.ts b/internal/project-management/mdtodo.test.ts index d695c73f..f3ebd26b 100644 --- a/internal/project-management/mdtodo.test.ts +++ b/internal/project-management/mdtodo.test.ts @@ -22,7 +22,7 @@ Root notes with [context](https://example.test/context). expect(document.parserMode).toBe("rxx"); expect(document.taskCount).toBe(3); expect(document.tasks.map((task) => task.taskId)).toEqual(["R1", "R1.1", "R2"]); - expect(document.tasks.map((task) => task.status)).toEqual(["in_progress", "done", "open"]); + expect(document.tasks.map((task) => task.status)).toEqual(["in_progress", "completed", "open"]); expect(document.tasks[1].parentTaskRef).toBe(document.tasks[0].taskRef); expect(document.tasks[0].depth).toBe(0); expect(document.tasks[1].depth).toBe(1); @@ -81,9 +81,9 @@ test("parseMdtodoDocument keeps legacy checkbox parsing explicit", () => { expect(document.parserMode).toBe("legacy-checkbox"); expect(document.taskCount).toBe(3); expect(document.tasks[0].status).toBe("open"); - expect(document.tasks[1].status).toBe("done"); + expect(document.tasks[1].status).toBe("completed"); expect(document.tasks[1].parentTaskRef).toBe(document.tasks[0].taskRef); - expect(document.tasks[2].status).toBe("blocked"); + expect(document.tasks[2].status).toBe("open"); expect(document.document.valuesRedacted).toBe(true); }); @@ -110,7 +110,7 @@ Original body. expect(result.changedTaskId).toBe("R1.3"); expect(result.markdown).toContain("### R1.3 Continue child"); - result = mutateMdtodoDocument(result.markdown, { type: "updateStatus", rxxId: "R1", status: "done" }); + result = mutateMdtodoDocument(result.markdown, { type: "updateStatus", rxxId: "R1", status: "completed" }); expect(result.markdown).toContain("## R1 Root task [completed]"); result = mutateMdtodoDocument(result.markdown, { type: "updateTitle", rxxId: "R1", title: "Renamed root" }); diff --git a/internal/project-management/mdtodo.ts b/internal/project-management/mdtodo.ts index 9a4f079d..62063e3c 100644 --- a/internal/project-management/mdtodo.ts +++ b/internal/project-management/mdtodo.ts @@ -7,6 +7,8 @@ import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; +export type MdtodoTaskStatus = "open" | "in_progress" | "completed"; + const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u; const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu; const rxxIdPattern = /^R\d+(?:\.\d+)*$/iu; @@ -14,19 +16,18 @@ const rxxIdPattern = /^R\d+(?:\.\d+)*$/iu; const statusAliases = new Map([ ["open", "open"], ["todo", "open"], - ["blocked", "blocked"], + ["blocked", "open"], ["in_progress", "in_progress"], ["processing", "in_progress"], - ["completed", "done"], - ["finished", "done"], - ["done", "done"] + ["completed", "completed"], + ["finished", "completed"], + ["done", "completed"] ]); const statusMarkers = new Map([ ["open", null], - ["blocked", "blocked"], ["in_progress", "in_progress"], - ["done", "completed"] + ["completed", "completed"] ]); export async function discoverMdtodoDocuments(options = {}) { @@ -272,7 +273,7 @@ function parseLegacyCheckboxTasks(lines, context) { fileRef: context.fileRef, relativePath: context.relativePath, title, - status: checkbox.toLowerCase() === "x" ? "done" : checkbox === "-" ? "blocked" : "open", + status: checkbox.toLowerCase() === "x" ? "completed" : "open", parentTaskRef, depth, lineNumber: index + 1, @@ -526,9 +527,8 @@ function requireStatus(value) { } function statusRank(status) { - if (status === "done") return 3; - if (status === "in_progress") return 2; - if (status === "blocked") return 1; + if (status === "completed") return 2; + if (status === "in_progress") return 1; return 0; } diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index 0f4e31d3..f878c266 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -65,7 +65,7 @@ test("project management app exposes MDTODO read model and Workbench link writes const tasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo"); expect(tasks.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]); - expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "done"]); + expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "completed"]); expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true); expect(tasks.page).toMatchObject({ offset: 0, limit: 200, total: 2, hasMore: false }); @@ -277,7 +277,7 @@ test("project management app configures HWPOD source and writes MDTODO content", const tasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=hwpod-mdtodo"); expect(tasks.tasks.map((task) => [task.taskId, task.title, task.status])).toEqual([ ["R1", "Edited root", "open"], - ["R1.1", "Child", "done"] + ["R1.1", "Child", "completed"] ]); const updateTask = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(rootTaskRef)}`, { diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index a36169d8..5f3bcb50 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -16,7 +16,7 @@ export { apiKeysAPI } from "./apiKeys"; export { usageAPI } from "./usage"; export { billingAPI } from "./billing"; export { systemAPI, type SkillUploadFileInput } from "./system"; -export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; +export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type MdtodoTaskStatus, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; import { fetchJson } from "./client"; import { accessAPI } from "./access"; diff --git a/web/hwlab-cloud-web/src/api/projectManagement.ts b/web/hwlab-cloud-web/src/api/projectManagement.ts index 043d8541..f7cd176c 100644 --- a/web/hwlab-cloud-web/src/api/projectManagement.ts +++ b/web/hwlab-cloud-web/src/api/projectManagement.ts @@ -97,6 +97,8 @@ export interface MdtodoFileRecord { updatedAt?: string; } +export type MdtodoTaskStatus = "open" | "in_progress" | "completed"; + export interface MdtodoTaskRecord { taskRef: string; projectId?: string; @@ -105,7 +107,7 @@ export interface MdtodoTaskRecord { taskId?: string; rxxId?: string; title?: string; - status?: string; + status?: MdtodoTaskStatus; parentTaskRef?: string | null; depth?: number; lineNumber?: number; @@ -146,7 +148,7 @@ export interface MdtodoTaskDetailRecord { fileRef?: string; relativePath?: string; title?: string; - status?: string; + status?: MdtodoTaskStatus; lineNumber?: number; headingLevel?: number; statusMarker?: string | null; @@ -243,7 +245,7 @@ export interface MdtodoTaskMutationInput { expectedFingerprint?: string; revision?: string; title?: string; - status?: string; + status?: MdtodoTaskStatus; body?: string; } export interface MdtodoTaskMutationResponse extends ProjectManagementEnvelope { @@ -261,7 +263,7 @@ export interface MdtodoTaskQuery { fileRef?: string | null; projectId?: string | null; parentTaskRef?: string | null; - status?: string | null; + status?: MdtodoTaskStatus | null; search?: string | null; limit?: number | null; offset?: number | null; diff --git a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoInfoDialog.vue b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoInfoDialog.vue index 2c59b89b..4e8197e8 100644 --- a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoInfoDialog.vue +++ b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoInfoDialog.vue @@ -141,9 +141,8 @@ function identifier(
State
open {{ taskStatusCounts.open || 0 }} · progress - {{ taskStatusCounts.in_progress || 0 }} · done - {{ taskStatusCounts.done || 0 }} · blocked - {{ taskStatusCounts.blocked || 0 }} + {{ taskStatusCounts.in_progress || 0 }} · completed + {{ taskStatusCounts.completed || 0 }}
diff --git a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskPanel.vue b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskPanel.vue index cdaace73..eee684c3 100644 --- a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskPanel.vue +++ b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskPanel.vue @@ -7,6 +7,7 @@ import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord, + MdtodoTaskStatus, } from "@/api"; import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue"; import ContentViewer from "@/components/common/ContentViewer.vue"; @@ -28,7 +29,7 @@ const props = defineProps<{ editingTitle: boolean; editingBody: boolean; editTitle: string; - editStatus: string; + editStatus: MdtodoTaskStatus; editBody: string; deleteConfirm: boolean; mutationLoading: boolean; @@ -50,7 +51,7 @@ const props = defineProps<{ const emit = defineEmits<{ "update:editTitle": [value: string]; - "update:editStatus": [value: string]; + "update:editStatus": [value: MdtodoTaskStatus]; "update:editBody": [value: string]; "update:providerProfile": [value: ProviderProfile]; beginTitleEdit: []; @@ -73,7 +74,7 @@ function titleKeydown(event: KeyboardEvent): void { } function updateStatus(event: Event): void { - emit("update:editStatus", (event.target as HTMLSelectElement).value); + emit("update:editStatus", (event.target as HTMLSelectElement).value as MdtodoTaskStatus); if (!props.editingTitle) emit("saveTaskBasics"); } @@ -154,8 +155,7 @@ function updateStatus(event: Event): void { > - - + diff --git a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskTree.vue b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskTree.vue index 2dde8a24..9e04a294 100644 --- a/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskTree.vue +++ b/web/hwlab-cloud-web/src/components/mdtodo/MdtodoTaskTree.vue @@ -14,13 +14,14 @@ import { visibleTasks, } from "@/composables/mdtodo/mdtodoTaskTreeModel"; import { statusLabel } from "@/composables/mdtodo/useMdtodoDisplay"; +import type { MdtodoTaskStatusFilter } from "@/composables/mdtodo/useMdtodoViewState"; const props = defineProps<{ tasks: MdtodoTaskRecord[]; selectedTaskRef: string | null; collapsedTaskRefs: Set; search: string; - status: string; + status: MdtodoTaskStatusFilter; page: MdtodoTaskPage | null; loading: boolean; fileScopedTaskCount: number; @@ -30,7 +31,7 @@ const emit = defineEmits<{ select: [taskRef: string]; "update:collapsedTaskRefs": [value: Set]; updateSearch: [value: string]; - updateStatus: [value: string]; + updateStatus: [value: MdtodoTaskStatusFilter]; loadMore: []; }>(); @@ -170,13 +171,12 @@ function keydown( data-testid="mdtodo-task-status-filter" aria-label="任务状态" :value="status" - @change="emit('updateStatus', ($event.target as HTMLSelectElement).value)" + @change="emit('updateStatus', ($event.target as HTMLSelectElement).value as MdtodoTaskStatusFilter)" > - - + diff --git a/web/hwlab-cloud-web/src/composables/admin/useBillingWorkspace.ts b/web/hwlab-cloud-web/src/composables/admin/useBillingWorkspace.ts new file mode 100644 index 00000000..c4c65df3 --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/admin/useBillingWorkspace.ts @@ -0,0 +1,225 @@ +import { computed, onMounted, reactive, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { billingAPI } from "@/api"; +import type { DataGridColumn } from "@/components/common/DataGrid.vue"; +import { useConsoleViewState } from "@/composables/useConsoleViewState"; +import type { RedeemCodeSummary, RedeemRedemptionSummary } from "@/types"; + +export function useBillingWorkspace() { +const route = useRoute(); +const router = useRouter(); +const viewState = useConsoleViewState<"summary" | "detail">({ + view: "summary", + viewValues: ["summary", "detail"], + tab: "codes", +}); +const codes = ref([]); +const redemptions = ref([]); +const loading = ref(true); +const loaded = ref(false); +const error = ref(null); +const pending = ref(null); +const selectedCodeId = ref( + typeof route.query.selectedCodeId === "string" + ? route.query.selectedCodeId + : null, +); +const createOpen = ref(false); +const createdCode = ref(null); +const filters = reactive({ + status: typeof route.query.filter === "string" ? route.query.filter : "", + userId: typeof route.query.userId === "string" ? route.query.userId : "", + codeId: typeof route.query.codeId === "string" ? route.query.codeId : "", +}); +const form = reactive({ + displayName: "", + creditAmount: 10, + reason: "redeem", + maxRedemptions: 1, + perUserLimit: 1, + expiresAt: "", +}); +let refreshSequence = 0; +const activeTab = computed({ + get: () => (viewState.tab.value === "redemptions" ? "redemptions" : "codes"), + set: (value: string) => + void viewState.update( + { tab: value === "codes" ? undefined : "redemptions" }, + false, + ), +}); +const viewMode = computed({ + get: () => viewState.view.value, + set: (value: "summary" | "detail") => + void viewState.update( + { view: value === "summary" ? undefined : value }, + false, + ), +}); +const selectedCode = computed(() => + selectedCodeId.value + ? (codes.value.find((item) => codeKey(item) === selectedCodeId.value) ?? + null) + : null, +); +const asyncState = computed(() => + loading.value + ? loaded.value + ? "refreshing" + : "initial-loading" + : error.value + ? loaded.value + ? "partial" + : "error" + : loaded.value + ? "ready" + : "empty", +); +const statusItems = computed(() => [ + { id: "codes", label: "Codes", value: codes.value.length }, + { + id: "active", + label: "Active", + value: codes.value.filter((item) => item.status === "active").length, + tone: "ok" as const, + }, + { id: "redemptions", label: "Redemptions", value: redemptions.value.length }, + { id: "authority", label: "Authority", value: "user-billing" }, +]); +const codeColumns: DataGridColumn[] = [ + { key: "code", label: "Code" }, + { key: "status", label: "Status" }, + { key: "credits", label: "Credits", align: "right" }, + { key: "usage", label: "Usage" }, + { key: "expires", label: "Expires" }, + { key: "actions", label: "Action", align: "right" }, +]; +const redemptionColumns: DataGridColumn[] = [ + { key: "time", label: "Time" }, + { key: "user", label: "User" }, + { key: "code", label: "Code" }, + { key: "credits", label: "Credits", align: "right" }, + { key: "status", label: "Status" }, + { key: "ledger", label: "Ledger" }, +]; + +onMounted(refreshAll); +watch( + () => [route.query.filter, route.query.userId, route.query.codeId].join("\0"), + () => { + filters.status = + typeof route.query.filter === "string" ? route.query.filter : ""; + filters.userId = + typeof route.query.userId === "string" ? route.query.userId : ""; + filters.codeId = + typeof route.query.codeId === "string" ? route.query.codeId : ""; + void refreshAll(); + }, +); +watch( + () => route.query.selectedCodeId, + (value) => { + selectedCodeId.value = typeof value === "string" ? value : null; + }, +); +async function refreshAll(): Promise { + const requestSequence = ++refreshSequence; + loading.value = true; + error.value = null; + const [codeResult, redemptionResult] = await Promise.all([ + billingAPI.adminRedeemCodes({ + status: filters.status || undefined, + limit: 100, + }), + billingAPI.adminRedeemRedemptions({ + userId: filters.userId || undefined, + codeId: filters.codeId || undefined, + limit: 100, + }), + ]); + if (requestSequence !== refreshSequence) return; + const errors: string[] = []; + if (codeResult.ok) codes.value = codeResult.data?.codes ?? []; + else errors.push(`Codes: ${codeResult.error ?? `HTTP ${codeResult.status}`}`); + if (redemptionResult.ok) + redemptions.value = redemptionResult.data?.redemptions ?? []; + else + errors.push( + `Redemptions: ${redemptionResult.error ?? `HTTP ${redemptionResult.status}`}`, + ); + error.value = errors.join(" · ") || null; + loading.value = false; + loaded.value = true; +} +async function persistFilters(): Promise { + await router.push({ + query: { + ...route.query, + filter: filters.status || undefined, + userId: filters.userId.trim() || undefined, + codeId: filters.codeId.trim() || undefined, + }, + }); +} +async function selectCode(code: RedeemCodeSummary): Promise { + const codeId = codeKey(code); + selectedCodeId.value = codeId; + await router.push({ query: { ...route.query, selectedCodeId: codeId } }); +} +async function closeInspector(): Promise { + selectedCodeId.value = null; + await router.push({ query: { ...route.query, selectedCodeId: undefined } }); +} +async function setStatus(code: RedeemCodeSummary): Promise { + if (!code.id) return; + pending.value = `status:${code.id}`; + const response = await billingAPI.updateAdminRedeemCode(code.id, { + status: code.status === "disabled" ? "active" : "disabled", + }); + pending.value = null; + if (!response.ok) error.value = response.error ?? `HTTP ${response.status}`; + else await refreshAll(); +} +async function createCode(): Promise { + pending.value = "create"; + const body: Record = { + ...form, + creditAmount: Number(form.creditAmount), + maxRedemptions: Number(form.maxRedemptions), + perUserLimit: Number(form.perUserLimit), + }; + if (form.expiresAt) body.expiresAt = new Date(form.expiresAt).toISOString(); + else delete body.expiresAt; + const response = await billingAPI.createAdminRedeemCode(body); + pending.value = null; + if (!response.ok) { + error.value = response.error ?? `HTTP ${response.status}`; + return; + } + createdCode.value = response.data?.redeemCode ?? null; + await refreshAll(); +} +function codeKey(item: RedeemCodeSummary): string { + return item.id || item.codePrefix || "redeem-code"; +} +function redemptionKey(item: RedeemRedemptionSummary): string { + return item.id || `${item.userId}:${item.codeId}:${item.createdAt}`; +} +function formatNumber(value: unknown): string { + return new Intl.NumberFormat("zh-CN").format(Number(value ?? 0)); +} +function formatDate(value: unknown): string { + const text = String(value ?? ""); + if (!text) return "-"; + const date = new Date(text); + return Number.isNaN(date.getTime()) ? text : date.toLocaleString("zh-CN"); +} + + return { + codes, redemptions, loading, loaded, error, pending, selectedCodeId, + createOpen, createdCode, filters, form, activeTab, viewMode, selectedCode, + asyncState, statusItems, codeColumns, redemptionColumns, refreshAll, + persistFilters, selectCode, closeInspector, setStatus, createCode, + codeKey, redemptionKey, formatNumber, formatDate, + }; +} diff --git a/web/hwlab-cloud-web/src/composables/admin/useUserAuditWorkspace.ts b/web/hwlab-cloud-web/src/composables/admin/useUserAuditWorkspace.ts new file mode 100644 index 00000000..3caee8f6 --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/admin/useUserAuditWorkspace.ts @@ -0,0 +1,129 @@ +import { reactive, ref, watch } from "vue"; +import { useRoute, useRouter, type LocationQueryRaw } from "vue-router"; +import { usageAPI } from "@/api"; +import type { AdminUsageRow, UsageLedgerRow } from "@/types"; + +const auditQueryMap = { + search: "auditSearch", + userId: "auditUser", + serviceId: "service", + resourceType: "resource", + apiKeyId: "apiKey", + status: "auditStatus", + kind: "kind", + source: "source", + idempotencyKey: "idempotency", + from: "from", + to: "to", +} as const; + +export function useUserAuditWorkspace() { + const route = useRoute(); + const router = useRouter(); + const auditTab = ref<"usage" | "ledger">(route.query.audit === "ledger" ? "ledger" : "usage"); + const auditLoading = ref(false); + const auditError = ref(null); + const auditUsageRows = ref([]); + const auditLedgerRows = ref([]); + const auditPage = ref(Math.max(1, Number(route.query.auditPage) || 1)); + const auditPageSize = 25; + const auditTotal = ref(0); + const auditFilters = reactive({ + search: textQuery("auditSearch"), + userId: textQuery("auditUser"), + serviceId: textQuery("service"), + resourceType: textQuery("resource"), + apiKeyId: textQuery("apiKey"), + status: textQuery("auditStatus"), + kind: textQuery("kind"), + source: textQuery("source"), + idempotencyKey: textQuery("idempotency"), + from: textQuery("from"), + to: textQuery("to"), + }); + let auditRequestSequence = 0; + + function textQuery(key: string): string { + return typeof route.query[key] === "string" ? String(route.query[key]) : ""; + } + function auditParams(extra: Record = {}): Record { + const params: Record = { page: auditPage.value, pageSize: auditPageSize, ...extra }; + for (const [key, value] of Object.entries(auditFilters)) + if (String(value).trim()) params[key] = String(value).trim(); + if (auditTab.value === "usage") { + delete params.kind; + delete params.source; + } else { + delete params.serviceId; + delete params.resourceType; + delete params.apiKeyId; + } + return params; + } + async function loadAudit(updateRoute = true): Promise { + const requestSequence = ++auditRequestSequence; + if (updateRoute) await syncAuditQuery(); + if (requestSequence !== auditRequestSequence) return; + auditLoading.value = true; + auditError.value = null; + const response = auditTab.value === "usage" + ? await usageAPI.adminUsage(auditParams()) + : await usageAPI.adminLedger(auditParams()); + if (requestSequence !== auditRequestSequence) return; + auditLoading.value = false; + if (!response.ok) { + auditError.value = response.error ?? `HTTP ${response.status}`; + return; + } + auditTotal.value = Number(response.data?.total ?? response.data?.count ?? 0); + if (auditTab.value === "usage") auditUsageRows.value = response.data?.rows ?? []; + else auditLedgerRows.value = response.data?.rows ?? []; + } + async function syncAuditQuery(): Promise { + const query: LocationQueryRaw = { + ...route.query, + audit: auditTab.value === "ledger" ? "ledger" : undefined, + auditPage: auditPage.value > 1 ? String(auditPage.value) : undefined, + }; + for (const [key, queryKey] of Object.entries(auditQueryMap)) + query[queryKey] = auditFilters[key as keyof typeof auditFilters].trim() || undefined; + await router.push({ query }); + } + async function setAuditTab(value: "usage" | "ledger"): Promise { + auditTab.value = value; + auditPage.value = 1; + await loadAudit(); + } + async function setAuditPage(value: number): Promise { + auditPage.value = value; + await loadAudit(); + } + function downloadAudit(): void { + window.location.assign(auditTab.value === "usage" + ? usageAPI.adminUsageExportUrl(auditParams({ limit: 1000 })) + : usageAPI.adminLedgerExportUrl(auditParams({ limit: 1000 }))); + } + + watch( + () => route.query, + () => { + const nextPage = Math.max(1, Number(route.query.auditPage) || 1); + const nextTab = route.query.audit === "ledger" ? "ledger" : "usage"; + const changed = auditPage.value !== nextPage || auditTab.value !== nextTab || + Object.entries(auditQueryMap).some(([key, queryKey]) => + auditFilters[key as keyof typeof auditFilters] !== textQuery(queryKey)); + if (!changed) return; + for (const [key, queryKey] of Object.entries(auditQueryMap)) + auditFilters[key as keyof typeof auditFilters] = textQuery(queryKey); + auditPage.value = nextPage; + auditTab.value = nextTab; + void loadAudit(false); + }, + ); + + return { + auditTab, auditLoading, auditError, auditUsageRows, auditLedgerRows, + auditPage, auditPageSize, auditTotal, auditFilters, loadAudit, + setAuditTab, setAuditPage, downloadAudit, + }; +} diff --git a/web/hwlab-cloud-web/src/composables/admin/useUsersWorkspace.ts b/web/hwlab-cloud-web/src/composables/admin/useUsersWorkspace.ts new file mode 100644 index 00000000..1f32037b --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/admin/useUsersWorkspace.ts @@ -0,0 +1,382 @@ +import { computed, onMounted, reactive, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { usageAPI } from "@/api"; +import { useConsoleViewState } from "@/composables/useConsoleViewState"; +import { useUserAuditWorkspace } from "@/composables/admin/useUserAuditWorkspace"; +import { + auditKey, formatDate, formatNumber, formatSigned, ledgerColumns, + metadataPreview, userColumns, userKey, usageColumns, +} from "@/composables/admin/usersWorkspacePresentation"; +import type { + AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, + ApiKeyRecord, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, + UsageLedgerRow, UsageReservationRow, +} from "@/types"; + +export function useUsersWorkspace() { +const route = useRoute(); +const router = useRouter(); +const viewState = useConsoleViewState<"list" | "grid">({ + view: "list", + viewValues: ["list", "grid"], + tab: "profile", +}); +const users = ref([]); +const plans = ref([]); +const detail = ref(null); +const selectedUserId = ref( + typeof route.params.userId === "string" ? route.params.userId : null, +); +const loading = ref(true); +const loaded = ref(false); +const detailLoading = ref(false); +const error = ref(null); +const mutationError = ref(null); +const pending = ref(null); +const page = ref(Math.max(1, Number(route.query.cursor) || 1)); +const pageSize = 25; +const total = ref(0); +const stateAuthority = ref("pk01-postgres"); +const createOpen = ref(false); + const audit = useUserAuditWorkspace(); +const filters = reactive({ + search: textQuery("q"), + status: textQuery("filter"), + role: textQuery("role"), +}); +const createForm = reactive({ + email: "", + username: "", + displayName: "", + password: "", + role: "user", + status: "active", + initialCredits: 0, +}); +const editForm = reactive({ + email: "", + username: "", + displayName: "", + password: "", + role: "user", + status: "active", + planId: "default", +}); +const creditForm = reactive({ + deltaCredits: 0, + reason: "admin_adjust", + idempotencyKey: "", +}); +let usersRequestSequence = 0; +let detailRequestSequence = 0; + +const selectedSummary = computed( + () => + detail.value?.summary ?? + users.value.find((item) => item.user?.id === selectedUserId.value) ?? + null, +); +const selectedUser = computed(() => selectedSummary.value?.user ?? null); +const selectedEntitlements = computed( + () => selectedSummary.value?.entitlements ?? [], +); +const ledgerRows = computed( + () => detail.value?.ledger?.rows ?? [], +); +const apiKeys = computed( + () => detail.value?.apiKeys?.keys ?? [], +); +const reservations = computed( + () => + detail.value?.reservations?.rows ?? + detail.value?.reservations?.active ?? + selectedSummary.value?.reservations?.active ?? + [], +); +const viewMode = computed({ + get: () => viewState.view.value, + set: (value: "list" | "grid") => + void viewState.update( + { view: value === "list" ? undefined : value }, + false, + ), +}); +const detailTab = computed({ + get: () => viewState.tab.value || "profile", + set: (value: string) => + void viewState.update( + { tab: value === "profile" ? undefined : value }, + false, + ), +}); +const asyncState = computed(() => + loading.value + ? users.value.length + ? "refreshing" + : "initial-loading" + : error.value + ? users.value.length + ? "partial" + : "error" + : loaded.value + ? "ready" + : "empty", +); +const statusItems = computed(() => [ + { id: "users", label: "Users", value: total.value }, + { + id: "active", + label: "Active on page", + value: users.value.filter((item) => item.user?.status === "active").length, + tone: "ok" as const, + }, + { id: "authority", label: "Authority", value: stateAuthority.value }, + { + id: "selected", + label: "Selected", + value: selectedUser.value?.username || "-", + }, +]); + + onMounted(async () => { + await Promise.all([loadPlans(), loadUsers(false), audit.loadAudit(false)]); + if (selectedUserId.value) await selectUser(selectedUserId.value, false); + }); + + watch( + () => route.params.userId, + (value) => { + const userId = typeof value === "string" ? value : ""; + if (userId && userId !== selectedUserId.value) void selectUser(userId, false); + if (!userId) { + detailRequestSequence += 1; + selectedUserId.value = null; + detail.value = null; + detailLoading.value = false; + } + }, + ); + watch( + () => route.query, + () => { + const nextPage = Math.max(1, Number(route.query.cursor) || 1); + const changed = filters.search !== textQuery("q") || + filters.status !== textQuery("filter") || + filters.role !== textQuery("role") || page.value !== nextPage; + if (!changed) return; + filters.search = textQuery("q"); + filters.status = textQuery("filter"); + filters.role = textQuery("role"); + page.value = nextPage; + void loadUsers(false); + }, + ); + +function textQuery(key: string): string { + return typeof route.query[key] === "string" ? String(route.query[key]) : ""; +} + +async function loadPlans(): Promise { + const response = await usageAPI.adminPlans(); + if (response.ok) plans.value = response.data?.plans ?? []; +} +async function loadUsers(updateRoute = true): Promise { + const requestSequence = ++usersRequestSequence; + if (updateRoute) await syncUserQuery(); + if (requestSequence !== usersRequestSequence) return; + loading.value = true; + const response = await usageAPI.adminUsers({ + page: page.value, + pageSize, + search: filters.search.trim(), + status: filters.status, + role: filters.role, + }); + if (requestSequence !== usersRequestSequence) return; + if (!response.ok) error.value = response.error ?? `HTTP ${response.status}`; + else { + users.value = response.data?.users ?? []; + total.value = Number( + response.data?.total ?? response.data?.count ?? users.value.length, + ); + stateAuthority.value = String( + response.data?.state?.stateAuthority ?? "pk01-postgres", + ); + error.value = null; + } + loading.value = false; + loaded.value = true; +} +async function applyFilters(): Promise { + page.value = 1; + await loadUsers(); +} +async function setPage(value: number): Promise { + page.value = value; + await loadUsers(); +} +async function syncUserQuery(): Promise { + const query = { + ...route.query, + q: filters.search.trim() || undefined, + filter: filters.status || undefined, + role: filters.role || undefined, + cursor: page.value > 1 ? String(page.value) : undefined, + }; + await router.push({ query }); +} +async function selectUser( + userId: string | undefined, + updateRoute = true, +): Promise { + if (!userId) return; + const requestSequence = ++detailRequestSequence; + selectedUserId.value = userId; + if (updateRoute && route.params.userId !== userId) + await router.push({ + name: "UserDetail", + params: { userId }, + query: route.query, + }); + if ( + requestSequence !== detailRequestSequence || + selectedUserId.value !== userId + ) + return; + detailLoading.value = true; + mutationError.value = null; + const response = await usageAPI.adminUser(userId); + if ( + requestSequence !== detailRequestSequence || + selectedUserId.value !== userId + ) + return; + if (!response.ok) { + mutationError.value = response.error ?? `HTTP ${response.status}`; + detail.value = null; + } else { + detail.value = response.data?.detail ?? null; + fillEditForm( + detail.value?.summary?.user ?? + users.value.find((item) => item.user?.id === userId)?.user, + ); + } + detailLoading.value = false; +} +function closeInspector(): void { + detailRequestSequence += 1; + selectedUserId.value = null; + detail.value = null; + detailLoading.value = false; + void router.push({ name: "Users", query: route.query }); +} +function fillEditForm( + user: (AuthUser & { email?: string }) | null | undefined, +): void { + editForm.email = user?.email ?? ""; + editForm.username = user?.username ?? ""; + editForm.displayName = user?.displayName ?? ""; + editForm.password = ""; + editForm.role = user?.role === "admin" ? "admin" : "user"; + editForm.status = String(user?.status ?? "active"); + editForm.planId = selectedSummary.value?.planId ?? "default"; +} +async function createUser(): Promise { + pending.value = "create"; + mutationError.value = null; + const response = await usageAPI.createAdminUser({ + ...createForm, + initialCredits: Number(createForm.initialCredits || 0), + }); + pending.value = null; + if (!response.ok) { + mutationError.value = response.error ?? `HTTP ${response.status}`; + return; + } + Object.assign(createForm, { + email: "", + username: "", + displayName: "", + password: "", + role: "user", + status: "active", + initialCredits: 0, + }); + createOpen.value = false; + await loadUsers(false); + if (response.data?.user?.id) await selectUser(response.data.user.id); +} +async function saveUser(): Promise { + const userId = selectedUser.value?.id; + if (!userId) return; + pending.value = `save:${userId}`; + mutationError.value = null; + const body: Record = { + email: editForm.email.trim(), + username: editForm.username.trim(), + displayName: editForm.displayName.trim(), + role: editForm.role, + status: editForm.status, + planId: editForm.planId || "default", + }; + if (editForm.password) body.password = editForm.password; + const response = await usageAPI.updateAdminUser(userId, body); + pending.value = null; + if (!response.ok) + mutationError.value = response.error ?? `HTTP ${response.status}`; + else { + await loadUsers(false); + await selectUser(userId, false); + } +} +async function setUserStatus(item: AdminBillingUserSummary): Promise { + const userId = item.user?.id; + if (!userId) return; + const status: AccessUserStatus = + item.user?.status === "disabled" ? "active" : "disabled"; + pending.value = `status:${userId}`; + mutationError.value = null; + const response = await usageAPI.updateAdminUserStatus(userId, status); + pending.value = null; + if (!response.ok) + mutationError.value = response.error ?? `HTTP ${response.status}`; + else { + await loadUsers(false); + if (selectedUserId.value === userId) await selectUser(userId, false); + } +} +async function adjustCredits(): Promise { + const userId = selectedUser.value?.id; + if (!userId) return; + pending.value = `credits:${userId}`; + mutationError.value = null; + const response = await usageAPI.adjustAdminCredits(userId, { + deltaCredits: Number(creditForm.deltaCredits), + reason: creditForm.reason.trim() || "admin_adjust", + idempotencyKey: creditForm.idempotencyKey.trim() || undefined, + }); + pending.value = null; + if (!response.ok) + mutationError.value = response.error ?? `HTTP ${response.status}`; + else { + creditForm.deltaCredits = 0; + creditForm.idempotencyKey = ""; + await Promise.all([ + loadUsers(false), + selectUser(userId, false), + audit.loadAudit(false), + ]); + } +} + return { + users, plans, detail, selectedUserId, loading, loaded, detailLoading, + error, mutationError, pending, page, pageSize, total, stateAuthority, + createOpen, filters, createForm, editForm, creditForm, selectedSummary, + selectedUser, selectedEntitlements, ledgerRows, apiKeys, reservations, + viewMode, detailTab, asyncState, statusItems, loadPlans, loadUsers, + applyFilters, setPage, selectUser, closeInspector, createUser, saveUser, + setUserStatus, adjustCredits, userColumns, userKey, formatNumber, + ...audit, usageColumns, ledgerColumns, auditKey, formatSigned, formatDate, + metadataPreview, + }; +} diff --git a/web/hwlab-cloud-web/src/composables/admin/usersWorkspacePresentation.ts b/web/hwlab-cloud-web/src/composables/admin/usersWorkspacePresentation.ts new file mode 100644 index 00000000..4249b23a --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/admin/usersWorkspacePresentation.ts @@ -0,0 +1,59 @@ +import type { DataGridColumn } from "@/components/common/DataGrid.vue"; +import type { AdminBillingUserSummary, AdminUsageRow, UsageLedgerRow } from "@/types"; + +export const userColumns: DataGridColumn[] = [ + { key: "user", label: "User" }, + { key: "status", label: "Status" }, + { key: "credits", label: "Credits", align: "right" }, + { key: "usage", label: "Usage", align: "right" }, + { key: "keys", label: "Keys", align: "right" }, + { key: "actions", label: "Action", align: "right" }, +]; + +export const usageColumns: DataGridColumn[] = [ + { key: "time", label: "Time" }, + { key: "user", label: "User" }, + { key: "service", label: "Service" }, + { key: "resource", label: "Resource" }, + { key: "credits", label: "Credits", align: "right" }, + { key: "status", label: "Status" }, + { key: "metadata", label: "Metadata" }, +]; + +export const ledgerColumns: DataGridColumn[] = [ + { key: "time", label: "Time" }, + { key: "user", label: "User" }, + { key: "action", label: "Action" }, + { key: "delta", label: "Delta", align: "right" }, + { key: "balance", label: "Before/After", align: "right" }, + { key: "operator", label: "Operator" }, + { key: "idempotency", label: "Idempotency" }, +]; + +export function userKey(item: AdminBillingUserSummary): string { + return item.user?.id ?? item.user?.username ?? "unknown-user"; +} +export function auditKey(item: AdminUsageRow | UsageLedgerRow): string { + return item.id ?? `${item.userId ?? "user"}:${item.createdAt ?? "time"}`; +} +export function formatNumber(value: unknown): string { + const number = Number(value ?? 0); + return Number.isFinite(number) ? new Intl.NumberFormat("zh-CN").format(number) : "0"; +} +export function formatSigned(value: unknown): string { + const number = Number(value ?? 0); + return `${number > 0 ? "+" : ""}${formatNumber(number)}`; +} +export function formatDate(value: unknown): string { + const text = String(value ?? ""); + if (!text) return "-"; + const date = new Date(text); + return Number.isNaN(date.getTime()) ? text : date.toLocaleString("zh-CN"); +} +export function metadataPreview(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) return "-"; + return Object.entries(value as Record) + .slice(0, 4) + .map(([key, item]) => `${key}=${typeof item === "object" ? "[structured]" : String(item)}`) + .join(" · ") || "-"; +} diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoDisplay.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoDisplay.ts index 74099b6d..4708d56c 100644 --- a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoDisplay.ts +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoDisplay.ts @@ -10,7 +10,7 @@ export function fileDisplayName(file: MdtodoFileRecord): string { } export function statusLabel(value?: string | null): string { - if (value === "done") return "完成"; + if (value === "completed") return "完成"; if (value === "blocked") return "阻塞"; if (value === "in_progress") return "进行中"; if (value === "open") return "待办"; diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoTaskData.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoTaskData.ts index 4cf7dbff..145b9876 100644 --- a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoTaskData.ts +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoTaskData.ts @@ -2,13 +2,13 @@ // Responsibility: file list, Rxx task window, task detail and workbench links loading via public API. import { computed, ref } from "vue"; -import { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskPage, type ProjectWorkbenchLinkRecord } from "@/api"; +import { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskPage, type MdtodoTaskStatus, type ProjectWorkbenchLinkRecord } from "@/api"; const TASK_WINDOW_LIMIT = 100; export interface MdtodoTaskWindowQuery { search?: string | null; - status?: string | null; + status?: MdtodoTaskStatus | null; } /** diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.test.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.test.ts index 21a4e86e..e802aadb 100644 --- a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.test.ts +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.test.ts @@ -45,7 +45,7 @@ describe("useMdtodoViewState", () => { view: "queue", tab: "tasks", q: "old", - status: "done", + status: "completed", }); await state.update( { view: "workspace", tab: "document", search: "", status: "all" }, diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.ts index 51d54f43..4d3cd8f9 100644 --- a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.ts +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoViewState.ts @@ -1,8 +1,10 @@ import { computed } from "vue"; import type { RouteLocationNormalizedLoaded, Router } from "vue-router"; +import type { MdtodoTaskStatus } from "@/api"; export type MdtodoViewMode = "workspace" | "queue"; export type MdtodoMobileTab = "tasks" | "document" | "report"; +export type MdtodoTaskStatusFilter = "all" | MdtodoTaskStatus; function queryValue(route: RouteLocationNormalizedLoaded, key: string): string { const value = route.query[key]; @@ -13,6 +15,12 @@ function queryValue(route: RouteLocationNormalizedLoaded, key: string): string { : ""; } +function normalizeStatusFilter(value: string): MdtodoTaskStatusFilter { + if (value === "in_progress") return value; + if (value === "completed") return value; + return value === "open" ? value : "all"; +} + export function useMdtodoViewState( router: Router, route: RouteLocationNormalizedLoaded, @@ -25,14 +33,14 @@ export function useMdtodoViewState( return value === "tasks" || value === "report" ? value : "document"; }); const search = computed(() => queryValue(route, "q")); - const status = computed(() => queryValue(route, "status") || "all"); + const status = computed(() => normalizeStatusFilter(queryValue(route, "status"))); async function update( next: { view?: MdtodoViewMode; tab?: MdtodoMobileTab; search?: string; - status?: string; + status?: MdtodoTaskStatusFilter; }, replace = false, ): Promise { diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceActions.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceActions.ts new file mode 100644 index 00000000..e121abd4 --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceActions.ts @@ -0,0 +1,293 @@ +import { providerProfilesAPI, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskRecord } from "@/api"; +import type { ProviderProfileCatalogResponse } from "@/types"; +import type { Router, RouteLocationNormalizedLoaded } from "vue-router"; +import type { useMdtodoReportPreview } from "@/composables/mdtodo/useMdtodoReportPreview"; +import type { useMdtodoSource } from "@/composables/mdtodo/useMdtodoSource"; +import type { useMdtodoTaskData } from "@/composables/mdtodo/useMdtodoTaskData"; +import type { useMdtodoTaskMutation } from "@/composables/mdtodo/useMdtodoTaskMutation"; +import type { useMdtodoViewState } from "@/composables/mdtodo/useMdtodoViewState"; +import type { useMdtodoWorkbenchLaunch } from "@/composables/mdtodo/useMdtodoWorkbenchLaunch"; +import type { MdtodoWorkspaceNavigation } from "@/composables/mdtodo/useMdtodoWorkspaceNavigation"; +import type { MdtodoWorkspaceState } from "@/composables/mdtodo/useMdtodoWorkspaceState"; + +export function useMdtodoWorkspaceActions(dependencies: { + router: Router; + route: RouteLocationNormalizedLoaded; + source: ReturnType; + taskData: ReturnType; + mutation: ReturnType; + report: ReturnType; + launch: ReturnType; + viewState: ReturnType; + state: MdtodoWorkspaceState; + navigation: MdtodoWorkspaceNavigation; +}) { + const { router, route, source, taskData, mutation, report, launch, viewState, state, navigation } = dependencies; + const { selectSource, reloadWindow, syncRoute, loadWindow } = navigation; + const { + selectedSourceId, selectedFileRef, selectedTaskRef, selectedSource, + selectedFile, selectedTask, currentTaskDetail, taskDetailPartial, + mutationDisabled, createDisabledReason, editTitle, editStatus, editBody, editingTitle, + editingBody, newTaskTitle, newTaskBody, showTaskCreate, deleteConfirm, + selectedTaskLinks, selectedTaskBody, selectedFileName, reportPaneCollapsed, + launchDisabled, showSourceConfig, providerProfile, providerOptions, + providerReady, providerError, + } = state; + function taskFingerprint(): string | undefined { + return ( + selectedTask.value?.sourceFingerprint || + selectedFile.value?.fingerprint || + undefined + ); + } + + function rejectMutation(): boolean { + if (!mutationDisabled.value) return false; + mutation.taskMutationMessage.value = null; + mutation.taskMutationError.value = + "当前仅有最近一次任务投影;Node 恢复并读到源 Markdown 后才能写入。"; + return true; + } + + function resetEditor(task: MdtodoTaskRecord | null): void { + editingTitle.value = false; + editingBody.value = false; + editTitle.value = task?.title || ""; + editStatus.value = task?.status || "open"; + const detail = taskData.taskDetail.value; + editBody.value = + detail && detail.taskRef === task?.taskRef ? detail.body || "" : ""; + deleteConfirm.value = false; + } + + async function saveBasics(): Promise { + if (rejectMutation() || !selectedTask.value) return; + const input: MdtodoTaskMutationInput = { + expectedFingerprint: taskFingerprint(), + }; + if ( + editTitle.value.trim() && + editTitle.value.trim() !== selectedTask.value.title + ) + input.title = editTitle.value.trim(); + if (editStatus.value !== (selectedTask.value.status || "open")) + input.status = editStatus.value; + if (!input.title && !input.status) + mutation.taskMutationMessage.value = "任务标题和状态没有变化"; + else if ( + await mutation.updateTask( + selectedTask.value.taskRef, + input, + "任务已保存", + reloadWindow, + ) + ) + editingTitle.value = false; + } + + async function saveBody(): Promise { + if (rejectMutation() || !selectedTask.value) return; + if ( + await mutation.updateTask( + selectedTask.value.taskRef, + { expectedFingerprint: taskFingerprint(), body: editBody.value }, + "正文已保存", + reloadWindow, + ) + ) + editingBody.value = false; + } + + async function createTask( + kind: "root" | "subtask" | "continue", + ): Promise { + if (rejectMutation()) return; + const sourceId = selectedSourceId.value; + const fileRef = selectedFileRef.value; + if (!sourceId || !fileRef) { + mutation.taskMutationError.value = + createDisabledReason.value || "请先选择 MDTODO 文件"; + return; + } + const title = newTaskTitle.value.trim(); + if (!title) { + mutation.taskMutationError.value = "新任务标题不能为空"; + return; + } + const input: MdtodoTaskMutationInput = { + sourceId, + fileRef, + expectedFingerprint: taskFingerprint(), + title, + body: newTaskBody.value, + }; + if (kind === "subtask" && selectedTask.value) + input.parentTaskRef = selectedTask.value.taskRef; + if (kind === "continue" && selectedTask.value) + input.afterTaskRef = selectedTask.value.taskRef; + const nextTaskRef = await mutation.createTask( + input, + "任务已创建", + async () => {}, + ); + if (nextTaskRef) { + report.closeReportPreview(); + newTaskTitle.value = ""; + newTaskBody.value = ""; + showTaskCreate.value = false; + const loaded = await loadWindow(sourceId, { + fileRef, + taskId: nextTaskRef, + updateRoute: false, + mode: "persistent", + }); + if (!loaded) { + mutation.taskMutationMessage.value = + "任务已创建;任务索引刷新失败,请重试"; + return; + } + await syncRoute(false); + } + } + + async function deleteTask(): Promise { + if (rejectMutation() || !selectedTask.value) return; + if (!deleteConfirm.value) { + deleteConfirm.value = true; + mutation.taskMutationMessage.value = "再次点击确认删除当前任务及子任务"; + return; + } + const deletedTaskRef = selectedTask.value.taskRef; + const expectedFingerprint = taskFingerprint(); + const fallbackTaskRef = + taskData.tasks.value.find((task) => task.taskRef !== deletedTaskRef) + ?.taskRef ?? null; + const previousTaskRef = selectedTaskRef.value; + selectedTaskRef.value = null; + const reloadAfterDelete = async () => { + if (selectedSourceId.value) { + await loadWindow(selectedSourceId.value, { + fileRef: selectedFileRef.value, + taskId: null, + updateRoute: false, + mode: "persistent", + }); + } + }; + const result = await mutation.deleteTask( + deletedTaskRef, + { expectedFingerprint }, + reloadAfterDelete, + ); + if (!result) { + selectedTaskRef.value = previousTaskRef; + return; + } + report.closeReportPreview(); + deleteConfirm.value = false; + selectedTaskRef.value = + taskData.tasks.value.find((task) => task.taskRef !== deletedTaskRef) + ?.taskRef ?? fallbackTaskRef; + await syncRoute(false, null); + } + + async function openReport(link: MdtodoTaskLinkRecord): Promise { + if ( + !selectedTaskRef.value || + !(await report.openReportPreview(selectedTaskRef.value, link)) + ) + return; + reportPaneCollapsed.value = false; + await syncRoute(); + await viewState.update({ tab: "report" }, true); + } + + async function closeReport(): Promise { + report.closeReportPreview(); + await syncRoute(false, null); + if (viewState.tab.value === "report") + await viewState.update({ tab: "document" }, true); + } + + async function launchWorkbench(): Promise { + if (!selectedTask.value || launchDisabled.value) return; + const result = await launch.launchTask( + selectedTask.value, + selectedTaskBody.value, + selectedTaskLinks.value, + selectedFileName.value, + source.navigation.value, + providerProfile.value, + ); + if (result) { + await taskData.loadLinks(selectedTask.value.taskRef); + await launch.navigateToWorkbench(result); + } + } + + function openSourceConfig(): void { + source.openSourceForm(selectedSource.value); + showSourceConfig.value = true; + } + async function saveSource(): Promise { + const sourceId = await source.saveSourceConfig(); + if (sourceId) { + showSourceConfig.value = false; + await selectSource(sourceId); + } + } + function setProvider(value: string): void { + providerProfile.value = value; + const query = { ...route.query }; + value ? (query.provider = value) : delete query.provider; + void router.replace({ query }); + } + async function loadProviders(): Promise { + providerReady.value = false; + const response = await providerProfilesAPI.catalog(); + providerReady.value = true; + if (!response.ok) { + providerError.value = "模型通道目录不可用"; + return; + } + providerOptions.value = providerCatalogOptions(response.data); + const requested = + typeof route.query.provider === "string" ? route.query.provider : ""; + const requestedAvailable = providerOptions.value.some( + (item) => item.value === requested && item.configured !== false, + ); + setProvider( + requestedAvailable + ? requested + : providerOptions.value.find((item) => item.configured !== false) + ?.value || "", + ); + } + + return { + taskFingerprint, + rejectMutation, + resetEditor, + saveBasics, + saveBody, + createTask, + deleteTask, + openReport, + closeReport, + launchWorkbench, + openSourceConfig, + saveSource, + setProvider, + loadProviders, + }; +} + +function providerCatalogOptions( + payload: ProviderProfileCatalogResponse | null, +) { + return (payload?.items ?? []).map((item) => ({ + value: item.profile, + label: item.profile, + configured: item.configured, + })); +} diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceController.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceController.ts index a5d43d66..14774522 100644 --- a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceController.ts +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceController.ts @@ -1,17 +1,5 @@ -import { computed, onMounted, onUnmounted, ref, watch } from "vue"; +import { onMounted, onUnmounted, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; -import { - providerProfilesAPI, - type MdtodoTaskLinkRecord, - type MdtodoTaskMutationInput, - type MdtodoTaskRecord, -} from "@/api"; -import type { - ApiError, - ErrorDiagnostic, - ProviderProfileCatalogResponse, -} from "@/types"; -import { fileDisplayName } from "@/composables/mdtodo/useMdtodoDisplay"; import { useMdtodoReportPreview } from "@/composables/mdtodo/useMdtodoReportPreview"; import { useMdtodoRouteSelection } from "@/composables/mdtodo/useMdtodoRouteSelection"; import { useMdtodoSource } from "@/composables/mdtodo/useMdtodoSource"; @@ -19,8 +7,11 @@ import { useMdtodoTaskData } from "@/composables/mdtodo/useMdtodoTaskData"; import { useMdtodoTaskMutation } from "@/composables/mdtodo/useMdtodoTaskMutation"; import { useMdtodoViewState } from "@/composables/mdtodo/useMdtodoViewState"; import { useMdtodoWorkbenchLaunch } from "@/composables/mdtodo/useMdtodoWorkbenchLaunch"; +import { useMdtodoWorkspaceActions } from "@/composables/mdtodo/useMdtodoWorkspaceActions"; +import { useMdtodoWorkspaceNavigation } from "@/composables/mdtodo/useMdtodoWorkspaceNavigation"; +import { useMdtodoWorkspaceState } from "@/composables/mdtodo/useMdtodoWorkspaceState"; -export type MdtodoRefreshMode = "blocking" | "persistent"; +export { type MdtodoRefreshMode } from "@/composables/mdtodo/useMdtodoWorkspaceState"; export function useMdtodoWorkspaceController() { const router = useRouter(); @@ -32,741 +23,33 @@ export function useMdtodoWorkspaceController() { const report = useMdtodoReportPreview(); const launch = useMdtodoWorkbenchLaunch(router); const viewState = useMdtodoViewState(router, route); - - const loading = ref(false); - const error = ref(null); - const apiError = ref(null); - const diagnostic = ref(null); - const selectedSourceId = ref(null); - const selectedFileRef = ref(null); - const selectedTaskRef = ref(null); - const collapsedTaskRefs = ref(new Set()); - const taskPaneWidth = ref(25); - const queuePaneWidth = ref(40); - const taskPaneCollapsed = ref(false); - const reportPaneWidth = ref(48); - const reportPaneCollapsed = ref(false); - const refreshMode = ref("blocking"); - const showInfo = ref(false); - const showSourceConfig = ref(false); - const showTaskCreate = ref(false); - const editingTitle = ref(false); - const editingBody = ref(false); - const editTitle = ref(""); - const editStatus = ref("open"); - const editBody = ref(""); - const newTaskTitle = ref(""); - const newTaskBody = ref(""); - const deleteConfirm = ref(false); - const providerProfile = ref(""); - const providerOptions = ref< - Array<{ value: string; label: string; configured?: boolean }> - >([]); - const providerReady = ref(false); - const providerError = ref(null); - let refreshSequence = 0; + const state = useMdtodoWorkspaceState({ source, taskData, report, launch, viewState }); + const navigation = useMdtodoWorkspaceNavigation({ router, route, selection, source, taskData, report, viewState, state }); + const actions = useMdtodoWorkspaceActions({ router, route, source, taskData, mutation, report, launch, viewState, state, navigation }); let queryTimer: ReturnType | null = null; - const selectedSource = computed( - () => - source.sources.value.find( - (item) => item.sourceId === selectedSourceId.value, - ) ?? null, - ); - const selectedFile = computed( - () => - taskData.files.value.find( - (item) => item.fileRef === selectedFileRef.value, - ) ?? null, - ); - const selectedTask = computed( - () => - taskData.tasks.value.find( - (item) => item.taskRef === selectedTaskRef.value, - ) ?? null, - ); - const selectedTaskProjection = computed(() => - selectedTaskRef.value - ? (taskData.taskByRef.value.get(selectedTaskRef.value) ?? null) - : null, - ); - const fileTasks = computed(() => - selectedFileRef.value - ? taskData.tasks.value.filter( - (item) => item.fileRef === selectedFileRef.value, - ) - : taskData.tasks.value, - ); - const currentTaskDetail = computed(() => - taskData.taskDetail.value?.taskRef === selectedTaskRef.value - ? taskData.taskDetail.value - : null, - ); - const taskSearch = computed({ - get: () => viewState.search.value, - set: (value: string) => { - void viewState.update({ search: value }, true); - }, + watch(state.selectedTaskRef, (taskRef) => { + void Promise.all([taskData.loadLinks(taskRef), taskData.loadTaskDetail(taskRef)]); }); - const taskStatus = computed({ - get: () => viewState.status.value, - set: (value: string) => { - void viewState.update({ status: value }, true); - }, - }); - const filteredTasks = computed(() => { - const query = taskSearch.value.trim().toLowerCase(); - return fileTasks.value.filter((task) => { - const statusMatches = - taskStatus.value === "all" || - (task.status || "unknown") === taskStatus.value; - const textMatches = - !query || - [task.taskId, task.title, task.taskRef].some((value) => - String(value || "") - .toLowerCase() - .includes(query), - ); - return statusMatches && textMatches; - }); - }); - const taskDetailPartial = computed( - () => taskData.taskDetailAuthority.value?.state === "unavailable", - ); - const mutationDisabled = computed(() => - Boolean( - selectedTask.value && - (taskData.taskDetailLoading.value || - !currentTaskDetail.value || - taskDetailPartial.value), - ), - ); - const createDisabledReason = computed(() => { - if (!selectedSourceId.value) return "请先选择 Source"; - if (!selectedFileRef.value) return "请先选择 MDTODO 文件"; - if (!selectedFile.value?.fingerprint) - return "文件 fingerprint 不可用,重建索引后再创建任务"; - if (taskDetailPartial.value) - return "当前详情来自离线投影,Node 恢复后才能创建任务"; - if ( - selectedTask.value && - (taskData.taskDetailLoading.value || !currentTaskDetail.value) - ) - return "任务详情 authority 尚未确认,请稍候"; - return null; - }); - const createDisabled = computed(() => Boolean(createDisabledReason.value)); - const selectedTaskBody = computed( - () => - currentTaskDetail.value?.body ?? - selectedTask.value?.bodyPreview ?? - selectedTaskProjection.value?.bodyPreview ?? - "", - ); - const selectedTaskLinks = computed(() => { - const detailLinks = markdownReportLinks( - currentTaskDetail.value?.links ?? [], - ); - if (detailLinks.length) return detailLinks; - const projectedLinks = markdownReportLinks( - selectedTask.value?.links ?? selectedTaskProjection.value?.links ?? [], - ); - if (projectedLinks.length) { - return taskDetailPartial.value - ? projectedLinks.map(unavailableReportLink) - : projectedLinks; - } - const linkCount = Number( - selectedTask.value?.linkCount ?? - selectedTaskProjection.value?.linkCount ?? - 0, - ); - return selectedTask.value && linkCount > 0 - ? [projectedReportLink(selectedTask.value, linkCount)] - : []; - }); - const selectedFileName = computed(() => - selectedFile.value - ? fileDisplayName(selectedFile.value) - : selectedTask.value?.fileRef || "-", - ); - const selectedTaskBodyHtml = computed(() => - report.markdownToHtml(selectedTaskBody.value), - ); - const reportPreviewHtml = computed(() => - report.markdownToHtml(report.reportPreview.value?.content ?? ""), - ); - const reportOpen = computed(() => Boolean(report.reportPreview.value)); - const taskStatusCounts = computed(() => - fileTasks.value.reduce>((counts, task) => { - const key = task.status || "unknown"; - counts[key] = (counts[key] ?? 0) + 1; - return counts; - }, {}), - ); - const providerBlocker = computed(() => { - if (!providerReady.value) return "模型通道校验中"; - if (providerError.value) return providerError.value; - if (!providerProfile.value) return "模型通道未选择"; - const option = providerOptions.value.find( - (item) => item.value === providerProfile.value, - ); - return option?.configured === false - ? `模型通道 ${providerProfile.value} 未配置` - : null; - }); - const launchBlocker = computed(() => - launch.isLaunchEnabled(source.navigation.value) - ? providerBlocker.value - : "Workbench Launch capability unavailable", - ); - const launchDisabled = computed( - () => - !selectedTask.value || - Boolean(launchBlocker.value) || - launch.launchLoading.value, - ); - - function currentQuery() { - return { - search: taskSearch.value.trim() || undefined, - status: taskStatus.value === "all" ? undefined : taskStatus.value, - }; - } - - function setError(value: unknown): void { - const result = - value && typeof value === "object" && "error" in value - ? (value as { - error?: string | null; - apiError?: ApiError | null; - diagnostic?: ErrorDiagnostic | null; - status?: number; - }) - : null; - error.value = - result?.error || - (value instanceof Error - ? value.message - : String(value || "MDTODO 加载失败")); - apiError.value = result?.apiError ?? null; - diagnostic.value = result?.diagnostic ?? null; - } - - async function syncRoute( - replace = false, - reportLinkId = report.activeReportLink.value?.linkId ?? null, - ): Promise { - await selection.syncSelectionRoute({ - sourceId: selectedSourceId.value, - fileRef: selectedFileRef.value, - taskId: selectedTask.value?.taskId ?? null, - reportLinkId, - replace, - }); - } - - async function loadWindow( - sourceId: string, - options: { - fileRef?: string | null; - taskId?: string | null; - reportLinkId?: string | null; - updateRoute?: boolean; - mode?: MdtodoRefreshMode; - } = {}, - ): Promise { - const sequence = ++refreshSequence; - refreshMode.value = options.mode ?? "blocking"; - taskData.taskLoading.value = true; - try { - await taskData.loadFiles(sourceId); - if (sequence !== refreshSequence) return false; - const requestedFileExists = - options.fileRef && - taskData.files.value.some((item) => item.fileRef === options.fileRef); - const currentFileExists = taskData.files.value.some( - (item) => item.fileRef === selectedFileRef.value, - ); - selectedFileRef.value = requestedFileExists - ? (options.fileRef ?? null) - : currentFileExists - ? selectedFileRef.value - : (taskData.files.value[0]?.fileRef ?? null); - const requestedTask = - options.taskId !== undefined ? options.taskId : selectedTaskRef.value; - await taskData.loadTaskWindow( - sourceId, - selectedFileRef.value, - requestedTask, - currentQuery(), - ); - if (sequence !== refreshSequence) return false; - const preferred = taskData.resolvePreferredTask(requestedTask); - const unresolved = Boolean(requestedTask && !preferred); - if (!unresolved) - selectedTaskRef.value = - preferred?.taskRef ?? taskData.tasks.value[0]?.taskRef ?? null; - if (refreshMode.value === "blocking") collapsedTaskRefs.value = new Set(); - if (options.reportLinkId && selectedTaskRef.value) { - await Promise.all([ - taskData.loadLinks(selectedTaskRef.value), - taskData.loadTaskDetail(selectedTaskRef.value), - ]); - await report.openReportPreviewById( - selectedTaskRef.value, - selectedTaskLinks.value, - options.reportLinkId, - ); - } else if (options.updateRoute !== false && !unresolved) - await syncRoute(true); - error.value = null; - apiError.value = null; - diagnostic.value = null; - return true; - } catch (value) { - if (sequence === refreshSequence) setError(value); - return false; - } finally { - if (sequence === refreshSequence) taskData.taskLoading.value = false; - } - } - - async function loadPage(): Promise { - loading.value = true; - error.value = null; - try { - const result = await source.loadNavigationAndSources(); - const routeSourceId = selection.readSelection().sourceId; - selectedSourceId.value = - routeSourceId && - result.sources.some((item) => item.sourceId === routeSourceId) - ? routeSourceId - : (result.sources[0]?.sourceId ?? null); - if (selectedSourceId.value) { - await loadWindow(selectedSourceId.value, { - ...selection.routeSelectionOptions(true), - mode: "blocking", - }); - } - } catch (value) { - setError(value); - } finally { - loading.value = false; - } - } - - async function selectSource(sourceId: string): Promise { - const previous = { - sourceId: selectedSourceId.value, - fileRef: selectedFileRef.value, - taskRef: selectedTaskRef.value, - }; - const workspace = snapshotTaskWorkspace(); - selectedSourceId.value = sourceId || null; - if (selectedSourceId.value) { - const loaded = await loadWindow(selectedSourceId.value, { - updateRoute: false, - mode: "blocking", - }); - if (loaded) { - report.closeReportPreview(); - await syncRoute(false); - } else { - selectedSourceId.value = previous.sourceId; - selectedFileRef.value = previous.fileRef; - selectedTaskRef.value = previous.taskRef; - restoreTaskWorkspace(workspace); - } - } - } - - async function selectFile(fileRef: string): Promise { - const previous = { - fileRef: selectedFileRef.value, - taskRef: selectedTaskRef.value, - }; - const workspace = snapshotTaskWorkspace(); - if (selectedSourceId.value) { - const loaded = await loadWindow(selectedSourceId.value, { - fileRef: fileRef || null, - taskId: null, - updateRoute: false, - mode: "blocking", - }); - if (loaded) { - report.closeReportPreview(); - await syncRoute(false); - } else { - selectedFileRef.value = previous.fileRef; - selectedTaskRef.value = previous.taskRef; - restoreTaskWorkspace(workspace); - } - } - } - - async function selectTask(taskRef: string): Promise { - selectedTaskRef.value = taskRef; - report.closeReportPreview(); - await syncRoute(); - await viewState.update({ tab: "document" }, true); - } - - async function applyRoute(): Promise { - const next = selection.readSelection(); - const sourceId = - next.sourceId && - source.sources.value.some((item) => item.sourceId === next.sourceId) - ? next.sourceId - : selectedSourceId.value; - if (!sourceId) return; - const sourceChanged = sourceId !== selectedSourceId.value; - const fileChanged = Boolean( - next.fileRef && next.fileRef !== selectedFileRef.value, - ); - selectedSourceId.value = sourceId; - if (sourceChanged || fileChanged || !taskData.tasks.value.length) { - await loadWindow(sourceId, { - ...selection.routeSelectionOptions(false), - mode: "blocking", - }); - return; - } - const task = next.taskId - ? taskData.resolvePreferredTask(next.taskId) - : selectedTask.value; - if (next.taskId && !task) { - await loadWindow(sourceId, { - ...selection.routeSelectionOptions(false), - mode: "persistent", - }); - return; - } - selectedTaskRef.value = task?.taskRef ?? selectedTaskRef.value; - if (next.linkId && selectedTaskRef.value) { - await Promise.all([ - taskData.loadLinks(selectedTaskRef.value), - taskData.loadTaskDetail(selectedTaskRef.value), - ]); - await report.openReportPreviewById( - selectedTaskRef.value, - selectedTaskLinks.value, - next.linkId, - ); - } else report.closeReportPreview(); - } - - async function reloadWindow(): Promise { - if (!selectedSourceId.value) return; - await loadWindow(selectedSourceId.value, { - fileRef: selectedFileRef.value, - taskId: selectedTaskRef.value, - updateRoute: false, - mode: taskData.tasks.value.length ? "persistent" : "blocking", - }); - if (selectedTaskRef.value) - await Promise.all([ - taskData.loadTaskDetail(selectedTaskRef.value), - taskData.loadLinks(selectedTaskRef.value), - ]); - } - - function taskFingerprint(): string | undefined { - return ( - selectedTask.value?.sourceFingerprint || - selectedFile.value?.fingerprint || - undefined - ); - } - - function snapshotTaskWorkspace() { - return { - files: [...taskData.files.value], - tasks: [...taskData.tasks.value], - taskPage: taskData.taskPage.value, - taskDetail: taskData.taskDetail.value, - authority: taskData.taskDetailAuthority.value, - links: [...taskData.links.value], - }; - } - - function restoreTaskWorkspace( - snapshot: ReturnType, - ): void { - taskData.files.value = snapshot.files; - taskData.tasks.value = snapshot.tasks; - taskData.taskPage.value = snapshot.taskPage; - taskData.taskDetail.value = snapshot.taskDetail; - taskData.taskDetailAuthority.value = snapshot.authority; - taskData.links.value = snapshot.links; - } - - function rejectMutation(): boolean { - if (!mutationDisabled.value) return false; - mutation.taskMutationMessage.value = null; - mutation.taskMutationError.value = - "当前仅有最近一次任务投影;Node 恢复并读到源 Markdown 后才能写入。"; - return true; - } - - function resetEditor(task: MdtodoTaskRecord | null): void { - editingTitle.value = false; - editingBody.value = false; - editTitle.value = task?.title || ""; - editStatus.value = task?.status || "open"; - const detail = taskData.taskDetail.value; - editBody.value = - detail && detail.taskRef === task?.taskRef ? detail.body || "" : ""; - deleteConfirm.value = false; - } - - async function saveBasics(): Promise { - if (rejectMutation() || !selectedTask.value) return; - const input: MdtodoTaskMutationInput = { - expectedFingerprint: taskFingerprint(), - }; - if ( - editTitle.value.trim() && - editTitle.value.trim() !== selectedTask.value.title - ) - input.title = editTitle.value.trim(); - if (editStatus.value !== (selectedTask.value.status || "open")) - input.status = editStatus.value; - if (!input.title && !input.status) - mutation.taskMutationMessage.value = "任务标题和状态没有变化"; - else if ( - await mutation.updateTask( - selectedTask.value.taskRef, - input, - "任务已保存", - reloadWindow, - ) - ) - editingTitle.value = false; - } - - async function saveBody(): Promise { - if (rejectMutation() || !selectedTask.value) return; - if ( - await mutation.updateTask( - selectedTask.value.taskRef, - { expectedFingerprint: taskFingerprint(), body: editBody.value }, - "正文已保存", - reloadWindow, - ) - ) - editingBody.value = false; - } - - async function createTask( - kind: "root" | "subtask" | "continue", - ): Promise { - if (rejectMutation()) return; - const sourceId = selectedSourceId.value; - const fileRef = selectedFileRef.value; - if (!sourceId || !fileRef) { - mutation.taskMutationError.value = - createDisabledReason.value || "请先选择 MDTODO 文件"; - return; - } - const title = newTaskTitle.value.trim(); - if (!title) { - mutation.taskMutationError.value = "新任务标题不能为空"; - return; - } - const input: MdtodoTaskMutationInput = { - sourceId, - fileRef, - expectedFingerprint: taskFingerprint(), - title, - body: newTaskBody.value, - }; - if (kind === "subtask" && selectedTask.value) - input.parentTaskRef = selectedTask.value.taskRef; - if (kind === "continue" && selectedTask.value) - input.afterTaskRef = selectedTask.value.taskRef; - const nextTaskRef = await mutation.createTask( - input, - "任务已创建", - async () => {}, - ); - if (nextTaskRef) { - report.closeReportPreview(); - newTaskTitle.value = ""; - newTaskBody.value = ""; - showTaskCreate.value = false; - const loaded = await loadWindow(sourceId, { - fileRef, - taskId: nextTaskRef, - updateRoute: false, - mode: "persistent", - }); - if (!loaded) { - mutation.taskMutationMessage.value = - "任务已创建;任务索引刷新失败,请重试"; - return; - } - await syncRoute(false); - } - } - - async function deleteTask(): Promise { - if (rejectMutation() || !selectedTask.value) return; - if (!deleteConfirm.value) { - deleteConfirm.value = true; - mutation.taskMutationMessage.value = "再次点击确认删除当前任务及子任务"; - return; - } - const deletedTaskRef = selectedTask.value.taskRef; - const expectedFingerprint = taskFingerprint(); - const fallbackTaskRef = - taskData.tasks.value.find((task) => task.taskRef !== deletedTaskRef) - ?.taskRef ?? null; - const previousTaskRef = selectedTaskRef.value; - selectedTaskRef.value = null; - const reloadAfterDelete = async () => { - if (selectedSourceId.value) { - await loadWindow(selectedSourceId.value, { - fileRef: selectedFileRef.value, - taskId: null, - updateRoute: false, - mode: "persistent", - }); - } - }; - const result = await mutation.deleteTask( - deletedTaskRef, - { expectedFingerprint }, - reloadAfterDelete, - ); - if (!result) { - selectedTaskRef.value = previousTaskRef; - return; - } - report.closeReportPreview(); - deleteConfirm.value = false; - selectedTaskRef.value = - taskData.tasks.value.find((task) => task.taskRef !== deletedTaskRef) - ?.taskRef ?? fallbackTaskRef; - await syncRoute(false, null); - } - - async function openReport(link: MdtodoTaskLinkRecord): Promise { - if ( - !selectedTaskRef.value || - !(await report.openReportPreview(selectedTaskRef.value, link)) - ) - return; - reportPaneCollapsed.value = false; - await syncRoute(); - await viewState.update({ tab: "report" }, true); - } - - async function closeReport(): Promise { - report.closeReportPreview(); - await syncRoute(false, null); - if (viewState.tab.value === "report") - await viewState.update({ tab: "document" }, true); - } - - async function launchWorkbench(): Promise { - if (!selectedTask.value || launchDisabled.value) return; - const result = await launch.launchTask( - selectedTask.value, - selectedTaskBody.value, - selectedTaskLinks.value, - selectedFileName.value, - source.navigation.value, - providerProfile.value, - ); - if (result) { - await taskData.loadLinks(selectedTask.value.taskRef); - await launch.navigateToWorkbench(result); - } - } - - function openSourceConfig(): void { - source.openSourceForm(selectedSource.value); - showSourceConfig.value = true; - } - async function saveSource(): Promise { - const sourceId = await source.saveSourceConfig(); - if (sourceId) { - showSourceConfig.value = false; - await selectSource(sourceId); - } - } - async function reindex(): Promise { - if ( - selectedSourceId.value && - (await source.reindexSource(selectedSourceId.value)) - ) - await reloadWindow(); - } - async function loadMore(): Promise { - taskData.taskLoading.value = true; - try { - await taskData.loadMoreTaskWindow(); - } catch (value) { - setError(value); - } finally { - taskData.taskLoading.value = false; - } - } - function setProvider(value: string): void { - providerProfile.value = value; - const query = { ...route.query }; - value ? (query.provider = value) : delete query.provider; - void router.replace({ query }); - } - async function loadProviders(): Promise { - providerReady.value = false; - const response = await providerProfilesAPI.catalog(); - providerReady.value = true; - if (!response.ok) { - providerError.value = "模型通道目录不可用"; - return; - } - providerOptions.value = providerCatalogOptions(response.data); - const requested = - typeof route.query.provider === "string" ? route.query.provider : ""; - const requestedAvailable = providerOptions.value.some( - (item) => item.value === requested && item.configured !== false, - ); - setProvider( - requestedAvailable - ? requested - : providerOptions.value.find((item) => item.configured !== false) - ?.value || "", - ); - } - - watch(selectedTaskRef, (taskRef) => { - void Promise.all([ - taskData.loadLinks(taskRef), - taskData.loadTaskDetail(taskRef), - ]); - }); - watch(selectedTask, resetEditor, { immediate: true }); + watch(state.selectedTask, actions.resetEditor, { immediate: true }); watch(taskData.taskDetail, (detail) => { - if (detail?.taskRef === selectedTaskRef.value && !editingBody.value) - editBody.value = detail.body || ""; + if (detail?.taskRef === state.selectedTaskRef.value && !state.editingBody.value) + state.editBody.value = detail.body || ""; }); - watch([taskSearch, taskStatus], () => { + watch([state.taskSearch, state.taskStatus], () => { if (queryTimer) clearTimeout(queryTimer); - queryTimer = setTimeout(() => { - void reloadWindow(); - }, 250); + queryTimer = setTimeout(() => void navigation.reloadWindow(), 250); }); watch( () => route.fullPath, () => { - if (!loading.value && source.sources.value.length) void applyRoute(); + if (!state.loading.value && source.sources.value.length) + void navigation.applyRoute(); }, ); onMounted(() => { - void loadProviders(); - void loadPage(); + void actions.loadProviders(); + void navigation.loadPage(); }); onUnmounted(() => { if (queryTimer) clearTimeout(queryTimer); @@ -782,121 +65,8 @@ export function useMdtodoWorkspaceController() { report, launch, viewState, - loading, - error, - apiError, - diagnostic, - selectedSourceId, - selectedFileRef, - selectedTaskRef, - selectedSource, - selectedFile, - selectedTask, - collapsedTaskRefs, - taskPaneWidth, - queuePaneWidth, - taskPaneCollapsed, - reportPaneWidth, - reportPaneCollapsed, - refreshMode, - showInfo, - showSourceConfig, - showTaskCreate, - editingTitle, - editingBody, - editTitle, - editStatus, - editBody, - newTaskTitle, - newTaskBody, - deleteConfirm, - providerProfile, - providerOptions, - providerReady, - providerError, - fileTasks, - filteredTasks, - taskSearch, - taskStatus, - taskStatusCounts, - currentTaskDetail, - taskDetailPartial, - mutationDisabled, - createDisabled, - createDisabledReason, - selectedTaskBody, - selectedTaskLinks, - selectedFileName, - selectedTaskBodyHtml, - reportPreviewHtml, - reportOpen, - launchBlocker, - launchDisabled, - loadPage, - selectSource, - selectFile, - selectTask, - reloadWindow, - loadMore, - saveBasics, - saveBody, - createTask, - deleteTask, - openReport, - closeReport, - launchWorkbench, - openSourceConfig, - saveSource, - reindex, - setProvider, - resetEditor, - }; -} - -function providerCatalogOptions( - payload: ProviderProfileCatalogResponse | null, -) { - return (payload?.items ?? []).map((item) => ({ - value: item.profile, - label: item.profile, - configured: item.configured, - })); -} - -function markdownReportLinks( - links: MdtodoTaskLinkRecord[], -): MdtodoTaskLinkRecord[] { - return links.filter((link) => link.kind === "markdown-report"); -} - -function projectedReportLink( - task: MdtodoTaskRecord, - linkCount: number, -): MdtodoTaskLinkRecord { - return { - linkId: `projected-report-unavailable-${task.taskRef}`, - label: - linkCount === 1 - ? `${task.taskId || task.rxxId} 报告` - : `${task.taskId || task.rxxId} 报告 (${linkCount})`, - href: "", - kind: "markdown-report", - relativePath: null, - ordinal: 1, - availability: "projected-link-unavailable", - message: - "任务投影确认存在报告链接;恢复 HWPOD 详情读取或重建索引后可打开。", - valuesRedacted: true, - }; -} - -function unavailableReportLink( - link: MdtodoTaskLinkRecord, -): MdtodoTaskLinkRecord { - return { - ...link, - availability: "projected-link-unavailable", - message: "报告索引可用,但 HWPOD 源当前不可读。", - valuesRedacted: true, + ...state, + ...navigation, + ...actions, }; } diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceNavigation.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceNavigation.ts new file mode 100644 index 00000000..15a88c65 --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceNavigation.ts @@ -0,0 +1,329 @@ +import type { Router, RouteLocationNormalizedLoaded } from "vue-router"; +import type { ApiError, ErrorDiagnostic } from "@/types"; +import type { useMdtodoReportPreview } from "@/composables/mdtodo/useMdtodoReportPreview"; +import type { useMdtodoRouteSelection } from "@/composables/mdtodo/useMdtodoRouteSelection"; +import type { useMdtodoSource } from "@/composables/mdtodo/useMdtodoSource"; +import type { useMdtodoTaskData } from "@/composables/mdtodo/useMdtodoTaskData"; +import type { useMdtodoViewState } from "@/composables/mdtodo/useMdtodoViewState"; +import type { MdtodoRefreshMode, MdtodoWorkspaceState } from "@/composables/mdtodo/useMdtodoWorkspaceState"; + +export function useMdtodoWorkspaceNavigation(dependencies: { + router: Router; + route: RouteLocationNormalizedLoaded; + selection: ReturnType; + source: ReturnType; + taskData: ReturnType; + report: ReturnType; + viewState: ReturnType; + state: MdtodoWorkspaceState; +}) { + const { router, route, selection, source, taskData, report, viewState, state } = dependencies; + const { + loading, error, apiError, diagnostic, selectedSourceId, selectedFileRef, + selectedTaskRef, selectedTask, selectedTaskLinks, collapsedTaskRefs, + refreshMode, taskSearch, taskStatus, + } = state; + let refreshSequence = 0; + function currentQuery() { + return { + search: taskSearch.value.trim() || undefined, + status: taskStatus.value === "all" ? undefined : taskStatus.value, + }; + } + + function setError(value: unknown): void { + const result = + value && typeof value === "object" && "error" in value + ? (value as { + error?: string | null; + apiError?: ApiError | null; + diagnostic?: ErrorDiagnostic | null; + status?: number; + }) + : null; + error.value = + result?.error || + (value instanceof Error + ? value.message + : String(value || "MDTODO 加载失败")); + apiError.value = result?.apiError ?? null; + diagnostic.value = result?.diagnostic ?? null; + } + + async function syncRoute( + replace = false, + reportLinkId = report.activeReportLink.value?.linkId ?? null, + ): Promise { + await selection.syncSelectionRoute({ + sourceId: selectedSourceId.value, + fileRef: selectedFileRef.value, + taskId: selectedTask.value?.taskId ?? null, + reportLinkId, + replace, + }); + } + + async function loadWindow( + sourceId: string, + options: { + fileRef?: string | null; + taskId?: string | null; + reportLinkId?: string | null; + updateRoute?: boolean; + mode?: MdtodoRefreshMode; + } = {}, + ): Promise { + const sequence = ++refreshSequence; + refreshMode.value = options.mode ?? "blocking"; + taskData.taskLoading.value = true; + try { + await taskData.loadFiles(sourceId); + if (sequence !== refreshSequence) return false; + const requestedFileExists = + options.fileRef && + taskData.files.value.some((item) => item.fileRef === options.fileRef); + const currentFileExists = taskData.files.value.some( + (item) => item.fileRef === selectedFileRef.value, + ); + selectedFileRef.value = requestedFileExists + ? (options.fileRef ?? null) + : currentFileExists + ? selectedFileRef.value + : (taskData.files.value[0]?.fileRef ?? null); + const requestedTask = + options.taskId !== undefined ? options.taskId : selectedTaskRef.value; + await taskData.loadTaskWindow( + sourceId, + selectedFileRef.value, + requestedTask, + currentQuery(), + ); + if (sequence !== refreshSequence) return false; + const preferred = taskData.resolvePreferredTask(requestedTask); + const unresolved = Boolean(requestedTask && !preferred); + if (!unresolved) + selectedTaskRef.value = + preferred?.taskRef ?? taskData.tasks.value[0]?.taskRef ?? null; + if (refreshMode.value === "blocking") collapsedTaskRefs.value = new Set(); + if (options.reportLinkId && selectedTaskRef.value) { + await Promise.all([ + taskData.loadLinks(selectedTaskRef.value), + taskData.loadTaskDetail(selectedTaskRef.value), + ]); + await report.openReportPreviewById( + selectedTaskRef.value, + selectedTaskLinks.value, + options.reportLinkId, + ); + } else if (options.updateRoute !== false && !unresolved) + await syncRoute(true); + error.value = null; + apiError.value = null; + diagnostic.value = null; + return true; + } catch (value) { + if (sequence === refreshSequence) setError(value); + return false; + } finally { + if (sequence === refreshSequence) taskData.taskLoading.value = false; + } + } + + async function loadPage(): Promise { + loading.value = true; + error.value = null; + try { + const result = await source.loadNavigationAndSources(); + const routeSourceId = selection.readSelection().sourceId; + selectedSourceId.value = + routeSourceId && + result.sources.some((item) => item.sourceId === routeSourceId) + ? routeSourceId + : (result.sources[0]?.sourceId ?? null); + if (selectedSourceId.value) { + await loadWindow(selectedSourceId.value, { + ...selection.routeSelectionOptions(true), + mode: "blocking", + }); + } + } catch (value) { + setError(value); + } finally { + loading.value = false; + } + } + + function snapshotTaskWorkspace() { + return { + files: [...taskData.files.value], + tasks: [...taskData.tasks.value], + taskPage: taskData.taskPage.value, + taskDetail: taskData.taskDetail.value, + authority: taskData.taskDetailAuthority.value, + links: [...taskData.links.value], + }; + } + + function restoreTaskWorkspace( + snapshot: ReturnType, + ): void { + taskData.files.value = snapshot.files; + taskData.tasks.value = snapshot.tasks; + taskData.taskPage.value = snapshot.taskPage; + taskData.taskDetail.value = snapshot.taskDetail; + taskData.taskDetailAuthority.value = snapshot.authority; + taskData.links.value = snapshot.links; + } + + async function selectSource(sourceId: string): Promise { + const previous = { + sourceId: selectedSourceId.value, + fileRef: selectedFileRef.value, + taskRef: selectedTaskRef.value, + }; + const workspace = snapshotTaskWorkspace(); + selectedSourceId.value = sourceId || null; + if (selectedSourceId.value) { + const loaded = await loadWindow(selectedSourceId.value, { + updateRoute: false, + mode: "blocking", + }); + if (loaded) { + report.closeReportPreview(); + await syncRoute(false); + } else { + selectedSourceId.value = previous.sourceId; + selectedFileRef.value = previous.fileRef; + selectedTaskRef.value = previous.taskRef; + restoreTaskWorkspace(workspace); + } + } + } + + async function selectFile(fileRef: string): Promise { + const previous = { + fileRef: selectedFileRef.value, + taskRef: selectedTaskRef.value, + }; + const workspace = snapshotTaskWorkspace(); + if (selectedSourceId.value) { + const loaded = await loadWindow(selectedSourceId.value, { + fileRef: fileRef || null, + taskId: null, + updateRoute: false, + mode: "blocking", + }); + if (loaded) { + report.closeReportPreview(); + await syncRoute(false); + } else { + selectedFileRef.value = previous.fileRef; + selectedTaskRef.value = previous.taskRef; + restoreTaskWorkspace(workspace); + } + } + } + + async function selectTask(taskRef: string): Promise { + selectedTaskRef.value = taskRef; + report.closeReportPreview(); + await syncRoute(); + await viewState.update({ tab: "document" }, true); + } + + async function applyRoute(): Promise { + const next = selection.readSelection(); + const sourceId = + next.sourceId && + source.sources.value.some((item) => item.sourceId === next.sourceId) + ? next.sourceId + : selectedSourceId.value; + if (!sourceId) return; + const sourceChanged = sourceId !== selectedSourceId.value; + const fileChanged = Boolean( + next.fileRef && next.fileRef !== selectedFileRef.value, + ); + selectedSourceId.value = sourceId; + if (sourceChanged || fileChanged || !taskData.tasks.value.length) { + await loadWindow(sourceId, { + ...selection.routeSelectionOptions(false), + mode: "blocking", + }); + return; + } + const task = next.taskId + ? taskData.resolvePreferredTask(next.taskId) + : selectedTask.value; + if (next.taskId && !task) { + await loadWindow(sourceId, { + ...selection.routeSelectionOptions(false), + mode: "persistent", + }); + return; + } + selectedTaskRef.value = task?.taskRef ?? selectedTaskRef.value; + if (next.linkId && selectedTaskRef.value) { + await Promise.all([ + taskData.loadLinks(selectedTaskRef.value), + taskData.loadTaskDetail(selectedTaskRef.value), + ]); + await report.openReportPreviewById( + selectedTaskRef.value, + selectedTaskLinks.value, + next.linkId, + ); + } else report.closeReportPreview(); + } + + async function reloadWindow(): Promise { + if (!selectedSourceId.value) return; + await loadWindow(selectedSourceId.value, { + fileRef: selectedFileRef.value, + taskId: selectedTaskRef.value, + updateRoute: false, + mode: taskData.tasks.value.length ? "persistent" : "blocking", + }); + if (selectedTaskRef.value) + await Promise.all([ + taskData.loadTaskDetail(selectedTaskRef.value), + taskData.loadLinks(selectedTaskRef.value), + ]); + } + + async function reindex(): Promise { + if ( + selectedSourceId.value && + (await source.reindexSource(selectedSourceId.value)) + ) + await reloadWindow(); + } + async function loadMore(): Promise { + taskData.taskLoading.value = true; + try { + await taskData.loadMoreTaskWindow(); + } catch (value) { + setError(value); + } finally { + taskData.taskLoading.value = false; + } + } + + return { + currentQuery, + setError, + syncRoute, + loadWindow, + loadPage, + snapshotTaskWorkspace, + restoreTaskWorkspace, + selectSource, + selectFile, + selectTask, + applyRoute, + reloadWindow, + reindex, + loadMore, + }; +} + +export type MdtodoWorkspaceNavigation = ReturnType; diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceState.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceState.ts new file mode 100644 index 00000000..fe34040a --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkspaceState.ts @@ -0,0 +1,305 @@ +import { computed, ref } from "vue"; +import type { MdtodoTaskLinkRecord, MdtodoTaskRecord, MdtodoTaskStatus } from "@/api"; +import type { ApiError, ErrorDiagnostic } from "@/types"; +import { fileDisplayName } from "@/composables/mdtodo/useMdtodoDisplay"; +import type { useMdtodoReportPreview } from "@/composables/mdtodo/useMdtodoReportPreview"; +import type { useMdtodoSource } from "@/composables/mdtodo/useMdtodoSource"; +import type { useMdtodoTaskData } from "@/composables/mdtodo/useMdtodoTaskData"; +import type { useMdtodoViewState } from "@/composables/mdtodo/useMdtodoViewState"; +import type { useMdtodoWorkbenchLaunch } from "@/composables/mdtodo/useMdtodoWorkbenchLaunch"; + +export type MdtodoRefreshMode = "blocking" | "persistent"; + +export function useMdtodoWorkspaceState(dependencies: { + source: ReturnType; + taskData: ReturnType; + report: ReturnType; + launch: ReturnType; + viewState: ReturnType; +}) { + const { source, taskData, report, launch, viewState } = dependencies; + const loading = ref(false); + const error = ref(null); + const apiError = ref(null); + const diagnostic = ref(null); + const selectedSourceId = ref(null); + const selectedFileRef = ref(null); + const selectedTaskRef = ref(null); + const collapsedTaskRefs = ref(new Set()); + const taskPaneWidth = ref(25); + const queuePaneWidth = ref(40); + const taskPaneCollapsed = ref(false); + const reportPaneWidth = ref(48); + const reportPaneCollapsed = ref(false); + const refreshMode = ref("blocking"); + const showInfo = ref(false); + const showSourceConfig = ref(false); + const showTaskCreate = ref(false); + const editingTitle = ref(false); + const editingBody = ref(false); + const editTitle = ref(""); + const editStatus = ref("open"); + const editBody = ref(""); + const newTaskTitle = ref(""); + const newTaskBody = ref(""); + const deleteConfirm = ref(false); + const providerProfile = ref(""); + const providerOptions = ref< + Array<{ value: string; label: string; configured?: boolean }> + >([]); + const providerReady = ref(false); + const providerError = ref(null); + const selectedSource = computed( + () => + source.sources.value.find( + (item) => item.sourceId === selectedSourceId.value, + ) ?? null, + ); + const selectedFile = computed( + () => + taskData.files.value.find( + (item) => item.fileRef === selectedFileRef.value, + ) ?? null, + ); + const selectedTask = computed( + () => + taskData.tasks.value.find( + (item) => item.taskRef === selectedTaskRef.value, + ) ?? null, + ); + const selectedTaskProjection = computed(() => + selectedTaskRef.value + ? (taskData.taskByRef.value.get(selectedTaskRef.value) ?? null) + : null, + ); + const fileTasks = computed(() => + selectedFileRef.value + ? taskData.tasks.value.filter( + (item) => item.fileRef === selectedFileRef.value, + ) + : taskData.tasks.value, + ); + const currentTaskDetail = computed(() => + taskData.taskDetail.value?.taskRef === selectedTaskRef.value + ? taskData.taskDetail.value + : null, + ); + const taskSearch = computed({ + get: () => viewState.search.value, + set: (value: string) => { + void viewState.update({ search: value }, true); + }, + }); + const taskStatus = computed({ + get: () => viewState.status.value, + set: (value: typeof viewState.status.value) => { + void viewState.update({ status: value }, true); + }, + }); + const filteredTasks = computed(() => { + const query = taskSearch.value.trim().toLowerCase(); + return fileTasks.value.filter((task) => { + const statusMatches = + taskStatus.value === "all" || + (task.status || "unknown") === taskStatus.value; + const textMatches = + !query || + [task.taskId, task.title, task.taskRef].some((value) => + String(value || "") + .toLowerCase() + .includes(query), + ); + return statusMatches && textMatches; + }); + }); + const taskDetailPartial = computed( + () => taskData.taskDetailAuthority.value?.state === "unavailable", + ); + const mutationDisabled = computed(() => + Boolean( + selectedTask.value && + (taskData.taskDetailLoading.value || + !currentTaskDetail.value || + taskDetailPartial.value), + ), + ); + const createDisabledReason = computed(() => { + if (!selectedSourceId.value) return "请先选择 Source"; + if (!selectedFileRef.value) return "请先选择 MDTODO 文件"; + if (!selectedFile.value?.fingerprint) + return "文件 fingerprint 不可用,重建索引后再创建任务"; + if (taskDetailPartial.value) + return "当前详情来自离线投影,Node 恢复后才能创建任务"; + if ( + selectedTask.value && + (taskData.taskDetailLoading.value || !currentTaskDetail.value) + ) + return "任务详情 authority 尚未确认,请稍候"; + return null; + }); + const createDisabled = computed(() => Boolean(createDisabledReason.value)); + const selectedTaskBody = computed( + () => + currentTaskDetail.value?.body ?? + selectedTask.value?.bodyPreview ?? + selectedTaskProjection.value?.bodyPreview ?? + "", + ); + const selectedTaskLinks = computed(() => { + const detailLinks = markdownReportLinks( + currentTaskDetail.value?.links ?? [], + ); + if (detailLinks.length) return detailLinks; + const projectedLinks = markdownReportLinks( + selectedTask.value?.links ?? selectedTaskProjection.value?.links ?? [], + ); + if (projectedLinks.length) { + return taskDetailPartial.value + ? projectedLinks.map(unavailableReportLink) + : projectedLinks; + } + const linkCount = Number( + selectedTask.value?.linkCount ?? + selectedTaskProjection.value?.linkCount ?? + 0, + ); + return selectedTask.value && linkCount > 0 + ? [projectedReportLink(selectedTask.value, linkCount)] + : []; + }); + const selectedFileName = computed(() => + selectedFile.value + ? fileDisplayName(selectedFile.value) + : selectedTask.value?.fileRef || "-", + ); + const selectedTaskBodyHtml = computed(() => + report.markdownToHtml(selectedTaskBody.value), + ); + const reportPreviewHtml = computed(() => + report.markdownToHtml(report.reportPreview.value?.content ?? ""), + ); + const reportOpen = computed(() => Boolean(report.reportPreview.value)); + const taskStatusCounts = computed(() => + fileTasks.value.reduce>((counts, task) => { + const key = task.status || "unknown"; + counts[key] = (counts[key] ?? 0) + 1; + return counts; + }, {}), + ); + const providerBlocker = computed(() => { + if (!providerReady.value) return "模型通道校验中"; + if (providerError.value) return providerError.value; + if (!providerProfile.value) return "模型通道未选择"; + const option = providerOptions.value.find( + (item) => item.value === providerProfile.value, + ); + return option?.configured === false + ? `模型通道 ${providerProfile.value} 未配置` + : null; + }); + const launchBlocker = computed(() => + launch.isLaunchEnabled(source.navigation.value) + ? providerBlocker.value + : "Workbench Launch capability unavailable", + ); + const launchDisabled = computed( + () => + !selectedTask.value || + Boolean(launchBlocker.value) || + launch.launchLoading.value, + ); + + return { + loading, + error, + apiError, + diagnostic, + selectedSourceId, + selectedFileRef, + selectedTaskRef, + collapsedTaskRefs, + taskPaneWidth, + queuePaneWidth, + taskPaneCollapsed, + reportPaneWidth, + reportPaneCollapsed, + refreshMode, + showInfo, + showSourceConfig, + showTaskCreate, + editingTitle, + editingBody, + editTitle, + editStatus, + editBody, + newTaskTitle, + newTaskBody, + deleteConfirm, + providerProfile, + providerOptions, + providerReady, + providerError, + selectedSource, + selectedFile, + selectedTask, + selectedTaskProjection, + fileTasks, + currentTaskDetail, + taskSearch, + taskStatus, + filteredTasks, + taskDetailPartial, + mutationDisabled, + createDisabledReason, + createDisabled, + selectedTaskBody, + selectedTaskLinks, + selectedFileName, + selectedTaskBodyHtml, + reportPreviewHtml, + reportOpen, + taskStatusCounts, + launchBlocker, + launchDisabled, + }; +} + +export type MdtodoWorkspaceState = ReturnType; + +function markdownReportLinks( + links: MdtodoTaskLinkRecord[], +): MdtodoTaskLinkRecord[] { + return links.filter((link) => link.kind === "markdown-report"); +} + +function projectedReportLink( + task: MdtodoTaskRecord, + linkCount: number, +): MdtodoTaskLinkRecord { + return { + linkId: `projected-report-unavailable-${task.taskRef}`, + label: + linkCount === 1 + ? `${task.taskId || task.rxxId} 报告` + : `${task.taskId || task.rxxId} 报告 (${linkCount})`, + href: "", + kind: "markdown-report", + relativePath: null, + ordinal: 1, + availability: "projected-link-unavailable", + message: + "任务投影确认存在报告链接;恢复 HWPOD 详情读取或重建索引后可打开。", + valuesRedacted: true, + }; +} + +function unavailableReportLink( + link: MdtodoTaskLinkRecord, +): MdtodoTaskLinkRecord { + return { + ...link, + availability: "projected-link-unavailable", + message: "报告索引可用,但 HWPOD 源当前不可读。", + valuesRedacted: true, + }; +} diff --git a/web/hwlab-cloud-web/src/views/admin/BillingView.css b/web/hwlab-cloud-web/src/views/admin/BillingView.css new file mode 100644 index 00000000..5fcc9f26 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/admin/BillingView.css @@ -0,0 +1,114 @@ +.billing-operations { + min-height: 620px; +} +.billing-main-stack { + display: grid; + min-width: 0; + gap: 14px; +} +.operations-tabs { + display: flex; + gap: 3px; + border-bottom: 1px solid #d8e1e4; +} +.operations-tabs button { + border: 0; + border-bottom: 2px solid transparent; + padding: 9px 12px; + background: transparent; +} +.operations-tabs button[aria-selected="true"] { + border-color: #08756f; + font-weight: 800; +} +.billing-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1px; + min-width: 0; + background: #d8e1e4; +} +.billing-summary-grid button { + display: grid; + min-width: 0; + gap: 7px; + border: 0; + padding: 12px; + background: white; + color: inherit; + text-align: left; +} +.billing-summary-grid dl { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: 0; +} +.billing-summary-grid dt { + color: #64757d; + font-size: 9px; + text-transform: uppercase; +} +.billing-summary-grid dd { + margin: 3px 0 0; + font-size: 11px; +} +.billing-filters { + display: flex; + min-width: 0; + gap: 8px; + margin-bottom: 12px; +} +.billing-filters .input { + min-width: 0; +} +.created-redeem-code { + display: grid; + gap: 6px; + min-width: 0; + border: 1px solid #d5b66a; + border-radius: 7px; + background: #fff9df; + padding: 10px; +} +.created-redeem-code code { + overflow-wrap: anywhere; + font-size: 12px; +} +.created-redeem-code small { + color: #755a16; +} +.inspector-facts { + display: grid; + gap: 1px; + margin: 0; + background: #d8e1e4; +} +.inspector-facts div { + padding: 10px; + background: white; +} +.inspector-facts dt { + color: #64757d; + font-size: 10px; +} +.inspector-facts dd { + margin: 4px 0 0; +} +@media (max-width: 390px) { + .billing-operations { + min-height: 0; + } + .billing-summary-grid { + grid-template-columns: minmax(0, 1fr); + } + .billing-summary-grid dl { + grid-template-columns: minmax(0, 1fr); + gap: 5px; + } + .billing-filters { + flex-direction: column; + } + .operations-tabs { + overflow-x: auto; + } +} diff --git a/web/hwlab-cloud-web/src/views/admin/BillingView.vue b/web/hwlab-cloud-web/src/views/admin/BillingView.vue index 6b4b2d2e..b9c12579 100644 --- a/web/hwlab-cloud-web/src/views/admin/BillingView.vue +++ b/web/hwlab-cloud-web/src/views/admin/BillingView.vue @@ -2,13 +2,9 @@ { formatDate(item.createdAt) } · { item.status } · + { item.id }{ item.ledgerId || "no ledger" }{ formatDate(row.createdAt) + }{ row.id }{ row.userId }{ row.ledgerId || "-" }

- 立即保存完整兑换码{{ createdCode }}立即保存完整兑换码{ createdCode }仅此响应可见;关闭后无法再次读取完整值。

@@ -505,119 +299,4 @@ function formatDate(value: unknown): string { - + diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.css b/web/hwlab-cloud-web/src/views/admin/UsersView.css new file mode 100644 index 00000000..50b1a3a5 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.css @@ -0,0 +1,131 @@ +.user-operations-page, +.user-main-stack { + min-width: 0; +} +.user-operations { + min-height: 640px; +} +.user-index-filters, +.user-main-stack, +.form-grid, +.inspector-fact-list { + display: grid; + gap: 8px; +} +.user-entity-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 8px; + padding: 10px; +} +.user-entity-card { + display: grid; + gap: 7px; + min-width: 0; + border: 1px solid #d2dce1; + border-radius: 8px; + padding: 8px; + background: #fff; +} +.user-entity-card[data-selected="true"] { + border-color: #258d89; + box-shadow: inset 3px 0 #168d88; +} +.user-entity-select { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px 8px; + min-width: 0; + border: 0; + background: transparent; + padding: 2px; + color: inherit; + text-align: left; + cursor: pointer; +} +.user-entity-select small, +.user-entity-select dl { + grid-column: 1 / -1; +} +.user-entity-select dl { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 5px; + margin: 4px 0 0; +} +.user-entity-select dl div { + min-width: 0; + border: 1px solid #e0e7ea; + border-radius: 5px; + padding: 5px; +} +.user-entity-select dt { + color: #718087; + font-size: 9px; +} +.user-entity-select dd { + margin: 2px 0 0; + font-weight: 800; +} +.audit-command-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px; +} +.audit-command-row [role="tablist"] { + display: flex; + gap: 4px; +} +.audit-command-row [role="tab"] { + min-height: 30px; + border: 1px solid #bac8ce; + background: #fff; + padding: 0 10px; +} +.audit-command-row [aria-selected="true"] { + background: #17383f; + color: #fff; +} +.audit-filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 7px; + padding: 0 10px 10px; +} +.inspector-fact-list article { + display: grid; + gap: 3px; + border: 1px solid #d9e2e6; + border-radius: 6px; + padding: 8px; + background: #f8fafb; +} +.inspector-fact-list small { + overflow-wrap: anywhere; + color: #61747c; +} +@media (max-width: 720px) { + .user-entity-grid { + grid-template-columns: 1fr; + } + .audit-filter-grid { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 390px) { + .user-operations { + min-height: 0; + } + .audit-filter-grid { + grid-template-columns: minmax(0, 1fr); + } + .audit-command-row { + align-items: stretch; + flex-direction: column; + } + .user-entity-select dl { + grid-template-columns: 1fr; + } +} diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index f50c4715..ee2c7e89 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -2,14 +2,9 @@ -

{{ mutationError }}

+

{ mutationError }

-

{{ auditError }}

+

{ auditError }

审计加载中…

- + } - { row.idempotencyKey || "-" } - + diff --git a/web/hwlab-cloud-web/src/views/projects/ProjectsView.vue b/web/hwlab-cloud-web/src/views/projects/ProjectsView.vue index 2f670cc5..2644b7a6 100644 --- a/web/hwlab-cloud-web/src/views/projects/ProjectsView.vue +++ b/web/hwlab-cloud-web/src/views/projects/ProjectsView.vue @@ -33,7 +33,7 @@ const tasks = ref([]); const links = ref([]); const selectedProjectTasks = ref([]); const selectedProjectTaskTotal = ref(0); -const selectedProjectBlockedTotal = ref(0); +const selectedProjectCompletedTotal = ref(0); const selectedProjectInProgressTotal = ref(0); const selectedProjectTaskCountsExact = ref(false); const selectedProjectTaskLoading = ref(false); @@ -127,20 +127,20 @@ async function loadSelectedProjectTasks(projectId: string | null): Promise if (!projectId) { selectedProjectTasks.value = []; selectedProjectTaskTotal.value = 0; - selectedProjectBlockedTotal.value = 0; + selectedProjectCompletedTotal.value = 0; selectedProjectInProgressTotal.value = 0; selectedProjectTaskCountsExact.value = false; return; } selectedProjectTasks.value = []; selectedProjectTaskTotal.value = 0; - selectedProjectBlockedTotal.value = 0; + selectedProjectCompletedTotal.value = 0; selectedProjectInProgressTotal.value = 0; selectedProjectTaskCountsExact.value = false; selectedProjectTaskLoading.value = true; - const [response, blockedResponse, inProgressResponse] = await Promise.all([ + const [response, completedResponse, inProgressResponse] = await Promise.all([ projectManagementAPI.tasks({ projectId, limit: 20, offset: 0 }), - projectManagementAPI.tasks({ projectId, status: "blocked", limit: 1, offset: 0 }), + projectManagementAPI.tasks({ projectId, status: "completed", limit: 1, offset: 0 }), projectManagementAPI.tasks({ projectId, status: "in_progress", limit: 1, offset: 0 }) ]); if (requestId !== selectedProjectTaskRequest) return; @@ -151,14 +151,14 @@ async function loadSelectedProjectTasks(projectId: string | null): Promise } selectedProjectTasks.value = response.data?.tasks ?? []; selectedProjectTaskTotal.value = Number(response.data?.page?.total ?? selectedProjectTasks.value.length); - if (blockedResponse.ok && inProgressResponse.ok) { - selectedProjectBlockedTotal.value = Number(blockedResponse.data?.page?.total ?? blockedResponse.data?.tasks?.length ?? 0); + if (completedResponse.ok && inProgressResponse.ok) { + selectedProjectCompletedTotal.value = Number(completedResponse.data?.page?.total ?? completedResponse.data?.tasks?.length ?? 0); selectedProjectInProgressTotal.value = Number(inProgressResponse.data?.page?.total ?? inProgressResponse.data?.tasks?.length ?? 0); selectedProjectTaskCountsExact.value = true; } else { - selectedProjectBlockedTotal.value = selectedProjectTasks.value.filter((task) => task.status === "blocked").length; + selectedProjectCompletedTotal.value = selectedProjectTasks.value.filter((task) => task.status === "completed").length; selectedProjectInProgressTotal.value = selectedProjectTasks.value.filter((task) => task.status === "in_progress").length; - selectedProjectTaskError.value = "任务状态总计读取失败;blocker 计数仅代表当前 20 条窗口"; + selectedProjectTaskError.value = "任务状态总计读取失败;completed 计数仅代表当前 20 条窗口"; } } @@ -197,8 +197,7 @@ function displayCount(value?: number | null): string { } function taskStatusLabel(value?: string | null): string { - if (value === "done") return "完成"; - if (value === "blocked") return "阻塞"; + if (value === "completed") return "完成"; if (value === "open") return "待办"; if (value === "in_progress") return "进行中"; return value || "未知"; @@ -273,7 +272,7 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
Source
{{ selectedSources.length }}
关联会话
{{ selectedLinks.length }}
任务活动
{{ selectedProjectTaskTotal }} total / {{ selectedProjectInProgressTotal }} in progress
-
Blocker
{{ selectedProjectBlockedTotal }} blocked{{ selectedProjectTaskCountsExact ? '服务端总计' : '当前窗口' }}
+
Completed
{{ selectedProjectCompletedTotal }} completed{{ selectedProjectTaskCountsExact ? '服务端总计' : '当前窗口' }}

Source readiness

  • @@ -374,8 +373,8 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu .source-list small, .link-list small { min-width: 0; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .task-preview-list code { min-width: 0; overflow: hidden; color: #475569; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.task-status[data-status="done"] { border-color: #86efac; background: #dcfce7; color: #166534; } -.task-status[data-status="blocked"] { border-color: #fed7aa; background: #fff7ed; color: #9a3412; } +.task-status[data-status="completed"] { border-color: #86efac; background: #dcfce7; color: #166534; } +.task-status[data-status="completed"] { border-color: #86efac; background: #dcfce7; color: #166534; } .task-status { border-color: #bae6fd; background: #f0f9ff; color: #075985; } @media (max-width: 1180px) { .project-summary-grid,