fix(web): restore admin console contracts

This commit is contained in:
root
2026-07-13 12:21:02 +02:00
parent 156ebb9ce1
commit f0a364fe7f
9 changed files with 214 additions and 54 deletions
@@ -35,11 +35,14 @@ test("R6 useForm normalizes nested API errors for toast/error surfaces", () => {
assert.equal(messageFromError({ reason: "blocked_by_gate" }), "blocked_by_gate");
});
test("R6 common module map keeps HWLAB views on shared table/dialog/form primitives", () => {
test("R6 common module map keeps HWLAB views on shared console primitives", () => {
assert.equal(fs.existsSync(path.join(rootDir, "src/components/common/DataTable.vue")), false, "legacy DataTable should stay removed after DataGrid migration");
const expectations: Array<[string, string[]]> = [
["src/views/admin/ProviderProfilesView.vue", ["AsyncBoundary", "DataGrid", "ResourceCollection", "BaseDialog", "ConfirmDialog", "TablePageLayout", "useTableLoader", "useForm"]],
["src/components/access/AccessMatrixPanel.vue", ["AsyncBoundary", "DataGrid", "ResourceCollection", "BoundedWorkspace", "TablePageLayout"]],
["src/components/access/AccessMatrixPanel.vue", ["AsyncBoundary", "OperationsWorkspace", "EntityIndex", "InspectorPanel", "ViewModeSwitch"]],
["src/views/admin/SecretsView.vue", ["AsyncBoundary", "OperationsWorkspace", "EntityIndex", "FactPath", "ViewModeSwitch"]],
["src/views/admin/UsersView.vue", ["AsyncBoundary", "OperationsWorkspace", "EntityIndex", "InspectorPanel", "ViewModeSwitch"]],
["src/views/admin/BillingView.vue", ["AsyncBoundary", "OperationsWorkspace", "EntityIndex", "InspectorPanel", "ViewModeSwitch"]],
["src/views/GateView.vue", ["AsyncBoundary", "DataGrid", "Pagination", "BaseDialog", "TablePageLayout", "useTableLoader"]],
["src/views/PerformanceView.vue", ["AsyncBoundary", "DataGrid", "TablePageLayout", "useTableLoader"]],
["src/views/SkillsView.vue", ["AsyncBoundary", "DataGrid", "ResourceCollection", "VirtualList", "BaseDialog", "TablePageLayout", "useTableLoader", "useForm"]]
@@ -49,9 +52,23 @@ test("R6 common module map keeps HWLAB views on shared table/dialog/form primiti
const source = read(relativePath);
for (const term of terms) assert.match(source, new RegExp(`\\b${term}\\b`, "u"), `${relativePath} should use ${term}`);
assert.doesNotMatch(source, /<table\s/u, `${relativePath} should not regress to local raw table markup`);
if (/admin\/(?:Secrets|Users|Billing)View|access\/AccessMatrixPanel/u.test(relativePath)) assert.doesNotMatch(source, /view-toggle|admin-user-glyph|billing-glyph|BoundedWorkspace|TablePageLayout/u, `${relativePath} should not restore private legacy layout or glyph implementations`);
}
});
test("R6 console semantics keep real icons, native entity lists, and dynamic access columns", () => {
const iconSource = read("src/components/console/ConsoleIcon.vue");
for (const icon of ["list", "grid", "matrix", "flow", "queue", "report"]) assert.match(iconSource, new RegExp(`\\b${icon}:`, "u"), `ConsoleIcon should define ${icon}`);
assert.doesNotMatch(iconSource, /paths\[props\.name\]\s*\|\|\s*paths\.overview/u, "unknown icons must not silently masquerade as overview");
const entityIndexSource = read("src/components/console/EntityIndex.vue");
assert.match(entityIndexSource, /<ul\b/u);
assert.match(entityIndexSource, /<li\b/u);
assert.doesNotMatch(entityIndexSource, /role="listbox"|role="option"/u);
const accessSource = read("src/components/access/AccessMatrixPanel.vue");
assert.match(accessSource, /--permission-tool-count/u);
assert.doesNotMatch(accessSource, /repeat\(6,/u);
});
test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => {
const issueBody = readIssueBodyFixture();
assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u);
@@ -83,8 +83,8 @@ function valueText(value: unknown): string { return typeof value === "string" &&
<div class="operations-main-stack">
<FactPath :steps="factSteps" label="授权事实路径" />
<SectionFrame :title="viewMode === 'matrix' ? '权限矩阵' : '授权清单'" description="同一授权 read model 的矩阵比较与紧凑清单" :body-padding="false">
<template #actions><ViewModeSwitch v-model="viewMode" :options="[{ value: 'matrix', label: '矩阵', icon: 'access' }, { value: 'list', label: '清单', icon: 'mdtodo' }]" /></template>
<div v-if="viewMode === 'matrix'" class="permission-matrix" role="grid" aria-label="用户权限矩阵">
<template #actions><ViewModeSwitch v-model="viewMode" :options="[{ value: 'matrix', label: '矩阵', icon: 'matrix' }, { value: 'list', label: '清单', icon: 'list' }]" /></template>
<div v-if="viewMode === 'matrix'" class="permission-matrix" role="grid" aria-label="用户权限矩阵" :aria-colcount="access.toolRows.length + 1" :style="{ '--permission-tool-count': access.toolRows.length }">
<div class="permission-matrix-head" role="row"><span role="columnheader">用户</span><span v-for="tool in access.toolRows" :key="tool.id" role="columnheader">{{ tool.label }}</span></div>
<button v-for="row in filteredRows" :key="row.id" type="button" role="row" :data-selected="row.id === access.selectedUserId" @click="selectRow(row)">
<span role="gridcell"><strong>{{ accessUserLabel(row) }}</strong><small>{{ row.role }} · {{ row.status }}</small></span>
@@ -123,7 +123,7 @@ function valueText(value: unknown): string { return typeof value === "string" &&
.entity-index-meta code { color: #64757d; font-size: 10px; }
.operations-main-stack { display: grid; gap: 14px; min-width: 0; }
.permission-matrix { overflow: auto; }
.permission-matrix-head, .permission-matrix > button { display: grid; grid-template-columns: minmax(180px, 1.6fr) repeat(6, minmax(86px, 1fr)); min-width: 760px; }
.permission-matrix-head, .permission-matrix > button { display: grid; grid-template-columns: minmax(180px, 1.6fr) repeat(var(--permission-tool-count), minmax(86px, 1fr)); min-width: 100%; width: max-content; }
.permission-matrix-head { position: sticky; top: 0; z-index: 1; background: #eef3f4; }
.permission-matrix-head span, .permission-matrix > button > span { padding: 9px; border-right: 1px solid #d8e1e4; border-bottom: 1px solid #d8e1e4; text-align: center; }
.permission-matrix-head span:first-child, .permission-matrix > button > span:first-child { text-align: left; }
@@ -8,6 +8,12 @@ const paths: Record<string, string[]> = {
overview: ["M4 4h7v7H4z", "M13 4h7v4h-7z", "M13 10h7v10h-7z", "M4 13h7v7H4z"],
project: ["M3 7h7l2 2h9v10H3z", "M3 7V5h7l2 2"],
mdtodo: ["M5 4h14v16H5z", "M8 8h8", "M8 12h8", "M8 16h5"],
list: ["M8 6h12", "M8 12h12", "M8 18h12", "M4 6h.01", "M4 12h.01", "M4 18h.01"],
grid: ["M4 4h6v6H4z", "M14 4h6v6h-6z", "M4 14h6v6H4z", "M14 14h6v6h-6z"],
matrix: ["M4 4h16v16H4z", "M4 10h16", "M4 16h16", "M10 4v16", "M16 4v16"],
flow: ["M5 4h5v5H5z", "M14 15h5v5h-5z", "M7.5 9v3a5 5 0 0 0 5 5H14", "m12 14 2 3-3 2"],
queue: ["M5 6h14", "M5 12h10", "M5 18h6", "m17 15 3 3-3 3"],
report: ["M6 3h9l3 3v15H6z", "M15 3v4h4", "M9 12h6", "M9 16h6"],
key: ["M14 7a5 5 0 1 0-4.5 7.2L4 19.7V22h2.3l1-1H10v-2.7l1-1h2.7l1.1-1.1", "M17 7h.01"],
usage: ["M4 19V9", "M10 19V5", "M16 19v-7", "M22 19H2"],
billing: ["M4 6h16v12H4z", "M4 10h16", "M8 15h4"],
@@ -26,6 +32,6 @@ const paths: Record<string, string[]> = {
<template>
<svg :width="props.size" :height="props.size" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path v-for="path in paths[props.name] || paths.overview" :key="path" :d="path" />
<path v-for="path in paths[props.name] || []" :key="path" :d="path" />
</svg>
</template>
@@ -9,11 +9,9 @@ const emit = defineEmits<{ select: [item: T] }>();
<template>
<section class="entity-index" :aria-label="label">
<header class="entity-index-toolbar"><slot name="toolbar" /></header>
<div v-if="items.length" class="entity-index-list" role="listbox" :aria-label="label">
<button v-for="item in items" :key="itemKey(item)" class="entity-index-row" type="button" role="option" :aria-selected="itemKey(item) === selectedId" :data-selected="itemKey(item) === selectedId ? 'true' : 'false'" @click="emit('select', item)">
<slot name="item" :item="item" :selected="itemKey(item) === selectedId" />
</button>
</div>
<ul v-if="items.length" class="entity-index-list" :aria-label="label">
<li v-for="item in items" :key="itemKey(item)"><button class="entity-index-row" type="button" :aria-current="itemKey(item) === selectedId ? 'true' : undefined" :data-selected="itemKey(item) === selectedId ? 'true' : 'false'" @click="emit('select', item)"><slot name="item" :item="item" :selected="itemKey(item) === selectedId" /></button></li>
</ul>
<p v-else class="muted-box">{{ emptyText }}</p>
<footer v-if="$slots.footer" class="entity-index-footer"><slot name="footer" /></footer>
</section>
@@ -9,7 +9,7 @@ defineEmits<{ close: [] }>();
<div><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
<button class="icon-button" type="button" aria-label="关闭详情" @click="$emit('close')">×</button>
</header>
<nav v-if="$slots.tabs" class="operations-inspector-tabs" aria-label="详情视图"><slot name="tabs" /></nav>
<nav v-if="$slots.tabs" class="operations-inspector-tabs" role="tablist" aria-label="详情视图"><slot name="tabs" /></nav>
<div class="operations-inspector-body"><slot /></div>
<footer v-if="$slots.footer" class="operations-inspector-footer"><slot name="footer" /></footer>
</section>
@@ -955,6 +955,7 @@ textarea {
}
.operations-workspace {
position: relative;
display: grid;
grid-template-columns: var(--operations-index-width) minmax(0, 1fr);
min-height: 0;
@@ -982,7 +983,8 @@ textarea {
.entity-index { display: flex; flex-direction: column; min-height: 100%; }
.entity-index-toolbar { position: sticky; top: 0; z-index: 2; display: grid; gap: var(--console-space-2); padding: var(--console-space-3); border-bottom: 1px solid var(--console-graphite-200); background: color-mix(in srgb, var(--console-graphite-50) 94%, transparent); }
.entity-index-list { display: grid; align-content: start; }
.entity-index-list { display: grid; align-content: start; margin: 0; padding: 0; list-style: none; }
.entity-index-list > li { min-width: 0; }
.entity-index-row { width: 100%; display: grid; gap: 3px; padding: 11px 13px; border: 0; border-bottom: 1px solid var(--console-graphite-200); background: transparent; color: inherit; text-align: left; cursor: pointer; }
.entity-index-row:hover { background: white; }
.entity-index-row[data-selected="true"] { background: var(--console-cyan-100); box-shadow: inset 3px 0 var(--console-cyan-700); }
@@ -1018,7 +1020,9 @@ textarea {
.view-mode-switch button { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; border: 0; border-radius: 4px; padding: 0 9px; background: transparent; color: var(--console-graphite-600); cursor: pointer; }
.view-mode-switch button[aria-pressed="true"] { background: var(--console-graphite-950); color: white; }
.view-mode-switch button:focus-visible { outline: 2px solid var(--console-cyan-700); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .view-mode-switch button { transition: none; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
@media (max-width: 1100px) {
.operations-workspace,
@@ -4,8 +4,11 @@ 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 EntityIndex from "@/components/console/EntityIndex.vue";
import InspectorPanel from "@/components/console/InspectorPanel.vue";
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
import AuditTimeline from "@/components/console/AuditTimeline.vue";
@@ -27,19 +30,22 @@ const pending = ref<string | null>(null);
const selectedCodeId = ref<string | null>(typeof route.query.codeId === "string" ? route.query.codeId : 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 : "" });
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.redemptionCodeId === "string" ? route.query.redemptionCodeId : "" });
const form = reactive({ displayName: "", creditAmount: 10, reason: "redeem", maxRedemptions: 1, perUserLimit: 1, expiresAt: "" });
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(() => codes.value.find((item) => codeKey(item) === selectedCodeId.value) ?? codes.value[0] ?? null);
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 : ""; selectedCodeId.value = typeof route.query.codeId === "string" ? route.query.codeId : null; void refreshAll(); });
async function refreshAll(): Promise<void> { 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, limit: 100 })]); if (codeResult.ok) codes.value = codeResult.data?.codes ?? []; else error.value = codeResult.error ?? `HTTP ${codeResult.status}`; if (redemptionResult.ok) redemptions.value = redemptionResult.data?.redemptions ?? []; else error.value ||= redemptionResult.error ?? `HTTP ${redemptionResult.status}`; loading.value = false; loaded.value = true; }
async function persistFilters(): Promise<void> { await router.push({ query: { ...route.query, filter: filters.status || undefined, userId: filters.userId || undefined } }); }
watch(() => [route.query.filter, route.query.userId, route.query.redemptionCodeId, 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.redemptionCodeId === "string" ? route.query.redemptionCodeId : ""; selectedCodeId.value = typeof route.query.codeId === "string" ? route.query.codeId : null; void refreshAll(); });
async function refreshAll(): Promise<void> { 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 })]); 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, redemptionCodeId: filters.codeId.trim() || undefined } }); }
async function selectCode(code: RedeemCodeSummary): Promise<void> { const codeId = codeKey(code); selectedCodeId.value = codeId; await router.push({ query: { ...route.query, codeId } }); }
async function closeInspector(): Promise<void> { selectedCodeId.value = null; await router.push({ query: { ...route.query, codeId: 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"; }
@@ -55,17 +61,18 @@ function formatDate(value: unknown): string { const text = String(value ?? "");
<StatusStrip :items="statusItems" />
<OperationsWorkspace class="billing-operations" label="兑换运营工作区" :inspector-open="Boolean(selectedCode)">
<template #index><EntityIndex :items="codes" :item-key="codeKey" :selected-id="selectedCode ? codeKey(selectedCode) : null" label="兑换码索引" @select="selectCode"><template #toolbar><select v-model="filters.status" class="input" @change="persistFilters"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="expired">expired</option></select><small>{{ codes.length }} codes</small></template><template #item="{ item }"><strong><code>{{ item.codePrefix }}</code> {{ item.displayName || item.id }}</strong><small>{{ formatNumber(item.creditAmount) }} credits · {{ formatNumber(item.redeemedCount) }}/{{ formatNumber(item.maxRedemptions) }}</small><span class="status-pill" :data-status="item.status">{{ item.status }}</span></template></EntityIndex></template>
<div class="billing-main-stack"><nav class="operations-tabs"><button type="button" :aria-selected="activeTab === 'codes'" @click="activeTab = 'codes'">Codes</button><button type="button" :aria-selected="activeTab === 'redemptions'" @click="activeTab = 'redemptions'">Redemptions</button></nav>
<SectionFrame v-if="activeTab === 'codes' && viewMode === 'summary'" title="兑换运营摘要" description="状态、额度、使用率与到期时间可直接扫描。" :body-padding="false"><div class="billing-summary-grid"><button v-for="code in codes" :key="code.id" type="button" @click="selectCode(code)"><span class="status-pill" :data-status="code.status">{{ code.status }}</span><strong>{{ code.codePrefix }}</strong><small>{{ code.displayName || code.reason }}</small><dl><div><dt>Credits</dt><dd>{{ formatNumber(code.creditAmount) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(code.redeemedCount) }}/{{ formatNumber(code.maxRedemptions) }}</dd></div><div><dt>Expires</dt><dd>{{ formatDate(code.expiresAt) }}</dd></div></dl></button></div></SectionFrame>
<SectionFrame v-else title="兑换与账本明细" description="同一 redemption DTO 的紧凑审计时间线。"><div class="billing-filters"><input v-model="filters.userId" class="input" placeholder="user id" @keyup.enter="persistFilters"><button class="table-action" type="button" @click="persistFilters">过滤</button></div><AuditTimeline :items="redemptions" :item-key="redemptionKey"><template #item="{ item }"><strong>{{ item.codePrefix || item.codeId }} · {{ item.userId }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.status }} · {{ item.id }}</small></template></AuditTimeline></SectionFrame>
<div class="billing-main-stack"><nav class="operations-tabs" role="tablist" aria-label="兑换运营数据"><button type="button" role="tab" :aria-selected="activeTab === 'codes'" @click="activeTab = 'codes'">Codes</button><button type="button" role="tab" :aria-selected="activeTab === 'redemptions'" @click="activeTab = 'redemptions'">Redemptions</button></nav>
<template v-if="activeTab === 'codes'"><SectionFrame v-if="viewMode === 'summary'" title="兑换运营摘要" description="状态、额度、使用率与到期时间可直接扫描。" :body-padding="false"><div class="billing-summary-grid"><button v-for="code in codes" :key="codeKey(code)" type="button" @click="selectCode(code)"><span class="status-pill" :data-status="code.status">{{ code.status }}</span><strong>{{ code.codePrefix }}</strong><small>{{ code.displayName || code.reason }}</small><dl><div><dt>Credits</dt><dd>{{ formatNumber(code.creditAmount) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(code.redeemedCount) }}/{{ formatNumber(code.maxRedemptions) }}</dd></div><div><dt>Expires</dt><dd>{{ formatDate(code.expiresAt) }}</dd></div></dl></button></div></SectionFrame><SectionFrame v-else title="兑换码紧凑清单" description="同一 RedeemCodeSummary 集合的高密度表格。" :body-padding="false"><DataGrid :columns="codeColumns" :rows="codes" :row-key="codeKey" :selected-id="selectedCodeId" aria-label="兑换码清单" @select="selectCode"><template #cell-code="{ row }"><code>{{ row.codePrefix }}</code><small>{{ row.displayName || row.id }}</small></template><template #cell-status="{ row }"><span class="status-pill" :data-status="row.status">{{ row.status }}</span></template><template #cell-credits="{ row }">{{ formatNumber(row.creditAmount) }}</template><template #cell-usage="{ row }">{{ formatNumber(row.redeemedCount) }} / {{ formatNumber(row.maxRedemptions) }}</template><template #cell-expires="{ row }">{{ formatDate(row.expiresAt) }}</template><template #cell-actions="{ row }"><button class="table-action" type="button" :disabled="pending === `status:${row.id}`" @click.stop="setStatus(row)">{{ row.status === 'disabled' ? '启用' : '禁用' }}</button></template></DataGrid></SectionFrame></template>
<template v-else><div class="billing-filters"><input v-model="filters.userId" class="input" placeholder="user id" @keyup.enter="persistFilters"><input v-model="filters.codeId" class="input" placeholder="code id" @keyup.enter="persistFilters"><button class="table-action" type="button" @click="persistFilters">过滤</button></div><SectionFrame v-if="viewMode === 'summary'" title="兑换时间线" description="同一 RedeemRedemptionSummary 集合的时间序列视图。"><AuditTimeline :items="redemptions" :item-key="redemptionKey"><template #item="{ item }"><strong>{{ item.codePrefix || item.codeId }} · {{ item.userId }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.status }} · {{ item.id }}</small><small>{{ item.ledgerId || 'no ledger' }}</small></template></AuditTimeline></SectionFrame><SectionFrame v-else title="兑换紧凑清单" description="同一 RedeemRedemptionSummary 集合的高密度表格。" :body-padding="false"><DataGrid :columns="redemptionColumns" :rows="redemptions" :row-key="redemptionKey" :interactive="false" aria-label="兑换记录清单"><template #cell-time="{ row }">{{ formatDate(row.createdAt) }}<small>{{ row.id }}</small></template><template #cell-user="{ row }"><code>{{ row.userId }}</code></template><template #cell-code="{ row }"><code>{{ row.codePrefix }}</code><small>{{ row.codeId }}</small></template><template #cell-credits="{ row }">{{ formatNumber(row.creditAmount) }}</template><template #cell-status="{ row }"><span class="status-pill" :data-status="row.status">{{ row.status }}</span></template><template #cell-ledger="{ row }"><code>{{ row.ledgerId || '-' }}</code></template></DataGrid></SectionFrame></template>
</div>
<template #inspector><section v-if="selectedCode" class="operations-inspector-panel"><header class="operations-inspector-header"><div><h2>{{ selectedCode.displayName || selectedCode.codePrefix }}</h2><p>{{ selectedCode.id }}</p></div></header><div class="operations-inspector-body"><dl class="inspector-facts"><div><dt>Status</dt><dd>{{ selectedCode.status }}</dd></div><div><dt>Credits</dt><dd>{{ formatNumber(selectedCode.creditAmount) }}</dd></div><div><dt>Per user</dt><dd>{{ formatNumber(selectedCode.perUserLimit) }}</dd></div><div><dt>Expires</dt><dd>{{ formatDate(selectedCode.expiresAt) }}</dd></div></dl><button class="table-action" type="button" :disabled="pending === `status:${selectedCode.id}`" @click="setStatus(selectedCode)">{{ selectedCode.status === 'disabled' ? '启用' : '禁用' }}</button></div></section></template>
<template #inspector><InspectorPanel v-if="selectedCode" :title="selectedCode.displayName || selectedCode.codePrefix || selectedCode.id || '兑换码'" :subtitle="selectedCode.id" @close="closeInspector"><dl class="inspector-facts"><div><dt>Status</dt><dd>{{ selectedCode.status }}</dd></div><div><dt>Credits</dt><dd>{{ formatNumber(selectedCode.creditAmount) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(selectedCode.redeemedCount) }} / {{ formatNumber(selectedCode.maxRedemptions) }}</dd></div><div><dt>Per user</dt><dd>{{ formatNumber(selectedCode.perUserLimit) }}</dd></div><div><dt>Reason</dt><dd>{{ selectedCode.reason || '-' }}</dd></div><div><dt>Expires</dt><dd>{{ formatDate(selectedCode.expiresAt) }}</dd></div></dl><button class="table-action" type="button" :disabled="pending === `status:${selectedCode.id}`" @click="setStatus(selectedCode)">{{ selectedCode.status === 'disabled' ? '启用' : '禁用' }}</button></InspectorPanel></template>
</OperationsWorkspace>
<BaseDialog :open="createOpen" title="创建兑换码" description="写入 user-billing authority" :close-on-backdrop="!pending" @close="createOpen = false"><div class="form-grid two-col"><label>名称<input v-model="form.displayName" class="input"></label><label>Credits<input v-model.number="form.creditAmount" class="input" type="number"></label><label>Reason<input v-model="form.reason" class="input"></label><label>Max redemptions<input v-model.number="form.maxRedemptions" class="input" type="number"></label><label>Per user<input v-model.number="form.perUserLimit" class="input" type="number"></label><label>Expires<input v-model="form.expiresAt" class="input" type="datetime-local"></label></div><p v-if="createdCode" class="muted-box">新兑换码:<code>{{ createdCode }}</code></p><template #footer><button class="table-action" type="button" @click="createOpen = false">关闭</button><button class="table-action" type="button" :disabled="pending === 'create'" @click="createCode">{{ pending === 'create' ? '创建中' : '创建' }}</button></template></BaseDialog>
<BaseDialog :open="createOpen" title="创建兑换码" description="完整 code 仅在本次创建响应中显示" :close-on-backdrop="!pending && !createdCode" @close="createdCode = null; createOpen = false"><div class="form-grid two-col"><label>名称<input v-model="form.displayName" class="input"></label><label>Credits<input v-model.number="form.creditAmount" class="input" type="number"></label><label>Reason<input v-model="form.reason" class="input"></label><label>Max redemptions<input v-model.number="form.maxRedemptions" class="input" type="number"></label><label>Per user<input v-model.number="form.perUserLimit" class="input" type="number"></label><label>Expires<input v-model="form.expiresAt" class="input" type="datetime-local"></label></div><p v-if="createdCode" class="created-redeem-code"><strong>立即保存完整兑换码</strong><code>{{ createdCode }}</code><small>仅此响应可见;关闭后无法再次读取完整值。</small></p><template #footer><button class="table-action" type="button" @click="createdCode = null; createOpen = false">{{ createdCode ? '已保存并关闭' : '取消' }}</button><button v-if="!createdCode" class="table-action" type="button" :disabled="pending === 'create'" @click="createCode">{{ pending === 'create' ? '创建中' : '创建' }}</button></template></BaseDialog>
</AsyncBoundary>
</section>
</template>
<style scoped>
.billing-operations { min-height: 620px; }.billing-main-stack { display: grid; 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; background: #d8e1e4; }.billing-summary-grid button { display: grid; 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; gap: 8px; margin-bottom: 12px; }.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; }
.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>
@@ -93,7 +93,7 @@ function displayDate(value: string): string { if (!value) return "-"; const date
</EntityIndex>
</template>
<div v-if="selected" class="operations-main-stack">
<div class="view-mode-row"><ViewModeSwitch v-model="viewMode" :options="[{ value: 'flow', label: '流向', icon: 'secrets' }, { value: 'list', label: '清单', icon: 'mdtodo' }]" /></div>
<div class="view-mode-row"><ViewModeSwitch v-model="viewMode" :options="[{ value: 'flow', label: '流向', icon: 'flow' }, { value: 'list', label: '清单', icon: 'list' }]" /></div>
<FactPath v-if="viewMode === 'flow'" :steps="factSteps" label="Secret 脱敏事实路径" />
<SectionFrame v-else title="库存清单" description="同一 ExternalSecret DTO 的紧凑字段视图。" :body-padding="false"><div class="secret-compact-list"><button v-for="row in filteredRows" :key="rowKey(row)" type="button" :data-selected="selected && rowKey(row) === rowKey(selected)" @click="openSecret(row)"><strong>{{ row.namespace }}/{{ row.name }}</strong><StatusBadge :status="row.status" :label="row.status" /><code>{{ row.storeKind }}/{{ row.storeName }}</code><code>{{ row.targetSecretName }}</code><span>{{ row.ageText }}</span></button></div></SectionFrame>
<SectionFrame title="ExternalSecret 事实" description="库存主面只保留可扫描的来源、目标、状态与 fingerprint 元数据。">
+156 -28
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
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 AuditTimeline from "@/components/console/AuditTimeline.vue";
import Pagination from "@/components/common/Pagination.vue";
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
import EntityIndex from "@/components/console/EntityIndex.vue";
import InspectorPanel from "@/components/console/InspectorPanel.vue";
@@ -15,7 +18,7 @@ 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 { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AdminUsageRow, AuthUser, BillingPlanDetail, UsageLedgerRow } from "@/types";
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AdminUsageRow, ApiKeyRecord, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, UsageLedgerRow, UsageReservationRow } from "@/types";
const route = useRoute();
const router = useRouter();
@@ -30,60 +33,185 @@ 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">("ledger");
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 filters = reactive({ search: typeof route.query.q === "string" ? route.query.q : "", status: typeof route.query.filter === "string" ? route.query.filter : "", role: typeof route.query.role === "string" ? route.query.role : "" });
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: "" });
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", 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 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([loadUsers(), loadPlans(), loadAudit()]); if (selectedUserId.value) await selectUser(selectedUserId.value, false); });
watch(() => route.params.userId, (value) => { const userId = typeof value === "string" ? value : ""; if (userId) void selectUser(userId, false); else { selectedUserId.value = null; detail.value = null; } });
async function loadUsers(): Promise<void> { loading.value = true; const response = await usageAPI.adminUsers({ page: 1, pageSize: 100, search: filters.search.trim(), status: filters.status, role: filters.role }); if (!response.ok) error.value = response.error ?? `HTTP ${response.status}`; else { users.value = response.data?.users ?? []; total.value = Number(response.data?.total ?? 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> { await router.push({ query: { ...route.query, q: filters.search || undefined, filter: filters.status || undefined, role: filters.role || undefined } }); await loadUsers(); }
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) { selectedUserId.value = null; detail.value = null; }
});
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 selectUser(userId: string | undefined, updateRoute = true): Promise<void> { if (!userId) return; if (updateRoute) await router.push({ name: "UserDetail", params: { userId }, query: route.query }); selectedUserId.value = userId; detailLoading.value = true; const response = await usageAPI.adminUser(userId); if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { detail.value = response.data?.detail ?? null; fillEditForm(detail.value?.summary?.user ?? null); } detailLoading.value = false; }
async function loadUsers(updateRoute = true): Promise<void> {
if (updateRoute) await syncUserQuery();
loading.value = true;
const response = await usageAPI.adminUsers({ page: page.value, pageSize, search: filters.search.trim(), status: filters.status, role: filters.role });
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;
if (updateRoute && route.params.userId !== userId) await router.push({ name: "UserDetail", params: { userId }, query: route.query });
selectedUserId.value = userId; detailLoading.value = true; mutationError.value = null;
const response = await usageAPI.adminUser(userId);
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 { selectedUserId.value = null; detail.value = null; void router.push({ name: "Users", query: route.query }); }
function fillEditForm(user: (AuthUser & { email?: string }) | null): 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"; const response = await usageAPI.createAdminUser({ ...createForm, initialCredits: Number(createForm.initialCredits) }); pending.value = null; if (!response.ok) { mutationError.value = response.error ?? `HTTP ${response.status}`; return; } createOpen.value = false; await loadUsers(); if (response.data?.user?.id) await selectUser(response.data.user.id); }
async function saveUser(): Promise<void> { if (!selectedUser.value?.id) return; pending.value = "save"; const body: Record<string, unknown> = { email: editForm.email, username: editForm.username, displayName: editForm.displayName, role: editForm.role, status: editForm.status, planId: editForm.planId }; if (editForm.password) body.password = editForm.password; const response = await usageAPI.updateAdminUser(selectedUser.value.id, body); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await loadUsers(); await selectUser(selectedUser.value.id, 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}`; const response = await usageAPI.updateAdminUserStatus(userId, status); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else await loadUsers(); }
async function adjustCredits(): Promise<void> { if (!selectedUser.value?.id) return; pending.value = "credits"; const response = await usageAPI.adjustAdminCredits(selectedUser.value.id, { deltaCredits: Number(creditForm.deltaCredits), reason: creditForm.reason, idempotencyKey: creditForm.idempotencyKey || undefined }); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await selectUser(selectedUser.value.id, false); await loadAudit(); } }
async function loadAudit(): Promise<void> { auditLoading.value = true; const response = auditTab.value === "usage" ? await usageAPI.adminUsage({ page: 1, pageSize: 100 }) : await usageAPI.adminLedger({ page: 1, pageSize: 100 }); if (response.ok) { if (auditTab.value === "usage") auditUsageRows.value = response.data?.rows ?? []; else auditLedgerRows.value = response.data?.rows ?? []; } auditLoading.value = false; }
function userKey(item: AdminBillingUserSummary): string { return item.user?.id ?? item.user?.username ?? "unknown"; }
function auditKey(item: AdminUsageRow | UsageLedgerRow): string { return item.id; }
function formatNumber(value: unknown): string { return new Intl.NumberFormat("zh-CN").format(Number(value ?? 0)); }
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> { if (updateRoute) await syncAuditQuery(); auditLoading.value = true; auditError.value = null; const response = auditTab.value === "usage" ? await usageAPI.adminUsage(auditParams()) : await usageAPI.adminLedger(auditParams()); 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(" · ") || "-"; }
</script>
<template>
<section class="route-stack user-operations-page">
<PageCommandBar eyebrow="Admin" title="身份运营工作区" description="用户集合为主面;资料、plan、API key、reservation 与 ledger 渐进进入 Inspector。"><template #actions><ViewModeSwitch v-model="viewMode" :options="[{ value: 'list', label: '紧凑列表', icon: 'mdtodo' }, { value: 'grid', label: '实体网格', icon: 'users' }]" /><button class="table-action" type="button" @click="createOpen = true">创建用户</button></template></PageCommandBar>
<AsyncBoundary :state="asyncState" :has-content="Boolean(users.length)" label="加载身份运营" @retry="loadUsers"><template #error><EmptyState title="身份运营加载失败" :description="error || '请求未完成'" action-label="重试" @action="loadUsers" /></template>
<PageCommandBar eyebrow="Admin" title="身份运营工作区" description="用户索引、身份主面与账务 Inspector 共享同一服务端分页数据集。">
<template #actions><ViewModeSwitch v-model="viewMode" :options="[{ value: 'list', label: '紧凑列表', icon: 'list' }, { value: 'grid', label: '实体网格', icon: 'grid' }]" /><button class="table-action" type="button" @click="createOpen = true">创建用户</button></template>
</PageCommandBar>
<AsyncBoundary :state="asyncState" :has-content="Boolean(users.length)" label="加载身份运营" @retry="loadUsers">
<template #error><EmptyState title="身份运营加载失败" :description="error || '请求未完成'" action-label="重试" @action="loadUsers" /></template>
<StatusStrip :items="statusItems" />
<OperationsWorkspace class="user-operations" label="用户身份运营" :inspector-open="Boolean(selectedUser)">
<template #index><EntityIndex :items="users" :item-key="userKey" :selected-id="selectedUserId" label="用户索引" @select="(item) => selectUser(item.user?.id)"><template #toolbar><input v-model="filters.search" class="input" placeholder="用户名、邮箱或 user id" @keyup.enter="applyFilters"><div class="user-filter-row"><select v-model="filters.status" class="input" @change="applyFilters"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select><select v-model="filters.role" class="input" @change="applyFilters"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select></div></template><template #item="{ item }"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId || 'default' }}</small><span class="entity-index-meta"><span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ formatNumber(item.credits?.available) }} cr</code></span></template></EntityIndex></template>
<div class="user-main-stack"><SectionFrame :title="viewMode === 'grid' ? '实体网格' : '身份清单'" description="两种视图消费同一 AdminBillingUserSummary 集合与稳定 user id。" :body-padding="false"><div v-if="viewMode === 'grid'" class="user-entity-grid"><button v-for="item in users" :key="userKey(item)" type="button" @click="selectUser(item.user?.id)"><ConsoleIcon name="users" :size="20" /><strong>{{ item.user?.displayName || item.user?.username }}</strong><small>{{ item.user?.email || item.user?.id }}</small><span class="status-pill" :data-status="item.user?.status">{{ item.user?.status }}</span><dl><div><dt>Available</dt><dd>{{ formatNumber(item.credits?.available) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(item.usage?.totalCredits) }}</dd></div><div><dt>Keys</dt><dd>{{ formatNumber(item.apiKeys?.activeCount) }}</dd></div></dl></button></div><div v-else class="user-compact-list"><button v-for="item in users" :key="userKey(item)" type="button" @click="selectUser(item.user?.id)"><strong>{{ item.user?.displayName || item.user?.username }}</strong><span>{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ item.planId || 'default' }}</code><code>{{ formatNumber(item.credits?.available) }} available</code><code>{{ formatNumber(item.usage?.totalCredits) }} usage</code><button class="table-action" type="button" :disabled="pending === `status:${item.user?.id}`" @click.stop="setUserStatus(item)">{{ item.user?.status === 'disabled' ? '启用' : '禁用' }}</button></button></div></SectionFrame>
<SectionFrame title="全局审计" description="不再与用户集合纵向堆成超长页;按需切换 usage / ledger"><template #actions><button class="table-action" type="button" :aria-pressed="auditTab === 'usage'" @click="auditTab = 'usage'; loadAudit()">Usage</button><button class="table-action" type="button" :aria-pressed="auditTab === 'ledger'" @click="auditTab = 'ledger'; loadAudit()">Ledger</button></template><AuditTimeline v-if="auditTab === 'usage'" :items="auditUsageRows" :item-key="auditKey"><template #item="{ item }"><strong>{{ item.serviceId }} · {{ item.userId }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.status }} · {{ item.id }}</small></template></AuditTimeline><AuditTimeline v-else :items="auditLedgerRows" :item-key="auditKey"><template #item="{ item }"><strong>{{ item.kind }} · {{ item.userId }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.status }} · {{ item.id }}</small></template></AuditTimeline><p v-if="auditLoading" class="muted-box">审计加载中</p></SectionFrame></div>
<template #inspector><InspectorPanel v-if="selectedUser" :title="selectedUser.displayName || selectedUser.username || selectedUser.id || '未命名用户'" :subtitle="selectedUser.id" @close="closeInspector"><template #tabs><button type="button" :aria-selected="detailTab === 'profile'" @click="detailTab = 'profile'">资料</button><button type="button" :aria-selected="detailTab === 'credits'" @click="detailTab = 'credits'">Credits</button><button type="button" :aria-selected="detailTab === 'ledger'" @click="detailTab = 'ledger'">Ledger</button></template><p v-if="mutationError" class="form-error">{{ mutationError }}</p><div v-if="detailTab === 'profile'" class="form-grid"><label>邮箱<input v-model="editForm.email" class="input"></label><label>用户名<input v-model="editForm.username" class="input"></label><label>显示名<input v-model="editForm.displayName" class="input"></label><label>角色<select v-model="editForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="editForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label><label>Plan<select v-model="editForm.planId" class="input"><option v-for="item in plans" :key="item.plan?.id" :value="item.plan?.id">{{ item.plan?.displayName || item.plan?.id }}</option></select></label><button class="table-action" type="button" :disabled="pending === 'save'" @click="saveUser">保存资料</button></div><div v-else-if="detailTab === 'credits'" class="form-grid"><p class="muted-box">Available {{ formatNumber(selectedSummary?.credits?.available) }} · Reserved {{ formatNumber(selectedSummary?.credits?.reserved) }}</p><label>Delta<input v-model.number="creditForm.deltaCredits" class="input" type="number"></label><label>Reason<input v-model="creditForm.reason" class="input"></label><label>Idempotency key<input v-model="creditForm.idempotencyKey" class="input"></label><button class="table-action" type="button" :disabled="pending === 'credits'" @click="adjustCredits">调整 Credits</button></div><AuditTimeline v-else :items="ledgerRows" :item-key="auditKey"><template #item="{ item }"><strong>{{ item.kind }} · {{ item.deltaCredits }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.reason }} · {{ item.id }}</small></template></AuditTimeline></InspectorPanel></template>
<p v-if="mutationError" class="form-error">{{ mutationError }}</p>
<OperationsWorkspace label="用户身份运营" :inspector-open="Boolean(selectedUserId)">
<template #index>
<EntityIndex :items="users" :item-key="userKey" :selected-id="selectedUserId" label="用户索引" @select="(item) => selectUser(item.user?.id)">
<template #toolbar><div class="user-index-filters"><input v-model="filters.search" class="input" aria-label="搜索用户" placeholder="用户名、邮箱或 user id" @keyup.enter="applyFilters"><select v-model="filters.status" class="input" aria-label="用户状态" @change="applyFilters"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select><select v-model="filters.role" class="input" aria-label="用户角色" @change="applyFilters"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select></div></template>
<template #item="{ item }"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId || 'default' }}</small><span class="entity-index-meta"><span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ formatNumber(item.credits?.available) }} cr</code></span></template>
<template #footer><Pagination :page="page" :page-size="pageSize" :total="total" @update:page="setPage" /></template>
</EntityIndex>
</template>
<div class="user-main-stack">
<SectionFrame :title="viewMode === 'grid' ? '用户实体网格' : '用户紧凑列表'" description="选择动作与状态写操作使用同级控件,避免交互元素嵌套。" :body-padding="false">
<div v-if="viewMode === 'grid'" class="user-entity-grid">
<article v-for="item in users" :key="userKey(item)" class="user-entity-card" :data-selected="userKey(item) === selectedUserId">
<button class="user-entity-select" type="button" @click="selectUser(item.user?.id)"><ConsoleIcon name="users" :size="20" /><strong>{{ item.user?.displayName || item.user?.username }}</strong><small>{{ item.user?.email || item.user?.id }}</small><dl><div><dt>Available</dt><dd>{{ formatNumber(item.credits?.available) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(item.usage?.totalCredits) }}</dd></div><div><dt>Keys</dt><dd>{{ formatNumber(item.apiKeys?.activeCount) }}</dd></div></dl></button>
<button class="table-action" type="button" :disabled="pending === `status:${item.user?.id}`" @click="setUserStatus(item)">{{ item.user?.status === 'disabled' ? '启用' : '禁用' }}</button>
</article>
</div>
<DataGrid v-else :columns="userColumns" :rows="users" :row-key="userKey" :selected-id="selectedUserId" aria-label="用户列表" @select="(item) => selectUser(item.user?.id)">
<template #cell-user="{ row }"><strong>{{ row.user?.displayName || row.user?.username }}</strong><small>{{ row.user?.email || row.user?.id }} · {{ row.planId || 'default' }}</small></template>
<template #cell-status="{ row }"><span class="status-pill" :data-status="row.user?.status">{{ row.user?.role }} / {{ row.user?.status }}</span></template>
<template #cell-credits="{ row }">{{ formatNumber(row.credits?.available) }} / {{ formatNumber(row.credits?.reserved) }}</template>
<template #cell-usage="{ row }">{{ formatNumber(row.usage?.totalCredits) }}</template>
<template #cell-keys="{ row }">{{ formatNumber(row.apiKeys?.activeCount) }} / {{ formatNumber(row.apiKeys?.totalCount) }}</template>
<template #cell-actions="{ row }"><button class="table-action" type="button" :disabled="pending === `status:${row.user?.id}`" @click.stop="setUserStatus(row)">{{ row.user?.status === 'disabled' ? '启用' : '禁用' }}</button></template>
</DataGrid>
</SectionFrame>
<SectionFrame title="全局 Usage / Ledger" description="保留旧页全部筛选、服务端分页、错误/partial 状态与 CSV export。" :body-padding="false">
<div class="audit-command-row"><div role="tablist" aria-label="运营审计数据"><button type="button" role="tab" :aria-selected="auditTab === 'usage'" @click="setAuditTab('usage')">Usage</button><button type="button" role="tab" :aria-selected="auditTab === 'ledger'" @click="setAuditTab('ledger')">Ledger</button></div><button class="table-action" type="button" @click="downloadAudit">CSV / Export</button></div>
<div class="audit-filter-grid"><input v-model="auditFilters.search" class="input" placeholder="搜索记录或原因" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.userId" class="input" placeholder="user id" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'usage'" v-model="auditFilters.serviceId" class="input" placeholder="service" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'usage'" v-model="auditFilters.resourceType" class="input" placeholder="resource" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'usage'" v-model="auditFilters.apiKeyId" class="input" placeholder="api key id" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'ledger'" v-model="auditFilters.kind" class="input" placeholder="kind" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'ledger'" v-model="auditFilters.source" class="input" placeholder="source" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.status" class="input" placeholder="status" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.idempotencyKey" class="input" placeholder="idempotency key" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.from" class="input" type="datetime-local" @change="() => loadAudit()"><input v-model="auditFilters.to" class="input" type="datetime-local" @change="() => loadAudit()"><button class="table-action" type="button" @click="auditPage = 1; loadAudit()">应用筛选</button></div>
<p v-if="auditError" class="form-error">{{ auditError }}</p><p v-if="auditLoading" class="muted-box">审计加载中</p>
<DataGrid v-else-if="auditTab === 'usage'" :columns="usageColumns" :rows="auditUsageRows" :row-key="auditKey" :interactive="false" aria-label="全局 Usage">
<template #cell-time="{ row }">{{ formatDate(row.createdAt) }}</template><template #cell-user="{ row }"><code>{{ row.userId }}</code><small>{{ row.username || row.email }}</small></template><template #cell-service="{ row }"><code>{{ row.serviceId }}</code></template><template #cell-resource="{ row }">{{ row.resourceType || row.id }}</template><template #cell-credits="{ row }">{{ formatNumber(row.credits) }}</template><template #cell-status="{ row }">{{ row.status }}</template><template #cell-metadata="{ row }">{{ metadataPreview(row.metadata) }}</template>
</DataGrid>
<DataGrid v-else :columns="ledgerColumns" :rows="auditLedgerRows" :row-key="auditKey" :interactive="false" aria-label="全局 Ledger">
<template #cell-time="{ row }">{{ formatDate(row.createdAt) }}</template><template #cell-user="{ row }"><code>{{ row.userId }}</code><small>{{ row.username || row.email }}</small></template><template #cell-action="{ row }">{{ row.kind }} · {{ row.reason }}</template><template #cell-delta="{ row }">{{ formatSigned(row.deltaCredits) }}</template><template #cell-balance="{ row }">{{ formatNumber(row.balanceBefore) }} {{ formatNumber(row.balanceAfter) }}</template><template #cell-operator="{ row }">{{ row.operatorUsername || row.operatorUserId || row.source }}</template><template #cell-idempotency="{ row }"><code>{{ row.idempotencyKey || '-' }}</code></template>
</DataGrid>
<Pagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @update:page="setAuditPage" />
</SectionFrame>
</div>
<template #inspector>
<InspectorPanel v-if="selectedUserId" :title="selectedUser?.displayName || selectedUser?.username || selectedUserId" :subtitle="selectedUserId" @close="closeInspector">
<template #tabs><button v-for="tab in ['profile', 'credits', 'entitlements', 'keys', 'reservations', 'ledger']" :key="tab" type="button" role="tab" :aria-selected="detailTab === tab" @click="detailTab = tab">{{ tab }}</button></template>
<p v-if="detailLoading" class="muted-box">详情加载中…</p><p v-else-if="mutationError" class="form-error">{{ mutationError }}</p>
<div v-if="!detailLoading && detailTab === 'profile'" class="form-grid"><label>邮箱<input v-model="editForm.email" class="input"></label><label>用户名<input v-model="editForm.username" class="input"></label><label>显示名<input v-model="editForm.displayName" class="input"></label><label>新密码<input v-model="editForm.password" class="input" type="password"></label><label>角色<select v-model="editForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="editForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label><label>Plan<select v-model="editForm.planId" class="input"><option v-for="item in plans" :key="item.plan?.id" :value="item.plan?.id">{{ item.plan?.displayName || item.plan?.id }}</option></select></label><button class="table-action" type="button" :disabled="pending === `save:${selectedUserId}`" @click="saveUser">保存资料</button></div>
<div v-else-if="detailTab === 'credits'" class="form-grid"><p class="muted-box">Available {{ formatNumber(selectedSummary?.credits?.available) }} · Reserved {{ formatNumber(selectedSummary?.credits?.reserved) }}</p><label>Delta<input v-model.number="creditForm.deltaCredits" class="input" type="number"></label><label>Reason<input v-model="creditForm.reason" class="input"></label><label>Idempotency key<input v-model="creditForm.idempotencyKey" class="input"></label><button class="table-action" type="button" :disabled="pending === `credits:${selectedUserId}`" @click="adjustCredits">调整 Credits</button></div>
<div v-else-if="detailTab === 'entitlements'" class="inspector-fact-list"><article v-for="item in selectedEntitlements" :key="item.id || item.resourceType"><strong>{{ item.resourceType }}</strong><small>{{ item.enabled === false ? 'disabled' : 'enabled' }} · monthly {{ formatNumber(item.monthlyUsageCredits) }} / {{ item.monthlyQuotaUnlimited ? 'unlimited' : formatNumber(item.monthlyQuotaCredits) }}</small><small>concurrent {{ formatNumber(item.activeReservations) }} / {{ item.concurrencyUnlimited ? 'unlimited' : formatNumber(item.maxConcurrentReservations) }} · RPM {{ formatNumber(item.rpmUsed) }} / {{ item.rpmUnlimited ? 'unlimited' : formatNumber(item.rpmLimit) }}</small></article><p v-if="!selectedEntitlements.length" class="muted-box">暂无 entitlement</p></div>
<div v-else-if="detailTab === 'keys'" class="inspector-fact-list"><article v-for="item in apiKeys" :key="item.id"><strong>{{ item.name || item.id }}</strong><small>{{ item.prefix || '-' }} · {{ item.revokedAt ? 'revoked' : 'active' }}</small><small>created {{ formatDate(item.createdAt) }} · last used {{ formatDate(item.lastUsedAt) }}</small></article><p v-if="!apiKeys.length" class="muted-box">暂无 API key</p></div>
<div v-else-if="detailTab === 'reservations'" class="inspector-fact-list"><article v-for="item in reservations" :key="item.reservationId"><strong>{{ item.reservationId }}</strong><small>{{ item.serviceId }} · {{ item.resourceType || 'generic' }} · {{ item.status }}</small><small>{{ formatNumber(item.estimatedCredits) }} credits · expires {{ formatDate(item.expiresAt) }}</small></article><p v-if="!reservations.length" class="muted-box">没有 active reservation</p></div>
<div v-else class="inspector-fact-list"><article v-for="item in ledgerRows" :key="auditKey(item)"><strong>{{ item.kind }} · {{ formatSigned(item.deltaCredits) }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.reason }} · {{ item.status }}</small><small>{{ formatNumber(item.balanceBefore) }} → {{ formatNumber(item.balanceAfter) }} · {{ item.idempotencyKey || '-' }}</small></article><p v-if="!ledgerRows.length" class="muted-box">暂无用户 ledger</p></div>
</InspectorPanel>
</template>
</OperationsWorkspace>
<BaseDrawer :open="createOpen" title="创建用户" description="写入 user-billing authority" :busy="pending === 'create'" @close="createOpen = false"><div class="form-grid two-col"><label>邮箱<input v-model="createForm.email" class="input" type="email"></label><label>用户名<input v-model="createForm.username" class="input"></label><label>显示名<input v-model="createForm.displayName" class="input"></label><label>初始 credits<input v-model.number="createForm.initialCredits" class="input" type="number"></label><label>角色<select v-model="createForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="createForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label><label>初始密码<input v-model="createForm.password" class="input" type="password"></label></div><template #footer><button class="table-action" type="button" @click="createOpen = false">取消</button><button class="table-action" type="button" :disabled="pending === 'create'" @click="createUser">创建</button></template></BaseDrawer>
<BaseDrawer :open="createOpen" title="创建用户" description="写入 user-billing authority" :busy="pending === 'create'" @close="createOpen = false"><div class="form-grid two-col"><label>邮箱<input v-model="createForm.email" class="input" type="email"></label><label>用户名<input v-model="createForm.username" class="input"></label><label>显示名<input v-model="createForm.displayName" class="input"></label><label>密码<input v-model="createForm.password" class="input" type="password"></label><label>初始 credits<input v-model.number="createForm.initialCredits" class="input" type="number"></label><label>角色<select v-model="createForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="createForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label></div><template #footer><button class="table-action" type="button" @click="createOpen = false">取消</button><button class="btn btn-primary" type="button" :disabled="pending === 'create'" @click="createUser">创建</button></template></BaseDrawer>
</AsyncBoundary>
</section>
</template>
<style scoped>
.user-operations { min-height: 650px; }.user-filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }.entity-index-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 4px; }.user-main-stack { display: grid; gap: 14px; }.user-entity-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap: 1px; background: #d8e1e4; }.user-entity-grid > button { display: grid; grid-template-columns: auto 1fr auto; gap: 7px; align-items: center; border: 0; padding: 12px; background: white; color: inherit; text-align: left; }.user-entity-grid small, .user-entity-grid dl { grid-column: 1/-1; }.user-entity-grid dl { display: grid; grid-template-columns: repeat(3,1fr); margin: 0; }.user-entity-grid dt { color: #64757d; font-size: 9px; }.user-entity-grid dd { margin: 3px 0 0; }.user-compact-list { overflow: auto; }.user-compact-list > button { display: grid; grid-template-columns: minmax(160px,1.3fr) 110px 100px 120px 110px 64px; gap: 8px; width: 100%; min-width: 760px; align-items: center; border: 0; border-bottom: 1px solid #d8e1e4; padding: 9px 11px; background: white; color: inherit; text-align: left; }.user-compact-list > button:hover { background: #e8f6f5; }
.user-operations-page,.user-main-stack { min-width: 0; }
.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) { .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>