feat(web): establish admin operations workspace
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
||||
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import FactPath from "@/components/console/FactPath.vue";
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
import SectionFrame from "@/components/layout/SectionFrame.vue";
|
||||
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
||||
import { useConsoleViewState } from "@/composables/useConsoleViewState";
|
||||
import { useAccessStore } from "@/stores/access";
|
||||
import { accessUserLabel, type AccessUserRow } from "@/stores/admin-access-view";
|
||||
@@ -18,34 +18,27 @@ import type { AccessUserRole, AccessUserStatus } from "@/types";
|
||||
const access = useAccessStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const viewState = useConsoleViewState();
|
||||
const columns: DataGridColumn[] = [
|
||||
{ key: "user", label: "用户" },
|
||||
{ key: "role", label: "角色" },
|
||||
{ key: "status", label: "状态" },
|
||||
{ key: "tools", label: "工具" },
|
||||
{ key: "tuples", label: "Tuples", align: "right" },
|
||||
{ key: "actions", label: "操作", align: "right" }
|
||||
];
|
||||
|
||||
const viewState = useConsoleViewState<"matrix" | "list">({ view: "matrix", viewValues: ["matrix", "list"] });
|
||||
const openfga = computed(() => access.summaryView.openfga);
|
||||
const query = computed({
|
||||
get: () => viewState.q.value,
|
||||
set: (value: string) => void viewState.update({ q: value.trim() })
|
||||
});
|
||||
const viewMode = computed<"cards" | "list">({
|
||||
get: () => viewState.view.value,
|
||||
set: (value) => void viewState.update({ view: value === "cards" ? undefined : value })
|
||||
});
|
||||
const query = computed({ get: () => viewState.q.value, set: (value: string) => void viewState.update({ q: value.trim() }) });
|
||||
const viewMode = computed({ get: () => viewState.view.value, set: (value: "matrix" | "list") => void viewState.update({ view: value === "matrix" ? undefined : value }, false) });
|
||||
const filteredRows = computed(() => {
|
||||
const needle = query.value.trim().toLowerCase();
|
||||
const needle = query.value.toLowerCase();
|
||||
return access.userRows.filter((row) => !needle || [row.id, row.username, row.displayName, row.role, row.status, ...Object.keys(row.tools)].filter(Boolean).join(" ").toLowerCase().includes(needle));
|
||||
});
|
||||
const asyncState = computed(() => {
|
||||
if (access.loading) return access.userRows.length ? "refreshing" : "initial-loading";
|
||||
if (access.error) return access.userRows.length ? "partial" : "error";
|
||||
return access.users ? "ready" : "empty";
|
||||
});
|
||||
const asyncState = computed(() => access.loading ? (access.userRows.length ? "refreshing" : "initial-loading") : access.error ? (access.userRows.length ? "partial" : "error") : access.users ? "ready" : "empty");
|
||||
const statusItems = computed(() => [
|
||||
{ id: "users", label: "Users", value: access.summaryView.users },
|
||||
{ id: "tuples", label: "Tuples", value: access.summaryView.tuples },
|
||||
{ id: "grants", label: "Tool grants", value: access.summaryView.toolTuples },
|
||||
{ id: "openfga", label: "OpenFGA", value: valueText(openfga.value.status || (openfga.value.ready ? "ready" : "not ready")), tone: openfga.value.ready ? "ok" as const : "warn" as const }
|
||||
]);
|
||||
const factSteps = computed(() => [
|
||||
{ id: "identity", label: "Identity", value: access.selectedUser ? accessUserLabel(access.selectedUser) : "选择用户" },
|
||||
{ id: "role", label: "Role", value: access.selectedUser?.role || "-" },
|
||||
{ id: "tools", label: "Tool grants", value: access.selectedUser ? `${access.selectedUser.toolCount}/${Object.keys(access.selectedUser.tools).length}` : "-" },
|
||||
{ id: "tuples", label: "OpenFGA tuples", value: String(access.detailView.tuples.length), tone: openfga.value.ready ? "ok" as const : "warn" as const }
|
||||
]);
|
||||
|
||||
onMounted(async () => {
|
||||
await access.refresh();
|
||||
@@ -61,210 +54,92 @@ function selectRow(row: AccessUserRow): void {
|
||||
if (route.params.userId !== row.id) void router.push({ name: "AccessUserDetail", params: { userId: row.id }, query: route.query });
|
||||
void access.selectUser(row.id);
|
||||
}
|
||||
|
||||
function onRoleChange(event: Event): void {
|
||||
const role = (event.target as HTMLSelectElement | null)?.value as AccessUserRole | undefined;
|
||||
if (role) void access.updateSelectedUser({ role });
|
||||
}
|
||||
|
||||
function onStatusChange(event: Event): void {
|
||||
const status = (event.target as HTMLSelectElement | null)?.value as AccessUserStatus | undefined;
|
||||
if (status) void access.updateSelectedUser({ status });
|
||||
}
|
||||
|
||||
function onToolChange(toolId: string, event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement | null)?.checked === true;
|
||||
void access.setTool(toolId, checked);
|
||||
}
|
||||
|
||||
function rowKey(row: AccessUserRow): string {
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function valueText(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "yes" : "no";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
return "-";
|
||||
}
|
||||
|
||||
function closeInspector(): void { void router.push({ name: "Access", query: route.query }); }
|
||||
function onRoleChange(event: Event): void { const role = (event.target as HTMLSelectElement).value as AccessUserRole; void access.updateSelectedUser({ role }); }
|
||||
function onStatusChange(event: Event): void { const status = (event.target as HTMLSelectElement).value as AccessUserStatus; void access.updateSelectedUser({ status }); }
|
||||
function onToolChange(toolId: string, event: Event): void { void access.setTool(toolId, (event.target as HTMLInputElement).checked); }
|
||||
function valueText(value: unknown): string { return typeof value === "string" && value.trim() ? value : typeof value === "number" ? String(value) : typeof value === "boolean" ? (value ? "yes" : "no") : "-"; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack access-admin-page">
|
||||
<AsyncBoundary :state="asyncState" :has-content="Boolean(access.userRows.length)" label="加载 Access matrix" empty-title="暂无 Access 用户" empty-description="同源 Access API 当前未返回用户。" @retry="access.refresh">
|
||||
<template #error><EmptyState title="Access 加载失败" :description="access.error || '请求未完成'" action-label="重试" @action="access.refresh" /></template>
|
||||
<div class="overview-grid access-summary-grid">
|
||||
<article class="metric-card">
|
||||
<span>Users</span>
|
||||
<strong>{{ access.summaryView.users }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Tuples</span>
|
||||
<strong>{{ access.summaryView.tuples }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Tool grants</span>
|
||||
<strong>{{ access.summaryView.toolTuples }}</strong>
|
||||
</article>
|
||||
<AsyncBoundary :state="asyncState" :has-content="Boolean(access.userRows.length)" label="加载授权工作区" empty-title="暂无授权用户" empty-description="同源 Access API 当前未返回用户。" @retry="access.refresh">
|
||||
<template #error><EmptyState title="授权工作区加载失败" :description="access.error || '请求未完成'" action-label="重试" @action="access.refresh" /></template>
|
||||
<StatusStrip :items="statusItems" live="polite" />
|
||||
<OperationsWorkspace class="access-operations" label="授权运营工作区" :inspector-open="Boolean(access.selectedUser)">
|
||||
<template #index>
|
||||
<EntityIndex :items="filteredRows" :item-key="(row) => row.id" :selected-id="access.selectedUserId" label="授权用户索引" empty-text="没有匹配用户。" @select="selectRow">
|
||||
<template #toolbar>
|
||||
<label class="operations-search"><span>用户索引</span><input v-model="query" type="search" placeholder="用户、角色、状态或 tool" /></label>
|
||||
<button class="table-action" type="button" :disabled="access.loading" @click="access.refresh">{{ access.loading ? "刷新中" : "刷新" }}</button>
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<strong>{{ accessUserLabel(item) }}</strong>
|
||||
<small>{{ item.username || item.id }}</small>
|
||||
<span class="entity-index-meta"><span class="status-pill" :data-status="item.status">{{ item.role }} / {{ item.status }}</span><code>{{ item.toolCount }} tools · {{ item.tupleCount }} tuples</code></span>
|
||||
</template>
|
||||
</EntityIndex>
|
||||
</template>
|
||||
|
||||
<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="用户权限矩阵">
|
||||
<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>
|
||||
<span v-for="tool in access.toolRows" :key="tool.id" role="gridcell" :data-allowed="row.tools[tool.id] ? 'true' : 'false'">{{ row.tools[tool.id] ? "允许" : "—" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="access-compact-list" role="list"><button v-for="row in filteredRows" :key="row.id" type="button" role="listitem" :data-selected="row.id === access.selectedUserId" @click="selectRow(row)"><span><strong>{{ accessUserLabel(row) }}</strong><small>{{ row.username || row.id }}</small></span><span class="status-pill" :data-status="row.status">{{ row.role }} / {{ row.status }}</span><code>{{ row.toolCount }} tools</code><code>{{ row.tupleCount }} tuples</code></button></div>
|
||||
</SectionFrame>
|
||||
<details class="technical-disclosure">
|
||||
<summary>OpenFGA 技术详情</summary>
|
||||
<dl class="field-grid"><div><dt>mode</dt><dd>{{ valueText(openfga.mode) }}</dd></div><div><dt>store</dt><dd>{{ valueText(openfga.storeName || openfga.storeId) }}</dd></div><div><dt>model</dt><dd>{{ valueText(openfga.modelId) }}</dd></div><div><dt>values</dt><dd>{{ openfga.valuesRedacted === false ? "visible" : "redacted" }}</dd></div></dl>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<section id="access-openfga-panel" class="data-panel admin-crud-panel openfga-panel">
|
||||
<header class="panel-header">
|
||||
<h2>OpenFGA</h2>
|
||||
<span class="status-pill" :data-status="valueText(openfga.status || (openfga.ready ? 'active' : 'disabled'))">{{ valueText(openfga.status || (openfga.ready ? 'ready' : 'not ready')) }}</span>
|
||||
</header>
|
||||
<dl class="field-grid">
|
||||
<div><dt>mode</dt><dd>{{ valueText(openfga.mode) }}</dd></div>
|
||||
<div><dt>store</dt><dd>{{ valueText(openfga.storeName || openfga.storeId) }}</dd></div>
|
||||
<div><dt>model</dt><dd>{{ valueText(openfga.modelId) }}</dd></div>
|
||||
<div><dt>values</dt><dd>{{ openfga.valuesRedacted === false ? "visible" : "redacted" }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<BoundedWorkspace class="access-workspace" :inspector-open="Boolean(access.selectedUser)" inspector-width="390px" label="Access 用户集合与详情">
|
||||
<TablePageLayout title="Access users" subtitle="Cloud API 同源 admin access matrix">
|
||||
<template #actions>
|
||||
<button id="access-refresh" class="table-action" type="button" :disabled="access.loading" @click="access.refresh">刷新</button>
|
||||
</template>
|
||||
<template #filters><div class="access-collection-controls"><label><span>搜索</span><input v-model="query" type="search" placeholder="用户、角色、状态或 tool" /></label><span class="access-view-toggle"><button type="button" :data-active="viewMode === 'cards'" @click="viewMode = 'cards'">卡片</button><button type="button" :data-active="viewMode === 'list'" @click="viewMode = 'list'">列表</button></span></div></template>
|
||||
<DataGrid v-if="viewMode === 'list'" :columns="columns" :rows="filteredRows" :row-key="rowKey" :selected-id="access.selectedUserId" empty-text="暂无用户记录。" @select="selectRow">
|
||||
<template #cell-user="{ row }">
|
||||
<button class="link-button" type="button" :data-selected="row.id === access.selectedUserId" @click="selectRow(row as AccessUserRow)">
|
||||
<strong>{{ accessUserLabel(row as AccessUserRow) }}</strong>
|
||||
<small>{{ (row as AccessUserRow).username || (row as AccessUserRow).id }}</small>
|
||||
</button>
|
||||
</template>
|
||||
<template #cell-role="{ row }">
|
||||
<span class="status-pill" :data-status="(row as AccessUserRow).role">{{ (row as AccessUserRow).role }}</span>
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<span class="status-pill" :data-status="(row as AccessUserRow).status">{{ (row as AccessUserRow).status }}</span>
|
||||
</template>
|
||||
<template #cell-tools="{ row }">
|
||||
{{ (row as AccessUserRow).toolCount }} / {{ Object.keys((row as AccessUserRow).tools).length }}
|
||||
</template>
|
||||
<template #cell-tuples="{ row }">
|
||||
{{ (row as AccessUserRow).tupleCount }}
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<button class="table-action" type="button" @click="selectRow(row as AccessUserRow)">详情</button>
|
||||
</template>
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="filteredRows.length" :items="filteredRows" :item-key="rowKey" view="cards" :selected-id="access.selectedUserId" :show-controls="false" aria-label="Access 用户卡片" @select="selectRow">
|
||||
<template #card="{ item: row }"><button class="access-user-card" :data-selected="row.id === access.selectedUserId" type="button" @click="selectRow(row)">
|
||||
<span class="access-user-glyph" aria-hidden="true">{{ row.role === 'admin' ? 'AD' : 'AC' }}</span>
|
||||
<div><strong>{{ accessUserLabel(row) }}</strong><small>{{ row.username || row.id }}</small></div>
|
||||
<span class="status-pill" :data-status="row.status">{{ row.role }} / {{ row.status }}</span>
|
||||
<dl><div><dt>Tools</dt><dd>{{ row.toolCount }} / {{ Object.keys(row.tools).length }}</dd></div><div><dt>Tuples</dt><dd>{{ row.tupleCount }}</dd></div></dl>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button></template>
|
||||
</ResourceCollection>
|
||||
<EmptyState v-else title="没有匹配用户" description="调整搜索;ACL 数据仍由同源 Access API 返回。" />
|
||||
</TablePageLayout>
|
||||
|
||||
<template #inspector>
|
||||
<section v-if="access.selectedUser" id="access-user-detail" class="access-detail-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2>Selected user</h2>
|
||||
<small>{{ access.selectedUser ? access.selectedUser.id : "未选择" }}</small>
|
||||
</div>
|
||||
<span v-if="access.detailLoading" class="status-pill">loading</span>
|
||||
</header>
|
||||
<p v-if="access.mutationError" class="form-error">{{ access.mutationError }}</p>
|
||||
<EmptyState v-if="!access.selectedUser" title="请选择用户" description="从 Access users 表格选择一个用户以编辑 role/status 和 tool matrix。" />
|
||||
<template v-else>
|
||||
<div class="access-user-head">
|
||||
<strong>{{ accessUserLabel(access.selectedUser) }}</strong>
|
||||
<small>{{ access.selectedUser.username || access.selectedUser.id }}</small>
|
||||
</div>
|
||||
<InspectorPanel v-if="access.selectedUser" :title="accessUserLabel(access.selectedUser)" :subtitle="access.selectedUser.id" @close="closeInspector">
|
||||
<p v-if="access.mutationError" class="form-error">{{ access.mutationError }}</p>
|
||||
<div class="form-grid compact-form-grid">
|
||||
<label>
|
||||
<span>Role</span>
|
||||
<select id="access-role-select" :value="access.selectedUser.role" :disabled="access.savingKey !== null" @change="onRoleChange">
|
||||
<option value="admin">admin</option>
|
||||
<option value="member">member</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select id="access-status-select" :value="access.selectedUser.status" :disabled="access.savingKey !== null" @change="onStatusChange">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="pending">pending</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Role</span><select :value="access.selectedUser.role" :disabled="access.savingKey !== null" @change="onRoleChange"><option value="admin">admin</option><option value="member">member</option><option value="viewer">viewer</option></select></label>
|
||||
<label><span>Status</span><select :value="access.selectedUser.status" :disabled="access.savingKey !== null" @change="onStatusChange"><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select></label>
|
||||
</div>
|
||||
<div class="tool-toggle-grid">
|
||||
<label v-for="tool in access.toolRows" :key="tool.id" class="tool-toggle-row" data-access-tool-toggle>
|
||||
<span>
|
||||
<strong>{{ tool.label }}</strong>
|
||||
<small>{{ tool.id }}</small>
|
||||
</span>
|
||||
<input type="checkbox" :checked="tool.allowed" :disabled="access.savingKey !== null" @change="onToolChange(tool.id, $event)">
|
||||
</label>
|
||||
</div>
|
||||
<div class="tuple-list">
|
||||
<h3>Tuples</h3>
|
||||
<p v-if="!access.detailView.tuples.length" class="muted-box">暂无 tuple。</p>
|
||||
<ul v-else>
|
||||
<li v-for="tuple in access.detailView.tuples" :key="String(tuple.object || tuple.relation || tuple.userId)">
|
||||
<code>{{ valueText(tuple.userId) }}</code>
|
||||
<span>{{ valueText(tuple.relation) }}</span>
|
||||
<code>{{ valueText(tuple.object) }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<section><h3>Tool grants</h3><div class="tool-toggle-grid"><label v-for="tool in access.toolRows" :key="tool.id" class="tool-toggle-row"><span><strong>{{ tool.label }}</strong><small>{{ tool.id }}</small></span><input type="checkbox" :checked="tool.allowed" :disabled="access.savingKey !== null" @change="onToolChange(tool.id, $event)"></label></div></section>
|
||||
<section class="tuple-list"><h3>Tuples</h3><p v-if="!access.detailView.tuples.length" class="muted-box">暂无 tuple。</p><ul v-else><li v-for="tuple in access.detailView.tuples" :key="String(tuple.object || tuple.relation || tuple.userId)"><code>{{ valueText(tuple.userId) }}</code><span>{{ valueText(tuple.relation) }}</span><code>{{ valueText(tuple.object) }}</code></li></ul></section>
|
||||
</InspectorPanel>
|
||||
</template>
|
||||
</BoundedWorkspace>
|
||||
</AsyncBoundary>
|
||||
</section>
|
||||
</OperationsWorkspace>
|
||||
</AsyncBoundary>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.access-admin-page { min-width: 0; }
|
||||
.access-workspace { min-height: 500px; }
|
||||
.access-collection-controls { display: grid; grid-template-columns: minmax(240px, 1fr) auto; gap: 10px; align-items: end; }
|
||||
.access-collection-controls label { display: grid; gap: 4px; color: #60717a; font-size: 9px; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.access-collection-controls input { width: 100%; height: 34px; border: 1px solid #b8c5cc; border-radius: 6px; background: #fff; padding: 0 9px; color: #1c3038; }
|
||||
.access-view-toggle { display: inline-flex; padding: 2px; border: 1px solid #b8c5cc; border-radius: 7px; background: #eef3f4; }
|
||||
.access-view-toggle button { height: 28px; border: 0; border-radius: 5px; background: transparent; padding: 0 11px; color: #64757d; font-size: 10px; font-weight: 800; }
|
||||
.access-view-toggle button[data-active="true"] { background: #183b43; color: #fff; box-shadow: 0 1px 2px rgb(12 36 42 / 20%); }
|
||||
.access-user-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 9px; padding: 10px; }
|
||||
.access-user-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; gap: 10px; align-items: center; min-width: 0; border: 1px solid #c7d3d8; border-radius: 8px; background: #fff; padding: 11px; color: #1c3038; text-align: left; box-shadow: inset 3px 0 #90a6ad; }
|
||||
.access-user-card:hover,
|
||||
.access-user-card[data-selected="true"] { border-color: #1ba39e; box-shadow: inset 3px 0 #1ba39e, 0 4px 12px rgb(19 60 68 / 8%); }
|
||||
.access-user-card > div { display: grid; min-width: 0; }
|
||||
.access-user-card strong,
|
||||
.access-user-card small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.access-user-card small { color: #6b7c84; font: 700 10px/1.4 ui-monospace, monospace; }
|
||||
.access-user-card dl { grid-column: 2 / -1; display: grid; grid-template-columns: repeat(2, 1fr); gap: 7px; margin: 0; }
|
||||
.access-user-card dl div { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid #e0e7e9; padding-top: 7px; }
|
||||
.access-user-card dt { color: #708087; font-size: 9px; font-weight: 850; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.access-user-card dd { margin: 0; font: 800 10px/1 ui-monospace, monospace; }
|
||||
.access-user-glyph { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 7px; background: #dcefed; color: #126f6a; font: 900 11px/1 ui-monospace, monospace; }
|
||||
.access-summary-grid { gap: 9px; }
|
||||
.access-summary-grid .metric-card { border-color: #cbd6dc; background: linear-gradient(145deg, #fff, #f4f7f8); box-shadow: inset 3px 0 #1ba39e; }
|
||||
.openfga-panel,
|
||||
.access-detail-panel { min-width: 0; box-shadow: inset 3px 0 #1ba39e; }
|
||||
.access-detail-panel { display: grid; gap: 12px; }
|
||||
@media (max-width: 720px) {
|
||||
.access-collection-controls { grid-template-columns: 1fr; }
|
||||
.access-view-toggle { justify-self: stretch; }
|
||||
.access-view-toggle button { flex: 1; }
|
||||
.access-user-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.access-user-head { border-color: #bbd8d5; background: #eff8f7; }
|
||||
.tool-toggle-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
.tool-toggle-row { border-color: #d5dfe3; background: #f8fafb; }
|
||||
.access-operations { min-height: 610px; }
|
||||
.operations-search { display: grid; gap: 5px; font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.operations-search input { min-width: 0; height: 34px; border: 1px solid #b8c5cc; border-radius: 5px; padding: 0 9px; background: white; }
|
||||
.entity-index-row small { color: #64757d; }
|
||||
.entity-index-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 4px; }
|
||||
.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 { 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; }
|
||||
.permission-matrix > button { width: 100%; border: 0; background: white; color: inherit; cursor: pointer; }
|
||||
.permission-matrix > button:hover, .permission-matrix > button[data-selected="true"] { background: #e8f6f5; }
|
||||
.permission-matrix strong, .permission-matrix small { display: block; }
|
||||
.permission-matrix small { color: #64757d; }
|
||||
.permission-matrix [data-allowed="true"] { color: #08756f; font-weight: 800; }
|
||||
.access-compact-list { display: grid; }
|
||||
.access-compact-list button { display: grid; grid-template-columns: minmax(180px, 1fr) 120px 90px 90px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid #d8e1e4; padding: 9px 11px; background: white; color: inherit; text-align: left; cursor: pointer; }
|
||||
.access-compact-list button:hover, .access-compact-list button[data-selected="true"] { background: #e8f6f5; }
|
||||
.access-compact-list strong, .access-compact-list small { display: block; }
|
||||
.technical-disclosure { border: 1px solid #d8e1e4; background: #f8fafb; }
|
||||
.technical-disclosure summary { cursor: pointer; padding: 11px 13px; font-weight: 800; }
|
||||
.technical-disclosure .field-grid { padding: 0 13px 13px; }
|
||||
.tool-toggle-grid { display: grid; gap: 7px; }
|
||||
.tuple-list { min-width: 0; overflow: auto; }
|
||||
.tuple-list li { min-width: 560px; }
|
||||
@media (max-width: 700px) {
|
||||
.access-summary-grid { grid-template-columns: 1fr 1fr; }
|
||||
.field-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.tuple-list li { min-width: 520px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts" generic="T extends object">
|
||||
withDefaults(defineProps<{ items: T[]; itemKey: (item: T) => string; label?: string; emptyText?: string }>(), { label: "审计时间线", emptyText: "暂无审计记录。" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol v-if="items.length" class="audit-timeline" :aria-label="label">
|
||||
<li v-for="item in items" :key="itemKey(item)"><slot name="item" :item="item" /></li>
|
||||
</ol>
|
||||
<p v-else class="muted-box">{{ emptyText }}</p>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{ name: string; size?: number }>(), { size: 18 });
|
||||
|
||||
const paths: Record<string, string[]> = {
|
||||
workbench: ["M4 5h16v11H4z", "M8 20h8", "M12 16v4"],
|
||||
debug: ["M9 4h6", "M12 2v4", "M7 9h10v8a5 5 0 0 1-10 0z", "M4 13h3", "M17 13h3"],
|
||||
code: ["m9 7-5 5 5 5", "m15 7 5 5-5 5", "m14 4-4 16"],
|
||||
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"],
|
||||
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"],
|
||||
access: ["M12 3 4 7v5c0 5 3.4 8.2 8 9 4.6-.8 8-4 8-9V7z", "M9 12l2 2 4-4"],
|
||||
secrets: ["M7 10V7a5 5 0 0 1 10 0v3", "M5 10h14v11H5z", "M12 14v3"],
|
||||
users: ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8", "M22 21v-2a4 4 0 0 0-3-3.87", "M16 3.13a4 4 0 0 1 0 7.75"],
|
||||
hwpod: ["M4 6h16v12H4z", "M8 10h8", "M8 14h4", "M2 9h2", "M20 9h2", "M2 15h2", "M20 15h2"],
|
||||
provider: ["M12 2v6", "M12 16v6", "M4.93 4.93l4.24 4.24", "m14.83 5.66 4.24-4.24", "M2 12h6", "M16 12h6", "M4.93 19.07l4.24-4.24", "m14.83-5.66 4.24 4.24", "M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8"],
|
||||
gate: ["M4 4h16v16H4z", "M9 12l2 2 4-4"],
|
||||
performance: ["M4 18a8 8 0 1 1 16 0", "m12 14 4-4", "M12 6v2", "M6.3 8.3l1.4 1.4", "M17.7 8.3l-1.4 1.4"],
|
||||
skills: ["M12 2 4 6v10l8 4 8-4V8z", "M4 8l8 4 8-4", "M12 12v10"],
|
||||
settings: ["M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7", "M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.12 3.67-.08-.02a1.7 1.7 0 0 0-1.8.32l-.14.08a1.7 1.7 0 0 0-.82 1.72V23H9v-.29A1.7 1.7 0 0 0 8.18 21l-.14-.08a1.7 1.7 0 0 0-1.8-.32l-.08.02-2.12-3.67.06-.06A1.7 1.7 0 0 0 4.6 15v-.16a1.7 1.7 0 0 0-.5-1.2l-.06-.06L6.16 9.9l.08.02a1.7 1.7 0 0 0 1.8-.32l.14-.08A1.7 1.7 0 0 0 9 7.8V7.5h6v.29a1.7 1.7 0 0 0 .82 1.72l.14.08a1.7 1.7 0 0 0 1.8.32l.08-.02 2.12 3.67-.06.06a1.7 1.7 0 0 0-.5 1.2z"],
|
||||
help: ["M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4", "M12 18h.01", "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20"]
|
||||
};
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts" generic="T extends object">
|
||||
withDefaults(defineProps<{ items: T[]; itemKey: (item: T) => string; selectedId?: string | null; label: string; emptyText?: string }>(), {
|
||||
selectedId: null,
|
||||
emptyText: "暂无对象。"
|
||||
});
|
||||
const emit = defineEmits<{ select: [item: T] }>();
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<p v-else class="muted-box">{{ emptyText }}</p>
|
||||
<footer v-if="$slots.footer" class="entity-index-footer"><slot name="footer" /></footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
export interface FactPathStep { id: string; label: string; value: string; tone?: "neutral" | "ok" | "warn" | "error"; }
|
||||
defineProps<{ steps: FactPathStep[]; label?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="fact-path" :aria-label="label || '事实路径'">
|
||||
<li v-for="(step, index) in steps" :key="step.id" :data-tone="step.tone || 'neutral'">
|
||||
<span class="fact-path-index">{{ index + 1 }}</span>
|
||||
<span><small>{{ step.label }}</small><strong>{{ step.value }}</strong></span>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; subtitle?: string }>();
|
||||
defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="operations-inspector-panel">
|
||||
<header class="operations-inspector-header">
|
||||
<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>
|
||||
<div class="operations-inspector-body"><slot /></div>
|
||||
<footer v-if="$slots.footer" class="operations-inspector-footer"><slot name="footer" /></footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ label: string; inspectorOpen?: boolean; indexWidth?: string; inspectorWidth?: string }>(), {
|
||||
inspectorOpen: false,
|
||||
indexWidth: "minmax(250px, 28%)",
|
||||
inspectorWidth: "minmax(320px, 34%)"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="operations-workspace" :style="{ '--operations-index-width': indexWidth, '--operations-inspector-width': inspectorWidth }" :data-inspector-open="inspectorOpen ? 'true' : 'false'" :aria-label="label">
|
||||
<aside class="operations-index"><slot name="index" /></aside>
|
||||
<main class="operations-main"><slot /></main>
|
||||
<aside v-if="inspectorOpen" class="operations-inspector"><slot name="inspector" /></aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import ConsoleIcon from "./ConsoleIcon.vue";
|
||||
|
||||
export interface ViewModeOption { value: string; label: string; icon: string; }
|
||||
defineProps<{ modelValue: string; options: ViewModeOption[]; label?: string }>();
|
||||
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-mode-switch" role="group" :aria-label="label || '显示方式'">
|
||||
<button v-for="option in options" :key="option.value" type="button" :aria-label="option.label" :aria-pressed="modelValue === option.value" :title="option.label" @click="emit('update:modelValue', option.value)">
|
||||
<ConsoleIcon :name="option.icon" :size="16" /><span>{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
@@ -18,7 +19,7 @@ interface NavItem {
|
||||
name: string;
|
||||
label: string;
|
||||
path: string;
|
||||
glyph: string;
|
||||
icon: string;
|
||||
navId: string;
|
||||
}
|
||||
|
||||
@@ -28,11 +29,11 @@ interface NavSection {
|
||||
}
|
||||
|
||||
const navSections = [
|
||||
{ title: "工作入口", items: [{ name: "CodeWorkbench", label: "Code 工作台", path: "/workbench", glyph: "CW", navId: "workbench.code" }, { name: "WorkbenchDebug", label: "调试", path: "/workbench/debug", glyph: "DB", navId: "workbench.debug" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", glyph: "OC", navId: "opencode.root" }, { name: "Dashboard", label: "平台概览", path: "/dashboard", glyph: "OV", navId: "user.dashboard" }] },
|
||||
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", glyph: "PJ", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", glyph: "MD", navId: "project.mdtodo" }] },
|
||||
{ title: "用户与运营", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", glyph: "AK", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", glyph: "US", navId: "user.usage" }, { name: "Billing", label: "充值与订阅", path: "/billing", glyph: "BL", navId: "user.billing" }] },
|
||||
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", glyph: "AC", navId: "admin.access" }, { name: "AdminSecrets", label: "密钥", path: "/admin/secrets", glyph: "SK", navId: "admin.secrets" }, { name: "Users", label: "用户", path: "/admin/users", glyph: "UR", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", glyph: "AB", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", glyph: "HP", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Provider Profiles", path: "/admin/provider-profiles", glyph: "PP", navId: "admin.providerProfiles" }] },
|
||||
{ title: "系统", items: [{ name: "Gate", label: "内部复核", path: "/gate", glyph: "GT", navId: "system.gate" }, { name: "Performance", label: "性能", path: "/performance", glyph: "PF", navId: "system.performance" }, { name: "Skills", label: "Skills", path: "/skills", glyph: "SS", navId: "system.skills" }, { name: "Settings", label: "设置", path: "/settings", glyph: "ST", navId: "system.settings" }, { name: "Help", label: "帮助", path: "/help", glyph: "?", navId: "system.help" }] }
|
||||
{ title: "工作入口", items: [{ name: "CodeWorkbench", label: "Code 工作台", path: "/workbench", icon: "workbench", navId: "workbench.code" }, { name: "WorkbenchDebug", label: "调试", path: "/workbench/debug", icon: "debug", navId: "workbench.debug" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "code", navId: "opencode.root" }, { name: "Dashboard", label: "平台概览", path: "/dashboard", icon: "overview", navId: "user.dashboard" }] },
|
||||
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "project", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "mdtodo", navId: "project.mdtodo" }] },
|
||||
{ title: "用户与运营", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "key", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "usage", navId: "user.usage" }, { name: "Billing", label: "充值与订阅", path: "/billing", icon: "billing", navId: "user.billing" }] },
|
||||
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "access", navId: "admin.access" }, { name: "AdminSecrets", label: "密钥", path: "/admin/secrets", icon: "secrets", navId: "admin.secrets" }, { name: "Users", label: "用户", path: "/admin/users", icon: "users", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "billing", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "hwpod", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Provider Profiles", path: "/admin/provider-profiles", icon: "provider", navId: "admin.providerProfiles" }] },
|
||||
{ title: "系统", items: [{ name: "Gate", label: "内部复核", path: "/gate", icon: "gate", navId: "system.gate" }, { name: "Performance", label: "性能", path: "/performance", icon: "performance", navId: "system.performance" }, { name: "Skills", label: "Skills", path: "/skills", icon: "skills", navId: "system.skills" }, { name: "Settings", label: "设置", path: "/settings", icon: "settings", navId: "system.settings" }, { name: "Help", label: "帮助", path: "/help", icon: "help", navId: "system.help" }] }
|
||||
] satisfies NavSection[];
|
||||
|
||||
const visibleSections = computed(() => navSections
|
||||
@@ -77,7 +78,7 @@ function isActive(item: NavItem): boolean {
|
||||
:aria-current="isActive(item) ? 'page' : undefined"
|
||||
@click="go(item)"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">{{ item.glyph }}</span>
|
||||
<span class="nav-icon" aria-hidden="true"><ConsoleIcon :name="item.icon" /></span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -116,7 +116,7 @@ function clamp(value: number, min: number, max: number): number {
|
||||
<template>
|
||||
<section
|
||||
ref="layoutRef"
|
||||
class="split-workspace-layout"
|
||||
class="split-workspace-layout operations-workspace"
|
||||
:style="layoutStyle"
|
||||
:aria-label="ariaLabel"
|
||||
:data-left-collapsed="leftCollapsed"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ApiError, ErrorDiagnostic, ProviderProfileCatalogResponse } from "
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import SplitWorkspaceLayout from "@/components/layout/SplitWorkspaceLayout.vue";
|
||||
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
||||
import MdtodoToolbar from "@/components/mdtodo/MdtodoToolbar.vue";
|
||||
import MdtodoTaskTree from "@/components/mdtodo/MdtodoTaskTree.vue";
|
||||
import MdtodoTaskPanel from "@/components/mdtodo/MdtodoTaskPanel.vue";
|
||||
@@ -100,6 +101,12 @@ const selectedSource = computed(() => source.sources.value.find((s) => s.sourceI
|
||||
const selectedFile = computed(() => taskData.files.value.find((f) => f.fileRef === selectedFileRef.value) ?? null);
|
||||
const selectedTask = computed(() => taskData.tasks.value.find((t) => t.taskRef === selectedTaskRef.value) ?? null);
|
||||
const selectedTaskProjection = computed(() => selectedTask.value?.task ?? null);
|
||||
const workspaceStatus = computed(() => [
|
||||
{ id: "source", label: "Source", value: selectedSource.value?.displayName || selectedSourceId.value || "-" },
|
||||
{ id: "file", label: "File", value: selectedFileName.value },
|
||||
{ id: "task", label: "Task", value: selectedTaskRef.value || "-", tone: selectedTask.value ? "ok" as const : "neutral" as const },
|
||||
{ id: "report", label: "Report", value: reportPaneOpen.value ? "open" : "closed", tone: reportPaneOpen.value ? "info" as const : "neutral" as const }
|
||||
]);
|
||||
const fileScopedTasks = computed(() => selectedFileRef.value ? taskData.tasks.value.filter((t) => t.fileRef === selectedFileRef.value) : taskData.tasks.value);
|
||||
const taskDetailPartial = computed(() => taskData.taskDetailAuthority.value?.state === "unavailable");
|
||||
const taskMutationDisabled = computed(() => Boolean(selectedTask.value && (taskData.taskDetailLoading.value || !taskData.taskDetail.value || taskDetailPartial.value)));
|
||||
@@ -634,6 +641,8 @@ function setError(err: unknown): void {
|
||||
@open-create-task="openTaskCreateDialog"
|
||||
/>
|
||||
|
||||
<StatusStrip :items="workspaceStatus" label="MDTODO 工作区状态" />
|
||||
|
||||
<div v-if="source.sourceMessage.value || source.sourceError.value" class="mdtodo-notices">
|
||||
<div v-if="source.sourceMessage.value" class="source-message" data-testid="mdtodo-source-message">
|
||||
<span>{{ source.sourceMessage.value }}</span>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
||||
export type ConsoleCollectionView = "cards" | "list";
|
||||
export type ConsoleCollectionDensity = "compact" | "comfortable";
|
||||
|
||||
export interface ConsoleViewStateDefaults {
|
||||
view?: ConsoleCollectionView;
|
||||
export interface ConsoleViewStateDefaults<TView extends string = ConsoleCollectionView> {
|
||||
view?: TView;
|
||||
viewValues?: readonly TView[];
|
||||
density?: ConsoleCollectionDensity;
|
||||
q?: string;
|
||||
filter?: string;
|
||||
@@ -17,22 +18,29 @@ export interface ConsoleViewStateDefaults {
|
||||
tab?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleViewState {
|
||||
view: ComputedRef<ConsoleCollectionView>;
|
||||
export type ConsoleViewStatePatch<TView extends string = ConsoleCollectionView> = Omit<ConsoleViewStateDefaults<TView>, "viewValues">;
|
||||
|
||||
export interface ConsoleViewState<TView extends string = ConsoleCollectionView> {
|
||||
view: ComputedRef<TView>;
|
||||
density: ComputedRef<ConsoleCollectionDensity>;
|
||||
q: ComputedRef<string>;
|
||||
filter: ComputedRef<string>;
|
||||
sort: ComputedRef<string>;
|
||||
cursor: ComputedRef<string>;
|
||||
tab: ComputedRef<string>;
|
||||
update: (patch: Partial<ConsoleViewStateDefaults>, replace?: boolean) => Promise<void>;
|
||||
update: (patch: Partial<ConsoleViewStatePatch<TView>>, replace?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useConsoleViewState(defaults: ConsoleViewStateDefaults = {}): ConsoleViewState {
|
||||
export function useConsoleViewState(): ConsoleViewState<ConsoleCollectionView>;
|
||||
export function useConsoleViewState(defaults: ConsoleViewStateDefaults<ConsoleCollectionView>): ConsoleViewState<ConsoleCollectionView>;
|
||||
export function useConsoleViewState<TView extends string>(defaults: ConsoleViewStateDefaults<TView> & { view: TView; viewValues: readonly TView[] }): ConsoleViewState<TView>;
|
||||
export function useConsoleViewState<TView extends string = ConsoleCollectionView>(defaults: ConsoleViewStateDefaults<TView> = {}): ConsoleViewState<TView> {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const viewValues = defaults.viewValues ?? (["cards", "list"] as unknown as readonly TView[]);
|
||||
const defaultView = defaults.view ?? viewValues[0] ?? ("cards" as TView);
|
||||
|
||||
const view = computed(() => queryEnum(route.query.view, ["cards", "list"] as const, defaults.view ?? "cards"));
|
||||
const view = computed<TView>(() => queryEnum(route.query.view, viewValues, defaultView));
|
||||
const density = computed(() => queryEnum(route.query.density, ["compact", "comfortable"] as const, defaults.density ?? "comfortable"));
|
||||
const q = computed(() => queryText(route.query.q) || defaults.q || "");
|
||||
const filter = computed(() => queryText(route.query.filter) || defaults.filter || "");
|
||||
@@ -40,7 +48,7 @@ export function useConsoleViewState(defaults: ConsoleViewStateDefaults = {}): Co
|
||||
const cursor = computed(() => queryText(route.query.cursor) || defaults.cursor || "");
|
||||
const tab = computed(() => queryText(route.query.tab) || defaults.tab || "");
|
||||
|
||||
async function update(patch: Partial<ConsoleViewStateDefaults>, replace = true): Promise<void> {
|
||||
async function update(patch: Partial<ConsoleViewStatePatch<TView>>, replace = true): Promise<void> {
|
||||
const query: LocationQueryRaw = { ...route.query };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined || value === null || value === "") delete query[key];
|
||||
|
||||
@@ -954,6 +954,87 @@ textarea {
|
||||
letter-spacing: 0.035em;
|
||||
}
|
||||
|
||||
.operations-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: var(--operations-index-width) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--console-graphite-200);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.operations-workspace[data-inspector-open="true"] {
|
||||
grid-template-columns: var(--operations-index-width) minmax(0, 1fr) var(--operations-inspector-width);
|
||||
}
|
||||
|
||||
.operations-index,
|
||||
.operations-main,
|
||||
.operations-inspector {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.operations-index { border-right: 1px solid var(--console-graphite-200); background: var(--console-graphite-50); }
|
||||
.operations-inspector { border-left: 1px solid var(--console-graphite-200); background: white; }
|
||||
.operations-main { padding: var(--console-space-4); }
|
||||
|
||||
.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-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); }
|
||||
.entity-index-row:focus-visible { outline: 2px solid var(--console-cyan-700); outline-offset: -2px; }
|
||||
.entity-index-footer { margin-top: auto; padding: var(--console-space-3); border-top: 1px solid var(--console-graphite-200); }
|
||||
|
||||
.fact-path { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0; padding: 1px; list-style: none; background: var(--console-graphite-200); }
|
||||
.fact-path li { position: relative; display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 8px; align-items: center; min-width: 0; padding: 10px; background: white; }
|
||||
.fact-path li:not(:last-child)::after { content: ""; position: absolute; right: -5px; z-index: 1; width: 9px; height: 9px; border-top: 1px solid var(--console-graphite-300); border-right: 1px solid var(--console-graphite-300); background: white; transform: rotate(45deg); }
|
||||
.fact-path-index { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid var(--console-graphite-300); border-radius: 50%; font: 600 11px/1 var(--console-font-mono); }
|
||||
.fact-path small, .fact-path strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fact-path small { color: var(--console-graphite-600); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.fact-path strong { margin-top: 2px; font-size: 12px; }
|
||||
.fact-path li[data-tone="ok"] .fact-path-index { color: var(--console-green-700); border-color: var(--console-green-700); }
|
||||
.fact-path li[data-tone="warn"] .fact-path-index { color: var(--console-amber-700); border-color: var(--console-amber-500); }
|
||||
.fact-path li[data-tone="error"] .fact-path-index { color: var(--console-red-700); border-color: var(--console-red-700); }
|
||||
|
||||
.operations-inspector-panel { display: flex; flex-direction: column; min-height: 100%; }
|
||||
.operations-inspector-header { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; gap: 12px; padding: 14px; border-bottom: 1px solid var(--console-graphite-200); background: white; }
|
||||
.operations-inspector-header h2, .operations-inspector-header p { margin: 0; }
|
||||
.operations-inspector-header h2 { font-size: 15px; }
|
||||
.operations-inspector-header p { margin-top: 3px; color: var(--console-graphite-600); font-size: 11px; }
|
||||
.operations-inspector-tabs { display: flex; gap: 2px; padding: 8px 12px; border-bottom: 1px solid var(--console-graphite-200); overflow-x: auto; }
|
||||
.operations-inspector-tabs button { border: 0; border-bottom: 2px solid transparent; padding: 7px 9px; background: transparent; color: var(--console-graphite-600); cursor: pointer; white-space: nowrap; }
|
||||
.operations-inspector-tabs button[aria-selected="true"] { border-color: var(--console-cyan-700); color: var(--console-graphite-950); }
|
||||
.operations-inspector-body { display: grid; align-content: start; gap: var(--console-space-4); padding: 14px; }
|
||||
.operations-inspector-footer { position: sticky; bottom: 0; display: flex; gap: 8px; justify-content: flex-end; padding: 12px 14px; border-top: 1px solid var(--console-graphite-200); background: white; }
|
||||
|
||||
.audit-timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.audit-timeline li { position: relative; padding: 0 0 16px 22px; border-left: 1px solid var(--console-graphite-300); }
|
||||
.audit-timeline li::before { content: ""; position: absolute; left: -5px; top: 4px; width: 9px; height: 9px; border: 2px solid white; border-radius: 50%; background: var(--console-cyan-700); box-shadow: 0 0 0 1px var(--console-cyan-700); }
|
||||
.view-mode-switch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--console-graphite-300); border-radius: 6px; background: var(--console-graphite-50); }
|
||||
.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 (max-width: 1100px) {
|
||||
.operations-workspace,
|
||||
.operations-workspace[data-inspector-open="true"] { grid-template-columns: minmax(220px, 34%) minmax(0, 1fr); }
|
||||
.operations-inspector { position: absolute; inset: 0 0 0 auto; z-index: 10; width: min(430px, 76vw); box-shadow: -12px 0 28px rgb(15 23 42 / .18); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.operations-workspace,
|
||||
.operations-workspace[data-inspector-open="true"] { display: block; overflow: auto; }
|
||||
.operations-index, .operations-main { min-height: 45vh; overflow: visible; }
|
||||
.operations-inspector { position: fixed; inset: 56px 0 0; width: auto; }
|
||||
.fact-path { grid-template-columns: 1fr; }
|
||||
.fact-path li::after { display: none; }
|
||||
}
|
||||
|
||||
.auth-main,
|
||||
.login-shell {
|
||||
background:
|
||||
|
||||
@@ -1,310 +1,71 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<script setup lang="ts">
|
||||
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 ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
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";
|
||||
|
||||
const codes = ref<RedeemCodeSummary[]>([]);
|
||||
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 createdCode = ref<string | null>(null);
|
||||
const selectedCodeId = ref<string | null>(typeof route.query.codeId === "string" ? route.query.codeId : null);
|
||||
const createOpen = ref(false);
|
||||
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 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 form = reactive({ displayName: "", creditAmount: 10, reason: "redeem", maxRedemptions: 1, perUserLimit: 1, expiresAt: "" });
|
||||
const activeTab = computed<"codes" | "redemptions">({
|
||||
get: () => route.query.tab === "redemptions" ? "redemptions" : "codes",
|
||||
set: (value) => {
|
||||
const query = { ...route.query };
|
||||
if (value === "codes") delete query.tab;
|
||||
else query.tab = value;
|
||||
void router.replace({ query });
|
||||
}
|
||||
});
|
||||
const viewMode = computed<"cards" | "list">({
|
||||
get: () => route.query.view === "list" ? "list" : "cards",
|
||||
set: (value) => {
|
||||
const query = { ...route.query };
|
||||
if (value === "cards") delete query.view;
|
||||
else query.view = value;
|
||||
void router.replace({ query });
|
||||
}
|
||||
});
|
||||
const asyncState = computed(() => {
|
||||
const hasContent = codes.value.length > 0 || redemptions.value.length > 0;
|
||||
if (loading.value) return hasContent ? "refreshing" : "initial-loading";
|
||||
if (error.value) return hasContent ? "partial" : "error";
|
||||
return loaded.value ? "ready" : "empty";
|
||||
});
|
||||
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" }
|
||||
];
|
||||
|
||||
function codeKey(code: RedeemCodeSummary): string {
|
||||
return code.id || code.codePrefix || "redeem-code";
|
||||
}
|
||||
|
||||
function redemptionKey(row: RedeemRedemptionSummary): string {
|
||||
return row.id || `${row.userId}:${row.codeId}`;
|
||||
}
|
||||
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 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" }]);
|
||||
|
||||
onMounted(refreshAll);
|
||||
|
||||
watch(() => [route.query.filter, route.query.userId, route.query.codeId].map((value) => typeof value === "string" ? value : "").join("\u0000"), () => {
|
||||
const status = typeof route.query.filter === "string" ? route.query.filter : "";
|
||||
const userId = typeof route.query.userId === "string" ? route.query.userId : "";
|
||||
const codeId = typeof route.query.codeId === "string" ? route.query.codeId : "";
|
||||
if (filters.status === status && filters.userId === userId && filters.codeId === codeId) return;
|
||||
filters.status = status;
|
||||
filters.userId = userId;
|
||||
filters.codeId = codeId;
|
||||
void refreshFromRoute();
|
||||
});
|
||||
|
||||
async function refreshFromRoute(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
await Promise.all([fetchCodes(), fetchRedemptions()]);
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
await persistFilters();
|
||||
await Promise.all([fetchCodes(), fetchRedemptions()]);
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
async function persistFilters(): Promise<void> {
|
||||
const query = { ...route.query };
|
||||
if (filters.status) query.filter = filters.status;
|
||||
else delete query.filter;
|
||||
if (filters.userId.trim()) query.userId = filters.userId.trim();
|
||||
else delete query.userId;
|
||||
if (filters.codeId.trim()) query.codeId = filters.codeId.trim();
|
||||
else delete query.codeId;
|
||||
await router.replace({ query });
|
||||
}
|
||||
|
||||
async function loadCodes(): Promise<void> {
|
||||
error.value = null;
|
||||
await persistFilters();
|
||||
await fetchCodes();
|
||||
}
|
||||
|
||||
async function fetchCodes(): Promise<void> {
|
||||
const response = await billingAPI.adminRedeemCodes({ limit: 100, status: filters.status });
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
codes.value = response.data?.codes ?? [];
|
||||
}
|
||||
|
||||
async function loadRedemptions(): Promise<void> {
|
||||
error.value = null;
|
||||
await persistFilters();
|
||||
await fetchRedemptions();
|
||||
}
|
||||
|
||||
async function fetchRedemptions(): Promise<void> {
|
||||
const response = await billingAPI.adminRedeemRedemptions({ limit: 100, userId: filters.userId, codeId: filters.codeId });
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
redemptions.value = response.data?.redemptions ?? [];
|
||||
}
|
||||
|
||||
async function createCode(): Promise<void> {
|
||||
pending.value = "create";
|
||||
createdCode.value = null;
|
||||
error.value = null;
|
||||
const body: Record<string, unknown> = { ...form, creditAmount: Number(form.creditAmount), maxRedemptions: Number(form.maxRedemptions), perUserLimit: Number(form.perUserLimit) };
|
||||
if (!String(form.expiresAt).trim()) delete body.expiresAt;
|
||||
else body.expiresAt = new Date(form.expiresAt).toISOString();
|
||||
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;
|
||||
form.displayName = "";
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function setStatus(codeId: string | undefined, status: string): Promise<void> {
|
||||
if (!codeId) return;
|
||||
pending.value = `status:${codeId}`;
|
||||
const response = await billingAPI.updateAdminRedeemCode(codeId, { status });
|
||||
pending.value = null;
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown): string {
|
||||
const number = Number(value ?? 0);
|
||||
if (!Number.isFinite(number)) return "0";
|
||||
return new Intl.NumberFormat("zh-CN").format(number);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "-";
|
||||
const date = new Date(text);
|
||||
if (Number.isNaN(date.getTime())) return text;
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
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 } }); }
|
||||
async function selectCode(code: RedeemCodeSummary): Promise<void> { const codeId = codeKey(code); selectedCodeId.value = codeId; await router.push({ query: { ...route.query, codeId } }); }
|
||||
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"); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack admin-billing-page">
|
||||
<PageCommandBar eyebrow="Admin" title="兑换与订阅" description="管理员生成兑换码并审计 redemption;真实支付 provider 未配置时只显示 placeholder。" />
|
||||
<AsyncBoundary :state="asyncState" :has-content="Boolean(codes.length || redemptions.length)" label="加载兑换码与兑换记录" error-title="账务审计加载失败" :error="error || '请求未完成'" @retry="refreshAll">
|
||||
<p v-if="error" class="form-error admin-action-error">{{ error }}</p>
|
||||
<section class="admin-billing-commandbar"><div><span>REDEEM AUTHORITY</span><strong>兑换码与 redemption 审计</strong><small>user-billing / bounded 100 rows</small></div><button type="button" @click="createdCode = null; createOpen = true">+ 创建兑换码</button></section>
|
||||
<nav class="admin-billing-tabs" role="tablist" aria-label="管理员账务视图"><button type="button" role="tab" :aria-selected="activeTab === 'codes'" :data-active="activeTab === 'codes'" @click="activeTab = 'codes'">兑换码 <span>{{ codes.length }}</span></button><button type="button" role="tab" :aria-selected="activeTab === 'redemptions'" :data-active="activeTab === 'redemptions'" @click="activeTab = 'redemptions'">兑换记录 <span>{{ redemptions.length }}</span></button></nav>
|
||||
|
||||
<section v-if="activeTab === 'codes'" class="data-panel admin-billing-surface">
|
||||
<header class="panel-header"><h2>兑换码</h2><div class="admin-billing-heading-actions"><small>{{ codes.length }} codes</small><span class="admin-billing-view-toggle"><button type="button" :data-active="viewMode === 'cards'" @click="viewMode = 'cards'">卡片</button><button type="button" :data-active="viewMode === 'list'" @click="viewMode = 'list'">列表</button></span></div></header>
|
||||
<div class="admin-users-filters">
|
||||
<select v-model="filters.status" class="input" @change="loadCodes"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="expired">expired</option></select>
|
||||
<button class="btn btn-secondary" type="button" @click="loadCodes">刷新</button>
|
||||
<section class="route-stack billing-operations-page">
|
||||
<PageCommandBar eyebrow="Admin" title="兑换运营与账本审计" description="兑换码与 redemption 使用同一稳定集合合同;创建动作进入 Dialog。"><template #actions><ViewModeSwitch v-model="viewMode" :options="[{ value: 'summary', label: '运营摘要', icon: 'billing' }, { value: 'detail', label: '账本明细', icon: 'usage' }]" /><button class="table-action" type="button" @click="createdCode = null; createOpen = true">创建兑换码</button></template></PageCommandBar>
|
||||
<AsyncBoundary :state="asyncState" :has-content="Boolean(codes.length || redemptions.length)" label="加载兑换运营" @retry="refreshAll"><template #error><EmptyState title="兑换运营加载失败" :description="error || '请求未完成'" action-label="重试" @action="refreshAll" /></template>
|
||||
<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>
|
||||
<DataGrid v-if="viewMode === 'list' && codes.length" :columns="codeColumns" :rows="codes" :row-key="codeKey" :interactive="false" empty-text="没有匹配兑换码。">
|
||||
<template #cell-code="{ row }"><code>{{ (row as RedeemCodeSummary).codePrefix }}</code><small>{{ (row as RedeemCodeSummary).displayName || (row as RedeemCodeSummary).id }} / {{ (row as RedeemCodeSummary).reason }}</small></template>
|
||||
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as RedeemCodeSummary).status">{{ (row as RedeemCodeSummary).status }}</span></template>
|
||||
<template #cell-credits="{ row }">{{ formatNumber((row as RedeemCodeSummary).creditAmount) }}</template>
|
||||
<template #cell-usage="{ row }">{{ formatNumber((row as RedeemCodeSummary).redeemedCount) }} / {{ formatNumber((row as RedeemCodeSummary).maxRedemptions) }}<small>per user {{ formatNumber((row as RedeemCodeSummary).perUserLimit) }}</small></template>
|
||||
<template #cell-expires="{ row }">{{ formatDate((row as RedeemCodeSummary).expiresAt) }}</template>
|
||||
<template #cell-actions="{ row: item }"><button class="table-action" type="button" :disabled="pending === `status:${(item as RedeemCodeSummary).id}`" @click="setStatus((item as RedeemCodeSummary).id, (item as RedeemCodeSummary).status === 'disabled' ? 'active' : 'disabled')">{{ (item as RedeemCodeSummary).status === "disabled" ? "启用" : "禁用" }}</button></template>
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="codes.length" :items="codes" :item-key="codeKey" view="cards" :show-controls="false" aria-label="兑换码卡片">
|
||||
<template #card="{ item: code }"><article class="admin-billing-card">
|
||||
<header><span class="billing-glyph" aria-hidden="true">RC</span><div><code>{{ code.codePrefix }}</code><small>{{ code.displayName || code.id }} / {{ code.reason }}</small></div><span class="status-pill" :data-status="code.status">{{ code.status }}</span></header>
|
||||
<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>
|
||||
<footer><small>per user {{ formatNumber(code.perUserLimit) }}</small><button class="table-action" type="button" :disabled="pending === `status:${code.id}`" @click="setStatus(code.id, code.status === 'disabled' ? 'active' : 'disabled')">{{ code.status === "disabled" ? "启用" : "禁用" }}</button></footer>
|
||||
</article></template>
|
||||
</ResourceCollection>
|
||||
<p v-else class="muted-box">没有匹配兑换码。</p>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === 'redemptions'" class="data-panel admin-billing-surface">
|
||||
<header class="panel-header"><h2>兑换记录</h2><div class="admin-billing-heading-actions"><small>{{ redemptions.length }} rows</small><span class="admin-billing-view-toggle"><button type="button" :data-active="viewMode === 'cards'" @click="viewMode = 'cards'">卡片</button><button type="button" :data-active="viewMode === 'list'" @click="viewMode = 'list'">列表</button></span></div></header>
|
||||
<div class="admin-users-filters">
|
||||
<input v-model="filters.userId" class="input" placeholder="user id" @keyup.enter="loadRedemptions" />
|
||||
<input v-model="filters.codeId" class="input" placeholder="code id" @keyup.enter="loadRedemptions" />
|
||||
<button class="btn btn-secondary" type="button" @click="loadRedemptions">过滤</button>
|
||||
</div>
|
||||
<DataGrid v-if="viewMode === 'list' && redemptions.length" :columns="redemptionColumns" :rows="redemptions" :row-key="redemptionKey" :interactive="false" empty-text="暂无兑换记录。">
|
||||
<template #cell-time="{ row }">{{ formatDate((row as RedeemRedemptionSummary).createdAt) }}<small>{{ (row as RedeemRedemptionSummary).id }}</small></template>
|
||||
<template #cell-user="{ row }"><code>{{ (row as RedeemRedemptionSummary).userId }}</code></template>
|
||||
<template #cell-code="{ row }"><code>{{ (row as RedeemRedemptionSummary).codePrefix }}</code><small>{{ (row as RedeemRedemptionSummary).codeId }}</small></template>
|
||||
<template #cell-credits="{ row }">{{ formatNumber((row as RedeemRedemptionSummary).creditAmount) }}</template>
|
||||
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as RedeemRedemptionSummary).status">{{ (row as RedeemRedemptionSummary).status }}</span></template>
|
||||
<template #cell-ledger="{ row }"><code>{{ (row as RedeemRedemptionSummary).ledgerId || "-" }}</code></template>
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="redemptions.length" :items="redemptions" :item-key="redemptionKey" view="cards" :show-controls="false" aria-label="兑换记录卡片">
|
||||
<template #card="{ item: row }"><article class="admin-billing-card">
|
||||
<header><span class="billing-glyph" aria-hidden="true">RD</span><div><strong>{{ row.codePrefix || row.codeId }}</strong><small>{{ formatDate(row.createdAt) }} / {{ row.id }}</small></div><span class="status-pill" :data-status="row.status">{{ row.status }}</span></header>
|
||||
<dl><div><dt>User</dt><dd>{{ row.userId }}</dd></div><div><dt>Credits</dt><dd>{{ formatNumber(row.creditAmount) }}</dd></div><div><dt>Ledger</dt><dd>{{ row.ledgerId || '-' }}</dd></div></dl>
|
||||
</article></template>
|
||||
</ResourceCollection>
|
||||
<p v-else class="muted-box">暂无兑换记录。</p>
|
||||
</section>
|
||||
|
||||
<BaseDialog :open="createOpen" title="创建兑换码" description="Code 仅在创建响应中显示一次。" wide @close="createdCode = null; createOpen = false">
|
||||
<form class="admin-billing-create" @submit.prevent="createCode">
|
||||
<div class="form-grid two-col">
|
||||
<label>名称<input v-model="form.displayName" class="input" placeholder="Campaign / manual recharge" /></label>
|
||||
<label>Credits<input v-model.number="form.creditAmount" class="input" type="number" min="1" /></label>
|
||||
<label>原因<input v-model="form.reason" class="input" /></label>
|
||||
<label>最大兑换次数<input v-model.number="form.maxRedemptions" class="input" type="number" min="1" /></label>
|
||||
<label>每用户限制<input v-model.number="form.perUserLimit" class="input" type="number" min="1" /></label>
|
||||
<label>过期时间<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>
|
||||
</form>
|
||||
<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>
|
||||
<p class="muted-box">两个接口都使用后端支持的 `limit=100` 有界读取;契约未提供 cursor、nextCursor 或 total,因此不伪造分页。</p>
|
||||
<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>
|
||||
</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>
|
||||
</AsyncBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-billing-page { min-width: 0; }
|
||||
.admin-billing-commandbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #c9d5db; border-radius: 8px; background: #17343c; padding: 10px 12px; color: #efffff; box-shadow: inset 3px 0 #21afa7; }
|
||||
.admin-billing-commandbar div { display: grid; gap: 4px; }
|
||||
.admin-billing-commandbar span { color: #7fd2cc; font: 850 9px/1 ui-monospace, monospace; letter-spacing: .12em; }
|
||||
.admin-billing-commandbar strong { font-size: 13px; }
|
||||
.admin-billing-commandbar small { color: #abc8c6; font-size: 10px; }
|
||||
.admin-billing-commandbar button { min-height: 34px; border: 1px solid #72bcb7; border-radius: 6px; background: #efffff; padding: 0 12px; color: #155e5b; font-weight: 800; cursor: pointer; }
|
||||
.admin-billing-tabs { display: flex; gap: 4px; border-bottom: 1px solid #cbd6dc; }
|
||||
.admin-billing-tabs button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 0 11px; color: #596b74; font-weight: 800; cursor: pointer; }
|
||||
.admin-billing-tabs button[data-active="true"] { border-bottom-color: #168d88; color: #145f5c; }
|
||||
.admin-billing-tabs span { display: inline-grid; min-width: 20px; height: 18px; place-items: center; border-radius: 9px; background: #e5eeee; padding: 0 6px; font-size: 9px; }
|
||||
.admin-billing-surface { min-width: 0; overflow: auto; box-shadow: inset 3px 0 #1ba39e; }
|
||||
.admin-billing-heading-actions { display: flex; align-items: center; gap: 9px; }
|
||||
.admin-billing-view-toggle { display: inline-flex; padding: 2px; border: 1px solid #b8c5cc; border-radius: 7px; background: #eef3f4; }
|
||||
.admin-billing-view-toggle button { height: 26px; border: 0; border-radius: 5px; background: transparent; padding: 0 10px; color: #64757d; font-size: 10px; font-weight: 800; }
|
||||
.admin-billing-view-toggle button[data-active="true"] { background: #183b43; color: #fff; }
|
||||
.admin-billing-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 9px; margin-top: 10px; }
|
||||
.admin-billing-card { display: grid; gap: 12px; min-width: 0; border: 1px solid #c7d3d8; border-radius: 8px; background: #fff; padding: 12px; box-shadow: inset 3px 0 #90a6ad; }
|
||||
.admin-billing-card header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; gap: 9px; align-items: center; }
|
||||
.admin-billing-card header > div { display: grid; min-width: 0; }
|
||||
.admin-billing-card header code,
|
||||
.admin-billing-card header strong,
|
||||
.admin-billing-card header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.billing-glyph { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 6px; background: #dcefed; color: #126f6a; font: 900 10px/1 ui-monospace, monospace; }
|
||||
.admin-billing-card dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin: 0; }
|
||||
.admin-billing-card dl div { display: grid; gap: 3px; min-width: 0; border-top: 1px solid #e0e7e9; padding-top: 7px; }
|
||||
.admin-billing-card dt { color: #708087; font-size: 9px; font-weight: 850; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.admin-billing-card dd { overflow: hidden; margin: 0; font: 750 10px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-billing-card footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.admin-billing-create { display: grid; gap: 12px; }
|
||||
.created-redeem-code { display: grid; gap: 7px; border: 1px solid #d8b45f; border-radius: 7px; background: #fff8de; padding: 11px; color: #604b17; }
|
||||
.created-redeem-code code { overflow-wrap: anywhere; }
|
||||
:deep(.admin-billing-surface table) { min-width: 720px; }
|
||||
@media (max-width: 640px) {
|
||||
.admin-billing-commandbar { align-items: stretch; flex-direction: column; }
|
||||
.admin-billing-heading-actions { align-items: flex-end; flex-direction: column; }
|
||||
.admin-billing-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { adminSecretsAPI } from "@/api";
|
||||
import AdminSecretsPlanPanel from "./AdminSecretsPlanPanel.vue";
|
||||
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import FactPath from "@/components/console/FactPath.vue";
|
||||
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.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 { normalizeAdminSecretsRows, normalizeAdminSecretsSummary, type AdminSecretsRow, type AdminSecretsSummaryView } from "@/stores/admin-secrets-view";
|
||||
import type { ApiError, ErrorDiagnostic } from "@/types";
|
||||
|
||||
const columns: DataGridColumn[] = [
|
||||
{ key: "name", label: "NAME" },
|
||||
{ key: "status", label: "STATUS" },
|
||||
{ key: "namespace", label: "NAMESPACE" },
|
||||
{ key: "cluster", label: "CLUSTER" },
|
||||
{ key: "targetSecret", label: "K8S SECRET" },
|
||||
{ key: "store", label: "SECRET STORE" },
|
||||
{ key: "age", label: "AGE" }
|
||||
];
|
||||
import AdminSecretsPlanPanel from "./AdminSecretsPlanPanel.vue";
|
||||
|
||||
const loading = ref(true);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const viewState = useConsoleViewState({ filter: "all" });
|
||||
const refreshing = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const apiError = ref<ApiError | null>(null);
|
||||
@@ -39,198 +26,101 @@ const diagnostic = ref<ErrorDiagnostic | null>(null);
|
||||
const summary = ref<AdminSecretsSummaryView | null>(null);
|
||||
const rows = ref<AdminSecretsRow[]>([]);
|
||||
const planPanelOpen = ref(false);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const viewState = useConsoleViewState<"flow" | "list">({ view: "flow", viewValues: ["flow", "list"], filter: "all" });
|
||||
const canPlan = computed(() => summary.value?.permissions.plan === true);
|
||||
const updatedText = computed(() => displayDate(summary.value?.generatedAt ?? ""));
|
||||
const query = computed({
|
||||
get: () => viewState.q.value,
|
||||
set: (value: string) => void viewState.update({ q: value.trim() })
|
||||
});
|
||||
const statusFilter = computed({
|
||||
get: () => viewState.filter.value || "all",
|
||||
set: (value: string) => void viewState.update({ filter: value === "all" ? "" : value })
|
||||
});
|
||||
const viewMode = computed<"cards" | "list">({
|
||||
get: () => viewState.view.value,
|
||||
set: (value) => void viewState.update({ view: value === "cards" ? undefined : value })
|
||||
});
|
||||
const filteredRows = computed(() => {
|
||||
const needle = query.value.trim().toLowerCase();
|
||||
return rows.value.filter((row) => {
|
||||
if (statusFilter.value !== "all" && row.status.toLowerCase() !== statusFilter.value.toLowerCase()) return false;
|
||||
return !needle || [row.name, row.namespace, row.cluster, row.targetSecretName, row.storeName, row.status, row.reason].join(" ").toLowerCase().includes(needle);
|
||||
});
|
||||
});
|
||||
const asyncState = computed(() => {
|
||||
if (loading.value || refreshing.value) return summary.value ? "refreshing" : "initial-loading";
|
||||
if (error.value) return summary.value ? "partial" : "error";
|
||||
return summary.value ? "ready" : "empty";
|
||||
const query = computed({ get: () => viewState.q.value, set: (value: string) => void viewState.update({ q: value.trim() }) });
|
||||
const statusFilter = computed({ get: () => viewState.filter.value || "all", set: (value: string) => void viewState.update({ filter: value === "all" ? "" : value }) });
|
||||
const viewMode = computed({ get: () => viewState.view.value, set: (value: "flow" | "list") => void viewState.update({ view: value === "flow" ? undefined : value }, false) });
|
||||
const filteredRows = computed(() => rows.value.filter((row) => {
|
||||
if (statusFilter.value !== "all" && row.status.toLowerCase() !== statusFilter.value.toLowerCase()) return false;
|
||||
const needle = query.value.toLowerCase();
|
||||
return !needle || [row.name, row.namespace, row.cluster, row.targetSecretName, row.storeName, row.status, row.reason].join(" ").toLowerCase().includes(needle);
|
||||
}));
|
||||
const selected = computed(() => {
|
||||
const namespace = typeof route.params.namespace === "string" ? route.params.namespace : "";
|
||||
const name = typeof route.params.name === "string" ? route.params.name : "";
|
||||
return rows.value.find((row) => row.namespace === namespace && row.name === name) ?? filteredRows.value[0] ?? null;
|
||||
});
|
||||
const asyncState = computed(() => loading.value || refreshing.value ? (summary.value ? "refreshing" : "initial-loading") : error.value ? (summary.value ? "partial" : "error") : summary.value ? "ready" : "empty");
|
||||
const statusItems = computed(() => [
|
||||
{ id: "objects", label: "ExternalSecrets", value: rows.value.length },
|
||||
{ id: "ready", label: "Ready", value: rows.value.filter((row) => row.status.toLowerCase() === "ready").length, tone: "ok" as const },
|
||||
{ id: "attention", label: "Attention", value: rows.value.filter((row) => !["ready", "unknown"].includes(row.status.toLowerCase())).length, tone: "warn" as const },
|
||||
{ id: "authority", label: "Values", value: "redacted", tone: "info" as const }
|
||||
]);
|
||||
const factSteps = computed(() => selected.value ? [
|
||||
{ id: "source", label: "sourceRef / store", value: `${selected.value.storeKind}/${selected.value.storeName}` },
|
||||
{ id: "external", label: "ExternalSecret", value: `${selected.value.namespace}/${selected.value.name}`, tone: selected.value.status.toLowerCase() === "ready" ? "ok" as const : "warn" as const },
|
||||
{ id: "target", label: "target Secret", value: selected.value.targetSecretName || "-" },
|
||||
{ id: "consumer", label: "consumer", value: selected.value.cluster || "cluster workload" }
|
||||
] : []);
|
||||
|
||||
onMounted(() => void reload());
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
refreshing.value = true;
|
||||
if (!summary.value) loading.value = true;
|
||||
error.value = null;
|
||||
apiError.value = null;
|
||||
diagnostic.value = null;
|
||||
const [summaryResult, listResult] = await Promise.all([
|
||||
adminSecretsAPI.summary(),
|
||||
adminSecretsAPI.externalSecrets()
|
||||
]);
|
||||
if (!summaryResult.ok) {
|
||||
error.value = summaryResult.error ?? `HTTP ${summaryResult.status}`;
|
||||
apiError.value = summaryResult.apiError ?? null;
|
||||
diagnostic.value = summaryResult.diagnostic ?? null;
|
||||
} else {
|
||||
error.value = null; apiError.value = null; diagnostic.value = null;
|
||||
const [summaryResult, listResult] = await Promise.all([adminSecretsAPI.summary(), adminSecretsAPI.externalSecrets()]);
|
||||
if (!summaryResult.ok) { error.value = summaryResult.error ?? `HTTP ${summaryResult.status}`; apiError.value = summaryResult.apiError ?? null; diagnostic.value = summaryResult.diagnostic ?? null; }
|
||||
else {
|
||||
summary.value = normalizeAdminSecretsSummary(summaryResult.data);
|
||||
const listItems = listResult.ok ? listResult.data?.items : summaryResult.data?.externalSecrets;
|
||||
rows.value = normalizeAdminSecretsRows(listItems);
|
||||
if (!listResult.ok) {
|
||||
error.value = listResult.error ?? `HTTP ${listResult.status}`;
|
||||
apiError.value = listResult.apiError ?? null;
|
||||
diagnostic.value = listResult.diagnostic ?? null;
|
||||
}
|
||||
rows.value = normalizeAdminSecretsRows(listResult.ok ? listResult.data?.items : summaryResult.data?.externalSecrets);
|
||||
if (!listResult.ok) { error.value = listResult.error ?? `HTTP ${listResult.status}`; apiError.value = listResult.apiError ?? null; diagnostic.value = listResult.diagnostic ?? null; }
|
||||
}
|
||||
loading.value = false;
|
||||
refreshing.value = false;
|
||||
loading.value = false; refreshing.value = false;
|
||||
}
|
||||
|
||||
function rowKey(row: AdminSecretsRow): string {
|
||||
return `${row.namespace}/${row.name}`;
|
||||
}
|
||||
|
||||
function detailLocation(row: AdminSecretsRow): { name: string; params: { namespace: string; name: string }; query: typeof route.query } {
|
||||
return { name: "AdminExternalSecretDetail", params: { namespace: row.namespace, name: row.name }, query: route.query };
|
||||
}
|
||||
|
||||
function openSecret(row: AdminSecretsRow): void {
|
||||
void router.push(detailLocation(row));
|
||||
}
|
||||
|
||||
function displayDate(value: string): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function rowKey(row: AdminSecretsRow): string { return `${row.namespace}/${row.name}`; }
|
||||
function openSecret(row: AdminSecretsRow): void { void router.push({ name: "AdminExternalSecretDetail", params: { namespace: row.namespace, name: row.name }, query: route.query }); }
|
||||
function displayDate(value: string): string { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN"); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack secrets-admin-page">
|
||||
<PageCommandBar eyebrow="Admin" title="密钥" description="ExternalSecrets 只读状态来自同源脱敏 read model;页面只展示对象名、状态、presence 与 fingerprint。" />
|
||||
|
||||
<AsyncBoundary :state="asyncState" :has-content="Boolean(summary)" label="加载 ExternalSecrets" empty-title="暂无 Secret read model" empty-description="同源脱敏 API 当前未返回摘要。" @retry="reload">
|
||||
<template #error><section class="data-panel admin-crud-panel">
|
||||
<EmptyState title="密钥状态加载失败" :description="error || '请求未完成'" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="reload" />
|
||||
<ApiErrorDiagnostic :error="error || '请求未完成'" :api-error="apiError" :diagnostic="diagnostic" compact />
|
||||
</section></template>
|
||||
|
||||
<template v-if="summary">
|
||||
<section class="secrets-compact-toolbar" aria-label="ExternalSecrets actions">
|
||||
<div>
|
||||
<strong>ExternalSecrets</strong>
|
||||
<small>{{ rows.length }} rows / updated {{ updatedText }} / valuesPrinted={{ String(summary.valuesPrinted) }}</small>
|
||||
</div>
|
||||
<div class="table-actions-inline">
|
||||
<button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button>
|
||||
<button v-if="canPlan" class="table-action" type="button" data-testid="admin-secrets-open-plan" @click="planPanelOpen = !planPanelOpen">{{ planPanelOpen ? "收起 Plan" : "Plan" }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="secrets-filter-bar" aria-label="ExternalSecrets filters">
|
||||
<label><span>搜索</span><input v-model="query" type="search" placeholder="名称、namespace、cluster 或 target" /></label>
|
||||
<label><span>状态</span><select v-model="statusFilter"><option value="all">全部</option><option value="ready">ready</option><option value="pending">pending</option><option value="error">error</option><option value="unknown">unknown</option></select></label>
|
||||
<small>{{ filteredRows.length }} / {{ rows.length }} objects</small>
|
||||
<span class="secrets-view-toggle"><button type="button" :data-active="viewMode === 'cards'" @click="viewMode = 'cards'">卡片</button><button type="button" :data-active="viewMode === 'list'" @click="viewMode = 'list'">列表</button></span>
|
||||
</section>
|
||||
|
||||
<AdminSecretsPlanPanel v-if="canPlan && planPanelOpen" :summary="summary" />
|
||||
|
||||
<TablePageLayout class="secrets-table-page" title="ExternalSecrets" subtitle="NAME / STATUS / NAMESPACE / CLUSTER / K8S SECRET / SECRET STORE / AGE">
|
||||
<template #actions>
|
||||
<StatusBadge :status="summary.status" :label="summary.status" />
|
||||
<PageCommandBar eyebrow="Admin" title="密钥库存与流向" description="只展示 ExternalSecret 脱敏 read model;Secret 值永不进入浏览器。">
|
||||
<template #actions><button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button><button v-if="canPlan" class="table-action" type="button" @click="planPanelOpen = !planPanelOpen">{{ planPanelOpen ? "关闭 Plan" : "Plan" }}</button></template>
|
||||
</PageCommandBar>
|
||||
<AsyncBoundary :state="asyncState" :has-content="Boolean(summary)" label="加载密钥库存" empty-title="暂无密钥库存" empty-description="当前 admin secrets read model 为空。" @retry="reload">
|
||||
<template #error><EmptyState title="密钥库存加载失败" :description="error || '请求未完成'" action-label="重试" @action="reload" /></template>
|
||||
<ApiErrorDiagnostic v-if="error && summary" :api-error="apiError" :diagnostic="diagnostic" />
|
||||
<StatusStrip :items="statusItems" live="polite" />
|
||||
<AdminSecretsPlanPanel v-if="canPlan && planPanelOpen && summary" :summary="summary" />
|
||||
<OperationsWorkspace class="secrets-operations" label="ExternalSecret 库存工作区">
|
||||
<template #index>
|
||||
<EntityIndex :items="filteredRows" :item-key="rowKey" :selected-id="selected ? rowKey(selected) : null" label="ExternalSecret 库存" @select="openSecret">
|
||||
<template #toolbar><label class="operations-search"><span>ExternalSecret 库存</span><input v-model="query" type="search" placeholder="名称、namespace、cluster 或 target" /></label><select v-model="statusFilter" class="input"><option value="all">全部状态</option><option value="ready">ready</option><option value="pending">pending</option><option value="error">error</option><option value="unknown">unknown</option></select><small>{{ filteredRows.length }} / {{ rows.length }} objects</small></template>
|
||||
<template #item="{ item }"><strong>{{ item.name }}</strong><small>{{ item.namespace }} · {{ item.cluster }}</small><span class="entity-index-meta"><StatusBadge :status="item.status" :label="item.status" /><code>{{ item.targetSecretName || "no target" }}</code></span></template>
|
||||
</EntityIndex>
|
||||
</template>
|
||||
<DataGrid v-if="viewMode === 'list'" class="secrets-external-table" :columns="columns" :rows="filteredRows" :row-key="rowKey" empty-text="当前筛选没有 ExternalSecret read model。" @select="openSecret">
|
||||
<template #cell-name="{ row }">
|
||||
<RouterLink class="secrets-name-link" :to="detailLocation(row as AdminSecretsRow)">
|
||||
<strong>{{ (row as AdminSecretsRow).name }}</strong>
|
||||
<small>{{ (row as AdminSecretsRow).refreshInterval }}</small>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<StatusBadge :status="(row as AdminSecretsRow).status" :label="(row as AdminSecretsRow).status" />
|
||||
<small>{{ (row as AdminSecretsRow).reason }}</small>
|
||||
</template>
|
||||
<template #cell-namespace="{ row }">
|
||||
<code>{{ (row as AdminSecretsRow).namespace }}</code>
|
||||
</template>
|
||||
<template #cell-cluster="{ row }">
|
||||
{{ (row as AdminSecretsRow).cluster }}
|
||||
</template>
|
||||
<template #cell-targetSecret="{ row }">
|
||||
<code>{{ (row as AdminSecretsRow).targetSecretName }}</code>
|
||||
<small>{{ (row as AdminSecretsRow).targetSecretResourceVersion }}</small>
|
||||
</template>
|
||||
<template #cell-store="{ row }">
|
||||
<code>{{ (row as AdminSecretsRow).storeKind }}/{{ (row as AdminSecretsRow).storeName }}</code>
|
||||
<small>{{ (row as AdminSecretsRow).storeScope }}</small>
|
||||
</template>
|
||||
<template #cell-age="{ row }">
|
||||
{{ (row as AdminSecretsRow).ageText }}
|
||||
<small>{{ displayDate((row as AdminSecretsRow).lastUpdated) }}</small>
|
||||
</template>
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="filteredRows.length" :items="filteredRows" :item-key="rowKey" view="cards" :show-controls="false" aria-label="ExternalSecret 卡片" @select="openSecret">
|
||||
<template #card="{ item: row }"><RouterLink class="secret-card" :to="detailLocation(row)">
|
||||
<header><span class="secret-glyph" aria-hidden="true">ES</span><div><strong>{{ row.name }}</strong><small>{{ row.namespace }} / {{ row.cluster }}</small></div><StatusBadge :status="row.status" :label="row.status" /></header>
|
||||
<dl><div><dt>K8s Secret</dt><dd>{{ row.targetSecretName || '-' }}</dd></div><div><dt>Secret Store</dt><dd>{{ row.storeKind }}/{{ row.storeName }}</dd></div><div><dt>Age</dt><dd>{{ row.ageText }}</dd></div></dl>
|
||||
<footer><span>{{ row.reason || row.refreshInterval || 'status reported' }}</span><strong>检查摘要 →</strong></footer>
|
||||
</RouterLink></template>
|
||||
</ResourceCollection>
|
||||
<EmptyState v-else title="没有匹配 ExternalSecret" description="调整搜索或状态;页面不会从运行面反解 Secret 值。" />
|
||||
</TablePageLayout>
|
||||
</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>
|
||||
<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 元数据。">
|
||||
<dl class="fact-grid"><div><dt>Status</dt><dd><StatusBadge :status="selected.status" :label="selected.status" /></dd></div><div><dt>Reason</dt><dd>{{ selected.reason || "-" }}</dd></div><div><dt>Refresh</dt><dd>{{ selected.refreshInterval || "-" }}</dd></div><div><dt>Age</dt><dd>{{ selected.ageText }}</dd></div><div><dt>Store scope</dt><dd>{{ selected.storeScope || "-" }}</dd></div><div><dt>Resource version</dt><dd><code>{{ selected.targetSecretResourceVersion || "-" }}</code></dd></div><div><dt>Last updated</dt><dd>{{ displayDate(selected.lastUpdated) }}</dd></div><div><dt>Generated</dt><dd>{{ displayDate(summary?.generatedAt || "") }}</dd></div></dl>
|
||||
</SectionFrame>
|
||||
<SectionFrame title="安全边界" description="sourceRef/store → ExternalSecret → target Secret → consumer;不从运行面反解。"><p class="muted-box">本页不展示 Secret 值、原始 provider payload 或可逆凭据。Plan、事件、fingerprint 与原始诊断仅在授权详情层披露。</p><button class="table-action" type="button" @click="openSecret(selected)">打开详情 Inspector</button></SectionFrame>
|
||||
</div>
|
||||
<EmptyState v-else title="没有匹配对象" description="调整库存筛选。" />
|
||||
</OperationsWorkspace>
|
||||
</AsyncBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.secrets-admin-page { min-width: 0; }
|
||||
.secrets-compact-toolbar { border-color: #cbd6dc; border-radius: 8px; background: #17343c; color: #efffff; box-shadow: inset 3px 0 #21afa7; }
|
||||
.secrets-compact-toolbar small { color: #a9c9c7; }
|
||||
.secrets-filter-bar { display: grid; grid-template-columns: minmax(240px, 1fr) 170px auto auto; gap: 8px; align-items: end; border: 1px solid #cbd6dc; border-radius: 8px; background: #f4f7f8; padding: 9px 10px; }
|
||||
.secrets-filter-bar label { display: grid; gap: 4px; color: #60717a; font-size: 9px; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.secrets-filter-bar input,
|
||||
.secrets-filter-bar select { width: 100%; height: 34px; border: 1px solid #b8c5cc; border-radius: 6px; background: #fff; padding: 0 9px; color: #1c3038; }
|
||||
.secrets-filter-bar small { align-self: center; color: #667780; font: 750 10px/1 ui-monospace, monospace; }
|
||||
.secrets-view-toggle { display: inline-flex; padding: 2px; border: 1px solid #b8c5cc; border-radius: 7px; background: #e8eef0; }
|
||||
.secrets-view-toggle button { height: 28px; border: 0; border-radius: 5px; background: transparent; padding: 0 11px; color: #64757d; font-size: 10px; font-weight: 800; }
|
||||
.secrets-view-toggle button[data-active="true"] { background: #183b43; color: #fff; }
|
||||
.secrets-table-page { min-width: 0; box-shadow: inset 3px 0 #1ba39e; }
|
||||
.secrets-name-link { color: #176d6a; text-decoration: none; }
|
||||
.secrets-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 9px; padding: 10px; }
|
||||
.secret-card { display: grid; gap: 12px; min-width: 0; border: 1px solid #c7d3d8; border-radius: 8px; background: #fff; padding: 12px; color: #1c3038; text-decoration: none; box-shadow: inset 3px 0 #90a6ad; }
|
||||
.secret-card:hover { border-color: #1ba39e; box-shadow: inset 3px 0 #1ba39e, 0 4px 12px rgb(19 60 68 / 8%); }
|
||||
.secret-card header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; gap: 9px; align-items: center; }
|
||||
.secret-card header > div { display: grid; min-width: 0; }
|
||||
.secret-card header strong,
|
||||
.secret-card header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.secret-card header small { color: #6b7c84; }
|
||||
.secret-glyph { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 6px; background: #dcefed; color: #126f6a; font: 900 10px/1 ui-monospace, monospace; }
|
||||
.secret-card dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin: 0; }
|
||||
.secret-card dl div { display: grid; gap: 3px; min-width: 0; border-top: 1px solid #e0e7e9; padding-top: 7px; }
|
||||
.secret-card dt { color: #708087; font-size: 9px; font-weight: 850; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.secret-card dd { overflow: hidden; margin: 0; font: 750 10px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.secret-card footer { display: flex; justify-content: space-between; gap: 8px; color: #677981; font-size: 10px; }
|
||||
.secret-card footer span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.secret-card footer strong { flex: none; color: #176d6a; }
|
||||
@media (max-width: 680px) {
|
||||
.secrets-filter-bar { grid-template-columns: 1fr; }
|
||||
.secrets-view-toggle { justify-self: stretch; }
|
||||
.secrets-view-toggle button { flex: 1; }
|
||||
.secrets-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.secrets-operations { min-height: 610px; }
|
||||
.operations-search { display: grid; gap: 5px; font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.operations-search input { height: 34px; min-width: 0; border: 1px solid #b8c5cc; border-radius: 5px; padding: 0 9px; }
|
||||
.entity-index-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 4px; }
|
||||
.entity-index-meta code { max-width: 150px; overflow: hidden; text-overflow: ellipsis; color: #64757d; font-size: 10px; }
|
||||
.operations-main-stack { display: grid; gap: 14px; }
|
||||
.view-mode-row { display: flex; justify-content: flex-end; }
|
||||
.secret-compact-list { overflow: auto; }
|
||||
.secret-compact-list button { display: grid; grid-template-columns: minmax(190px, 1.4fr) 90px minmax(160px, 1fr) minmax(140px, 1fr) 70px; gap: 9px; width: 100%; min-width: 720px; align-items: center; border: 0; border-bottom: 1px solid #d8e1e4; padding: 9px 11px; background: white; color: inherit; text-align: left; cursor: pointer; }
|
||||
.secret-compact-list button:hover, .secret-compact-list button[data-selected="true"] { background: #e8f6f5; }
|
||||
.fact-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; background: #d8e1e4; }
|
||||
.fact-grid div { min-width: 0; padding: 11px; background: white; }
|
||||
.fact-grid dt { color: #64757d; font-size: 10px; text-transform: uppercase; }
|
||||
.fact-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; }
|
||||
@media (max-width: 640px) { .fact-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import Pagination from "@/components/common/Pagination.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AdminUsageRow, ApiKeyRecord, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, UsageLedgerRow, UsageReservationRow } from "@/types";
|
||||
|
||||
@@ -460,7 +461,7 @@ function metadataPreview(value: unknown): string {
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="users.length" :items="users" :item-key="userKey" view="cards" :selected-id="selectedUserId" :show-controls="false" aria-label="用户卡片" @select="(item) => selectUser(item.user?.id)">
|
||||
<template #card="{ item }"><article class="admin-user-card" :data-selected="selectedUserId === item.user?.id">
|
||||
<span class="admin-user-glyph" aria-hidden="true">{{ item.user?.role === 'admin' ? 'AD' : 'US' }}</span>
|
||||
<span class="admin-user-glyph" aria-hidden="true"><ConsoleIcon name="users" :size="19" /></span>
|
||||
<div class="admin-user-card-name"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId ?? 'default' }}</small></div>
|
||||
<span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span>
|
||||
<dl><div><dt>Available</dt><dd>{{ formatNumber(item.credits?.available) }}<small>{{ formatNumber(item.credits?.balance) }} balance · {{ formatNumber(item.credits?.reserved) }} reserved</small></dd></div><div><dt>Usage</dt><dd>{{ formatNumber(item.usage?.totalCredits) }}<small>{{ formatNumber(item.usage?.recordCount) }} records</small></dd></div><div><dt>Keys</dt><dd>{{ formatNumber(item.apiKeys?.activeCount) }}<small>{{ formatNumber(item.apiKeys?.totalCount) }} total</small></dd></div></dl>
|
||||
|
||||
Reference in New Issue
Block a user