fix(web): 收敛 MDTODO 状态与工作区拆分

This commit is contained in:
root
2026-07-15 03:10:32 +02:00
parent 009083468a
commit 2667b7e18f
25 changed files with 2137 additions and 1989 deletions
+4 -4
View File
@@ -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" });
+10 -10
View File
@@ -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;
}
+2 -2
View File
@@ -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)}`, {
+1 -1
View File
@@ -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";
@@ -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;
@@ -141,9 +141,8 @@ function identifier(
<dt>State</dt>
<dd>
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 }}
</dd>
</div>
</dl>
@@ -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");
}
</script>
@@ -154,8 +155,7 @@ function updateStatus(event: Event): void {
>
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="blocked">阻塞</option>
<option value="done">完成</option>
<option value="completed">完成</option>
</select>
</label>
</header>
@@ -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<string>;
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<string>];
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)"
>
<option value="all">全部状态</option>
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="blocked">阻塞</option>
<option value="done">完成</option>
<option value="completed">完成</option>
</select>
</div>
@@ -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<RedeemCodeSummary[]>([]);
const redemptions = ref<RedeemRedemptionSummary[]>([]);
const loading = ref(true);
const loaded = ref(false);
const error = ref<string | null>(null);
const pending = ref<string | null>(null);
const selectedCodeId = ref<string | null>(
typeof route.query.selectedCodeId === "string"
? route.query.selectedCodeId
: null,
);
const createOpen = ref(false);
const createdCode = ref<string | null>(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<void> {
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<void> {
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<void> {
const codeId = codeKey(code);
selectedCodeId.value = codeId;
await router.push({ query: { ...route.query, selectedCodeId: codeId } });
}
async function closeInspector(): Promise<void> {
selectedCodeId.value = null;
await router.push({ query: { ...route.query, selectedCodeId: undefined } });
}
async function setStatus(code: RedeemCodeSummary): Promise<void> {
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<void> {
pending.value = "create";
const body: Record<string, unknown> = {
...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,
};
}
@@ -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<string | null>(null);
const auditUsageRows = ref<AdminUsageRow[]>([]);
const auditLedgerRows = ref<UsageLedgerRow[]>([]);
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<string, unknown> = {}): Record<string, unknown> {
const params: Record<string, unknown> = { 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<void> {
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<void> {
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<void> {
auditTab.value = value;
auditPage.value = 1;
await loadAudit();
}
async function setAuditPage(value: number): Promise<void> {
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,
};
}
@@ -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<AdminBillingUserSummary[]>([]);
const plans = ref<BillingPlanDetail[]>([]);
const detail = ref<AdminBillingUserDetail | null>(null);
const selectedUserId = ref<string | null>(
typeof route.params.userId === "string" ? route.params.userId : null,
);
const loading = ref(true);
const loaded = ref(false);
const detailLoading = ref(false);
const error = ref<string | null>(null);
const mutationError = ref<string | null>(null);
const pending = ref<string | null>(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<ResourceEntitlementSummary[]>(
() => selectedSummary.value?.entitlements ?? [],
);
const ledgerRows = computed<UsageLedgerRow[]>(
() => detail.value?.ledger?.rows ?? [],
);
const apiKeys = computed<ApiKeyRecord[]>(
() => detail.value?.apiKeys?.keys ?? [],
);
const reservations = computed<UsageReservationRow[]>(
() =>
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<void> {
const response = await usageAPI.adminPlans();
if (response.ok) plans.value = response.data?.plans ?? [];
}
async function loadUsers(updateRoute = true): Promise<void> {
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<void> {
page.value = 1;
await loadUsers();
}
async function setPage(value: number): Promise<void> {
page.value = value;
await loadUsers();
}
async function syncUserQuery(): Promise<void> {
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<void> {
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<void> {
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<void> {
const userId = selectedUser.value?.id;
if (!userId) return;
pending.value = `save:${userId}`;
mutationError.value = null;
const body: Record<string, unknown> = {
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<void> {
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<void> {
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,
};
}
@@ -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<string, unknown>)
.slice(0, 4)
.map(([key, item]) => `${key}=${typeof item === "object" ? "[structured]" : String(item)}`)
.join(" · ") || "-";
}
@@ -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 "待办";
@@ -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;
}
/**
@@ -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" },
@@ -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<MdtodoTaskStatusFilter>(() => normalizeStatusFilter(queryValue(route, "status")));
async function update(
next: {
view?: MdtodoViewMode;
tab?: MdtodoMobileTab;
search?: string;
status?: string;
status?: MdtodoTaskStatusFilter;
},
replace = false,
): Promise<void> {
@@ -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<typeof useMdtodoSource>;
taskData: ReturnType<typeof useMdtodoTaskData>;
mutation: ReturnType<typeof useMdtodoTaskMutation>;
report: ReturnType<typeof useMdtodoReportPreview>;
launch: ReturnType<typeof useMdtodoWorkbenchLaunch>;
viewState: ReturnType<typeof useMdtodoViewState>;
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
report.closeReportPreview();
await syncRoute(false, null);
if (viewState.tab.value === "report")
await viewState.update({ tab: "document" }, true);
}
async function launchWorkbench(): Promise<void> {
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<void> {
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<void> {
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,
}));
}
@@ -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<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
const collapsedTaskRefs = ref(new Set<string>());
const taskPaneWidth = ref(25);
const queuePaneWidth = ref(40);
const taskPaneCollapsed = ref(false);
const reportPaneWidth = ref(48);
const reportPaneCollapsed = ref(false);
const refreshMode = ref<MdtodoRefreshMode>("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<string | null>(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<typeof setTimeout> | 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<Record<string, number>>((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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
selectedTaskRef.value = taskRef;
report.closeReportPreview();
await syncRoute();
await viewState.update({ tab: "document" }, true);
}
async function applyRoute(): Promise<void> {
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<void> {
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<typeof snapshotTaskWorkspace>,
): 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
report.closeReportPreview();
await syncRoute(false, null);
if (viewState.tab.value === "report")
await viewState.update({ tab: "document" }, true);
}
async function launchWorkbench(): Promise<void> {
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<void> {
const sourceId = await source.saveSourceConfig();
if (sourceId) {
showSourceConfig.value = false;
await selectSource(sourceId);
}
}
async function reindex(): Promise<void> {
if (
selectedSourceId.value &&
(await source.reindexSource(selectedSourceId.value))
)
await reloadWindow();
}
async function loadMore(): Promise<void> {
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<void> {
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,
};
}
@@ -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<typeof useMdtodoRouteSelection>;
source: ReturnType<typeof useMdtodoSource>;
taskData: ReturnType<typeof useMdtodoTaskData>;
report: ReturnType<typeof useMdtodoReportPreview>;
viewState: ReturnType<typeof useMdtodoViewState>;
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<void> {
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<boolean> {
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<void> {
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<typeof snapshotTaskWorkspace>,
): 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<void> {
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<void> {
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<void> {
selectedTaskRef.value = taskRef;
report.closeReportPreview();
await syncRoute();
await viewState.update({ tab: "document" }, true);
}
async function applyRoute(): Promise<void> {
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<void> {
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<void> {
if (
selectedSourceId.value &&
(await source.reindexSource(selectedSourceId.value))
)
await reloadWindow();
}
async function loadMore(): Promise<void> {
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<typeof useMdtodoWorkspaceNavigation>;
@@ -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<typeof useMdtodoSource>;
taskData: ReturnType<typeof useMdtodoTaskData>;
report: ReturnType<typeof useMdtodoReportPreview>;
launch: ReturnType<typeof useMdtodoWorkbenchLaunch>;
viewState: ReturnType<typeof useMdtodoViewState>;
}) {
const { source, taskData, report, launch, viewState } = dependencies;
const loading = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
const collapsedTaskRefs = ref(new Set<string>());
const taskPaneWidth = ref(25);
const queuePaneWidth = ref(40);
const taskPaneCollapsed = ref(false);
const reportPaneWidth = ref(48);
const reportPaneCollapsed = ref(false);
const refreshMode = ref<MdtodoRefreshMode>("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<MdtodoTaskStatus>("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<string | 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);
},
});
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<Record<string, number>>((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<typeof useMdtodoWorkspaceState>;
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,
};
}
@@ -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;
}
}
@@ -2,13 +2,9 @@
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
<script setup lang="ts">
import { ChartBar, CreditCard } from "lucide-vue-next";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { billingAPI } from "@/api";
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import DataGrid from "@/components/common/DataGrid.vue";
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import BillingCodeIndex from "@/components/billing/BillingCodeIndex.vue";
import BillingCodeInspector from "@/components/billing/BillingCodeInspector.vue";
@@ -18,217 +14,15 @@ import AuditTimeline from "@/components/console/AuditTimeline.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import SectionFrame from "@/components/layout/SectionFrame.vue";
import StatusStrip from "@/components/layout/StatusStrip.vue";
import { useConsoleViewState } from "@/composables/useConsoleViewState";
import type { RedeemCodeSummary, RedeemRedemptionSummary } from "@/types";
import { useBillingWorkspace } from "@/composables/admin/useBillingWorkspace";
const route = useRoute();
const router = useRouter();
const viewState = useConsoleViewState<"summary" | "detail">({
view: "summary",
viewValues: ["summary", "detail"],
tab: "codes",
});
const codes = ref<RedeemCodeSummary[]>([]);
const redemptions = ref<RedeemRedemptionSummary[]>([]);
const loading = ref(true);
const loaded = ref(false);
const error = ref<string | null>(null);
const pending = ref<string | null>(null);
const selectedCodeId = ref<string | null>(
typeof route.query.selectedCodeId === "string"
? route.query.selectedCodeId
: null,
);
const createOpen = ref(false);
const createdCode = ref<string | null>(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<void> {
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<void> {
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<void> {
const codeId = codeKey(code);
selectedCodeId.value = codeId;
await router.push({ query: { ...route.query, selectedCodeId: codeId } });
}
async function closeInspector(): Promise<void> {
selectedCodeId.value = null;
await router.push({ query: { ...route.query, selectedCodeId: undefined } });
}
async function setStatus(code: RedeemCodeSummary): Promise<void> {
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<void> {
pending.value = "create";
const body: Record<string, unknown> = {
...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");
}
const {
codes, redemptions, loading, error, pending, selectedCodeId, createOpen,
createdCode, filters, form, activeTab, viewMode, selectedCode, asyncState,
statusItems, codeColumns, redemptionColumns, refreshAll, persistFilters,
selectCode, closeInspector, setStatus, createCode, codeKey, redemptionKey,
formatNumber, formatDate,
} = useBillingWorkspace();
</script>
<template>
@@ -306,27 +100,27 @@ function formatDate(value: unknown): string {
type="button"
@click="selectCode(code)"
>
<span class="status-pill" :data-status="code.status">{{
<span class="status-pill" :data-status="code.status">{
code.status
}}</span
><strong>{{ code.codePrefix }}</strong
><small>{{ code.displayName || code.reason }}</small>
}</span
><strong>{ code.codePrefix }</strong
><small>{ code.displayName || code.reason }</small>
<dl>
<div>
<dt>Credits</dt>
<dd>{{ formatNumber(code.creditAmount) }}</dd>
<dd>{ formatNumber(code.creditAmount) }</dd>
</div>
<div>
<dt>Usage</dt>
<dd>
{{ formatNumber(code.redeemedCount) }}/{{
{ formatNumber(code.redeemedCount) }/{
formatNumber(code.maxRedemptions)
}}
}
</dd>
</div>
<div>
<dt>Expires</dt>
<dd>{{ formatDate(code.expiresAt) }}</dd>
<dd>{ formatDate(code.expiresAt) }</dd>
</div>
</dl>
</button>
@@ -344,21 +138,21 @@ function formatDate(value: unknown): string {
aria-label="兑换码清单"
@select="selectCode"
><template #cell-code="{ row }"
><code>{{ row.codePrefix }}</code
><small>{{ row.displayName || row.id }}</small></template
><code>{ row.codePrefix }</code
><small>{ row.displayName || row.id }</small></template
><template #cell-status="{ row }"
><span class="status-pill" :data-status="row.status">{{
><span class="status-pill" :data-status="row.status">{
row.status
}}</span></template
><template #cell-credits="{ row }">{{
}</span></template
><template #cell-credits="{ row }">{
formatNumber(row.creditAmount)
}}</template
}</template
><template #cell-usage="{ row }"
>{{ formatNumber(row.redeemedCount) }} /
{{ formatNumber(row.maxRedemptions) }}</template
><template #cell-expires="{ row }">{{
>{ formatNumber(row.redeemedCount) } /
{ formatNumber(row.maxRedemptions) }</template
><template #cell-expires="{ row }">{
formatDate(row.expiresAt)
}}</template
}</template
><template #cell-actions="{ row }"
><button
class="table-action"
@@ -366,7 +160,7 @@ function formatDate(value: unknown): string {
:disabled="pending === `status:${row.id}`"
@click.stop="setStatus(row)"
>
{{ row.status === "disabled" ? "启用" : "禁用" }}
{ row.status === "disabled" ? "启用" : "禁用" }
</button></template
></DataGrid
></SectionFrame
@@ -399,12 +193,12 @@ function formatDate(value: unknown): string {
><AuditTimeline :items="redemptions" :item-key="redemptionKey"
><template #item="{ item }"
><strong
>{{ item.codePrefix || item.codeId }} ·
{{ item.userId }}</strong
>{ item.codePrefix || item.codeId } ·
{ item.userId }</strong
><small
>{{ formatDate(item.createdAt) }} · {{ item.status }} ·
{{ item.id }}</small
><small>{{ item.ledgerId || "no ledger" }}</small></template
>{ formatDate(item.createdAt) } · { item.status } ·
{ item.id }</small
><small>{ item.ledgerId || "no ledger" }</small></template
></AuditTimeline
></SectionFrame
><SectionFrame
@@ -419,22 +213,22 @@ function formatDate(value: unknown): string {
:interactive="false"
aria-label="兑换记录清单"
><template #cell-time="{ row }"
>{{ formatDate(row.createdAt)
}}<small>{{ row.id }}</small></template
>{ formatDate(row.createdAt)
}<small>{ row.id }</small></template
><template #cell-user="{ row }"
><code>{{ row.userId }}</code></template
><code>{ row.userId }</code></template
><template #cell-code="{ row }"
><code>{{ row.codePrefix }}</code
><small>{{ row.codeId }}</small></template
><template #cell-credits="{ row }">{{
><code>{ row.codePrefix }</code
><small>{ row.codeId }</small></template
><template #cell-credits="{ row }">{
formatNumber(row.creditAmount)
}}</template
}</template
><template #cell-status="{ row }"
><span class="status-pill" :data-status="row.status">{{
><span class="status-pill" :data-status="row.status">{
row.status
}}</span></template
}</span></template
><template #cell-ledger="{ row }"
><code>{{ row.ledgerId || "-" }}</code></template
><code>{ row.ledgerId || "-" }</code></template
></DataGrid
></SectionFrame
></template
@@ -477,7 +271,7 @@ function formatDate(value: unknown): string {
/></label>
</div>
<p v-if="createdCode" class="created-redeem-code">
<strong>立即保存完整兑换码</strong><code>{{ createdCode }}</code
<strong>立即保存完整兑换码</strong><code>{ createdCode }</code
><small>仅此响应可见关闭后无法再次读取完整值</small>
</p>
<template #footer
@@ -489,7 +283,7 @@ function formatDate(value: unknown): string {
createOpen = false;
"
>
{{ createdCode ? "已保存并关闭" : "取消" }}</button
{ createdCode ? "已保存并关闭" : "取消" }</button
><button
v-if="!createdCode"
class="table-action"
@@ -497,7 +291,7 @@ function formatDate(value: unknown): string {
:disabled="pending === 'create'"
@click="createCode"
>
{{ pending === "create" ? "创建中" : "创建" }}
{ pending === "create" ? "创建中" : "创建" }
</button></template
></BaseDialog
>
@@ -505,119 +299,4 @@ function formatDate(value: unknown): string {
</section>
</template>
<style>
.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;
}
}
</style>
<style src="./BillingView.css"></style>
@@ -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;
}
}
+40 -716
View File
@@ -2,14 +2,9 @@
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
<script setup lang="ts">
import { Grid2X2, List } from "lucide-vue-next";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { LocationQueryRaw } from "vue-router";
import { usageAPI } from "@/api";
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
import BaseDrawer from "@/components/common/BaseDrawer.vue";
import DataGrid from "@/components/common/DataGrid.vue";
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
@@ -19,559 +14,20 @@ import StatusStrip from "@/components/layout/StatusStrip.vue";
import UserDirectoryCollection from "@/components/users/UserDirectoryCollection.vue";
import UserDirectoryIndex from "@/components/users/UserDirectoryIndex.vue";
import UserAccountInspector from "@/components/users/UserAccountInspector.vue";
import { useConsoleViewState } from "@/composables/useConsoleViewState";
import type {
AccessUserStatus,
AdminBillingUserDetail,
AdminBillingUserSummary,
AdminUsageRow,
ApiKeyRecord,
AuthUser,
BillingPlanDetail,
ResourceEntitlementSummary,
UsageLedgerRow,
UsageReservationRow,
} from "@/types";
import { useUsersWorkspace } from "@/composables/admin/useUsersWorkspace";
const route = useRoute();
const router = useRouter();
const viewState = useConsoleViewState<"list" | "grid">({
view: "list",
viewValues: ["list", "grid"],
tab: "profile",
});
const users = ref<AdminBillingUserSummary[]>([]);
const plans = ref<BillingPlanDetail[]>([]);
const detail = ref<AdminBillingUserDetail | null>(null);
const selectedUserId = ref<string | null>(
typeof route.params.userId === "string" ? route.params.userId : null,
);
const loading = ref(true);
const loaded = ref(false);
const detailLoading = ref(false);
const error = ref<string | null>(null);
const mutationError = ref<string | null>(null);
const pending = ref<string | null>(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 auditTab = ref<"usage" | "ledger">(
route.query.audit === "ledger" ? "ledger" : "usage",
);
const auditLoading = ref(false);
const auditError = ref<string | null>(null);
const auditUsageRows = ref<AdminUsageRow[]>([]);
const auditLedgerRows = ref<UsageLedgerRow[]>([]);
const auditPage = ref(Math.max(1, Number(route.query.auditPage) || 1));
const auditPageSize = 25;
const auditTotal = ref(0);
const filters = reactive({
search: textQuery("q"),
status: textQuery("filter"),
role: textQuery("role"),
});
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"),
});
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;
let auditRequestSequence = 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<ResourceEntitlementSummary[]>(
() => selectedSummary.value?.entitlements ?? [],
);
const ledgerRows = computed<UsageLedgerRow[]>(
() => detail.value?.ledger?.rows ?? [],
);
const apiKeys = computed<ApiKeyRecord[]>(
() => detail.value?.apiKeys?.keys ?? [],
);
const reservations = computed<UsageReservationRow[]>(
() =>
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 || "-",
},
]);
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" },
];
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" },
];
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" },
];
onMounted(async () => {
await Promise.all([loadPlans(), loadUsers(false), 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 nextAuditPage = Math.max(1, Number(route.query.auditPage) || 1);
const nextAuditTab = route.query.audit === "ledger" ? "ledger" : "usage";
const userChanged =
filters.search !== textQuery("q") ||
filters.status !== textQuery("filter") ||
filters.role !== textQuery("role") ||
page.value !== nextPage;
const auditChanged =
auditPage.value !== nextAuditPage ||
auditTab.value !== nextAuditTab ||
Object.entries(auditQueryMap).some(
([key, queryKey]) =>
auditFilters[key as keyof typeof auditFilters] !==
textQuery(queryKey),
);
if (userChanged) {
filters.search = textQuery("q");
filters.status = textQuery("filter");
filters.role = textQuery("role");
page.value = nextPage;
void loadUsers(false);
}
if (auditChanged) {
for (const [key, queryKey] of Object.entries(auditQueryMap))
auditFilters[key as keyof typeof auditFilters] = textQuery(queryKey);
auditPage.value = nextAuditPage;
auditTab.value = nextAuditTab;
void loadAudit(false);
}
},
);
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;
function textQuery(key: string): string {
return typeof route.query[key] === "string" ? String(route.query[key]) : "";
}
async function loadPlans(): Promise<void> {
const response = await usageAPI.adminPlans();
if (response.ok) plans.value = response.data?.plans ?? [];
}
async function loadUsers(updateRoute = true): Promise<void> {
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<void> {
page.value = 1;
await loadUsers();
}
async function setPage(value: number): Promise<void> {
page.value = value;
await loadUsers();
}
async function syncUserQuery(): Promise<void> {
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<void> {
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<void> {
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<void> {
const userId = selectedUser.value?.id;
if (!userId) return;
pending.value = `save:${userId}`;
mutationError.value = null;
const body: Record<string, unknown> = {
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<void> {
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<void> {
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),
loadAudit(false),
]);
}
}
function auditParams(
extra: Record<string, unknown> = {},
): Record<string, unknown> {
const params: Record<string, unknown> = {
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<void> {
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<void> {
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<void> {
auditTab.value = value;
auditPage.value = 1;
await loadAudit();
}
async function setAuditPage(value: number): Promise<void> {
auditPage.value = value;
await loadAudit();
}
function downloadAudit(): void {
window.location.assign(
auditTab.value === "usage"
? usageAPI.adminUsageExportUrl(auditParams({ limit: 1000 }))
: usageAPI.adminLedgerExportUrl(auditParams({ limit: 1000 })),
);
}
function userKey(item: AdminBillingUserSummary): string {
return item.user?.id ?? item.user?.username ?? "unknown-user";
}
function auditKey(item: AdminUsageRow | UsageLedgerRow): string {
return item.id ?? `${item.userId ?? "user"}:${item.createdAt ?? "time"}`;
}
function formatNumber(value: unknown): string {
const number = Number(value ?? 0);
return Number.isFinite(number)
? new Intl.NumberFormat("zh-CN").format(number)
: "0";
}
function formatSigned(value: unknown): string {
const number = Number(value ?? 0);
return `${number > 0 ? "+" : ""}${formatNumber(number)}`;
}
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");
}
function metadataPreview(value: unknown): string {
if (!value || typeof value !== "object" || Array.isArray(value)) return "-";
return (
Object.entries(value as Record<string, unknown>)
.slice(0, 4)
.map(
([key, item]) =>
`${key}=${typeof item === "object" ? "[structured]" : String(item)}`,
)
.join(" · ") || "-"
);
}
const {
users, plans, selectedUserId, loading, detailLoading, error, mutationError,
pending, page, pageSize, total, createOpen, filters, createForm, editForm,
creditForm, selectedSummary, selectedUser, selectedEntitlements, ledgerRows,
apiKeys, reservations, viewMode, detailTab, asyncState, statusItems,
loadUsers, applyFilters, setPage, selectUser, closeInspector, createUser, saveUser,
setUserStatus, adjustCredits, userColumns, userKey, formatNumber,
auditTab, auditLoading, auditError, auditUsageRows, auditLedgerRows,
auditPage, auditPageSize, auditTotal, auditFilters, loadAudit, setAuditTab, setAuditPage,
downloadAudit, usageColumns, ledgerColumns, auditKey, formatSigned, formatDate,
metadataPreview,
} = useUsersWorkspace();
</script>
<template>
@@ -607,7 +63,7 @@ function metadataPreview(value: unknown): string {
@action="loadUsers"
/></template>
<StatusStrip :items="statusItems" />
<p v-if="mutationError" class="form-error">{{ mutationError }}</p>
<p v-if="mutationError" class="form-error">{ mutationError }</p>
<OperationsWorkspace
class="user-operations"
label="用户身份运营"
@@ -717,7 +173,7 @@ function metadataPreview(value: unknown): string {
应用筛选
</button>
</div>
<p v-if="auditError" class="form-error">{{ auditError }}</p>
<p v-if="auditError" class="form-error">{ auditError }</p>
<p v-if="auditLoading" class="muted-box">审计加载中</p>
<DataGrid
v-else-if="auditTab === 'usage'"
@@ -727,24 +183,24 @@ function metadataPreview(value: unknown): string {
:interactive="false"
aria-label="全局 Usage"
>
<template #cell-time="{ row }">{{
<template #cell-time="{ row }">{
formatDate(row.createdAt)
}}</template
}</template
><template #cell-user="{ row }"
><code>{{ row.userId }}</code
><small>{{ row.username || row.email }}</small></template
><code>{ row.userId }</code
><small>{ row.username || row.email }</small></template
><template #cell-service="{ row }"
><code>{{ row.serviceId }}</code></template
><template #cell-resource="{ row }">{{
><code>{ row.serviceId }</code></template
><template #cell-resource="{ row }">{
row.resourceType || row.id
}}</template
><template #cell-credits="{ row }">{{
}</template
><template #cell-credits="{ row }">{
formatNumber(row.credits)
}}</template
><template #cell-status="{ row }">{{ row.status }}</template
><template #cell-metadata="{ row }">{{
}</template
><template #cell-status="{ row }">{ row.status }</template
><template #cell-metadata="{ row }">{
metadataPreview(row.metadata)
}}</template>
}</template>
</DataGrid>
<DataGrid
v-else
@@ -754,25 +210,25 @@ function metadataPreview(value: unknown): string {
:interactive="false"
aria-label="全局 Ledger"
>
<template #cell-time="{ row }">{{
<template #cell-time="{ row }">{
formatDate(row.createdAt)
}}</template
}</template
><template #cell-user="{ row }"
><code>{{ row.userId }}</code
><small>{{ row.username || row.email }}</small></template
><code>{ row.userId }</code
><small>{ row.username || row.email }</small></template
><template #cell-action="{ row }"
>{{ row.kind }} · {{ row.reason }}</template
><template #cell-delta="{ row }">{{
>{ row.kind } · { row.reason }</template
><template #cell-delta="{ row }">{
formatSigned(row.deltaCredits)
}}</template
}</template
><template #cell-balance="{ row }"
>{{ formatNumber(row.balanceBefore) }}
{{ formatNumber(row.balanceAfter) }}</template
><template #cell-operator="{ row }">{{
>{ formatNumber(row.balanceBefore) }
{ formatNumber(row.balanceAfter) }</template
><template #cell-operator="{ row }">{
row.operatorUsername || row.operatorUserId || row.source
}}</template
}</template
><template #cell-idempotency="{ row }"
><code>{{ row.idempotencyKey || "-" }}</code></template
><code>{ row.idempotencyKey || "-" }</code></template
>
</DataGrid>
<Pagination
@@ -848,136 +304,4 @@ function metadataPreview(value: unknown): string {
</section>
</template>
<style>
.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;
}
}
</style>
<style src="./UsersView.css"></style>
@@ -33,7 +33,7 @@ const tasks = ref<MdtodoTaskRecord[]>([]);
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const selectedProjectTasks = ref<MdtodoTaskRecord[]>([]);
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<void>
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<void>
}
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
<div><dt>Source</dt><dd>{{ selectedSources.length }}</dd></div>
<div><dt>关联会话</dt><dd>{{ selectedLinks.length }}</dd></div>
<div><dt>任务活动</dt><dd>{{ selectedProjectTaskTotal }} total / {{ selectedProjectInProgressTotal }} in progress</dd></div>
<div><dt>Blocker</dt><dd><span class="pm-status" :data-tone="selectedProjectBlockedTotal ? 'error' : 'ok'">{{ selectedProjectBlockedTotal }} blocked</span><small>{{ selectedProjectTaskCountsExact ? '服务端总计' : '当前窗口' }}</small></dd></div>
<div><dt>Completed</dt><dd><span class="pm-status" data-tone="ok">{{ selectedProjectCompletedTotal }} completed</span><small>{{ selectedProjectTaskCountsExact ? '服务端总计' : '当前窗口' }}</small></dd></div>
</dl>
<section><h3>Source readiness</h3><ul class="source-list">
<li v-for="source in selectedSources" :key="source.sourceId">
@@ -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,