refactor(web): 重写非 Workbench 页面组件树
This commit is contained in:
@@ -91,9 +91,9 @@ test("R6 common module map keeps HWLAB views on shared console primitives", () =
|
||||
[
|
||||
"AsyncBoundary",
|
||||
"OperationsWorkspace",
|
||||
"EntityIndex",
|
||||
"InspectorPanel",
|
||||
"ViewModeSwitch",
|
||||
"AccessUserIndex",
|
||||
"AccessPermissionWorkspace",
|
||||
"AccessUserInspector",
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -101,9 +101,8 @@ test("R6 common module map keeps HWLAB views on shared console primitives", () =
|
||||
[
|
||||
"AsyncBoundary",
|
||||
"OperationsWorkspace",
|
||||
"EntityIndex",
|
||||
"FactPath",
|
||||
"ViewModeSwitch",
|
||||
"SecretInventoryIndex",
|
||||
"SecretInventoryWorkspace",
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -111,8 +110,9 @@ test("R6 common module map keeps HWLAB views on shared console primitives", () =
|
||||
[
|
||||
"AsyncBoundary",
|
||||
"OperationsWorkspace",
|
||||
"EntityIndex",
|
||||
"InspectorPanel",
|
||||
"UserDirectoryIndex",
|
||||
"UserDirectoryCollection",
|
||||
"UserAccountInspector",
|
||||
"ViewModeSwitch",
|
||||
],
|
||||
],
|
||||
@@ -121,8 +121,8 @@ test("R6 common module map keeps HWLAB views on shared console primitives", () =
|
||||
[
|
||||
"AsyncBoundary",
|
||||
"OperationsWorkspace",
|
||||
"EntityIndex",
|
||||
"InspectorPanel",
|
||||
"BillingCodeIndex",
|
||||
"BillingCodeInspector",
|
||||
"ViewModeSwitch",
|
||||
],
|
||||
],
|
||||
@@ -228,16 +228,16 @@ test("R2 console rewrite owns the final visual layer and removes private letter
|
||||
for (const icon of ["menu", "info", "logout", "empty", "project", "key", "provider", "skills"])
|
||||
assert.match(iconSource, new RegExp(`\\b${icon}:`, "u"), `ConsoleIcon should define ${icon}`);
|
||||
|
||||
const migratedViews = [
|
||||
"src/components/common/EmptyState.vue",
|
||||
"src/views/SkillsView.vue",
|
||||
"src/views/projects/ProjectsView.vue",
|
||||
"src/views/admin/ProviderProfilesView.vue",
|
||||
"src/views/user/ApiKeysView.vue",
|
||||
const migratedViews: Array<[string, string]> = [
|
||||
["src/components/common/EmptyState.vue", "ConsoleIcon"],
|
||||
["src/views/SkillsView.vue", "EntityCard"],
|
||||
["src/views/projects/ProjectsView.vue", "ProjectResourceCard"],
|
||||
["src/views/admin/ProviderProfilesView.vue", "EntityCard"],
|
||||
["src/views/user/ApiKeysView.vue", "ApiKeyResourceCard"],
|
||||
];
|
||||
for (const relativePath of migratedViews) {
|
||||
for (const [relativePath, sharedComponent] of migratedViews) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /ConsoleIcon/u, `${relativePath} should use the shared icon system`);
|
||||
assert.match(source, new RegExp(sharedComponent, "u"), `${relativePath} should use ${sharedComponent}`);
|
||||
assert.doesNotMatch(source, />\s*(?:SK|PJ|AI|KEY|HW)\s*</u, `${relativePath} should not render private letter glyphs`);
|
||||
}
|
||||
|
||||
@@ -258,6 +258,44 @@ test("R2 console rewrite owns the final visual layer and removes private letter
|
||||
assert.match(cardSource, /entity-card-meta/u);
|
||||
});
|
||||
|
||||
test("R2 page rewrites move business markup into owned modules instead of CSS wrappers", () => {
|
||||
const moduleExpectations: Array<[string, string[]]> = [
|
||||
["src/components/access/AccessUserIndex.vue", ["EntityIndex", "AccessUserRow"]],
|
||||
["src/components/access/AccessPermissionWorkspace.vue", ["permission-matrix", "FactPath", "ViewModeSwitch"]],
|
||||
["src/components/access/AccessUserInspector.vue", ["InspectorPanel", "Tool grants", "Tuples"]],
|
||||
["src/components/secrets/SecretInventoryIndex.vue", ["EntityIndex", "StatusBadge"]],
|
||||
["src/components/secrets/SecretInventoryWorkspace.vue", ["FactPath", "SectionFrame", "安全边界"]],
|
||||
["src/components/users/UserDirectoryIndex.vue", ["EntityIndex", "Pagination"]],
|
||||
["src/components/users/UserDirectoryCollection.vue", ["ConsoleIcon", "DataGrid", "用户实体网格"]],
|
||||
["src/components/users/UserAccountInspector.vue", ["InspectorPanel", "entitlements", "reservations", "ledger"]],
|
||||
["src/components/billing/BillingCodeIndex.vue", ["EntityIndex", "RedeemCodeSummary"]],
|
||||
["src/components/billing/BillingCodeInspector.vue", ["InspectorPanel", "Credits", "Usage"]],
|
||||
["src/components/hwpod/HwpodDevicesWorkspace.vue", ["HwpodDeviceCard", "DataGrid", "ResourceCollection"]],
|
||||
["src/components/hwpod/HwpodNodesWorkspace.vue", ["挂载 HWPOD", "HwpodReadinessRail", "DataGrid"]],
|
||||
["src/components/hwpod/HwpodOnboardingWorkspace.vue", ["Python single file", "SHA-256", "Staged readiness"]],
|
||||
["src/components/common/OverlaySurface.vue", ["useOverlaySurface", "ConsoleIcon", "Teleport"]],
|
||||
["src/components/mdtodo/MdtodoTaskPanel.vue", ["ContentViewer", "editingBody", "Markdown"]],
|
||||
["src/components/mdtodo/MdtodoReportPanel.vue", ["ContentViewer", "openFullscreen", "closeFullscreen"]],
|
||||
];
|
||||
for (const [relativePath, terms] of moduleExpectations) {
|
||||
const source = read(relativePath);
|
||||
for (const term of terms) assert.match(source, new RegExp(term, "u"), `${relativePath} should contain ${term}`);
|
||||
}
|
||||
|
||||
const cardMigrations: Array<[string, string]> = [
|
||||
["src/views/SkillsView.vue", "EntityCard"],
|
||||
["src/views/admin/ProviderProfilesView.vue", "EntityCard"],
|
||||
["src/views/projects/ProjectsView.vue", "ProjectResourceCard"],
|
||||
["src/views/user/ApiKeysView.vue", "ApiKeyResourceCard"],
|
||||
];
|
||||
for (const [relativePath, component] of cardMigrations) assert.match(read(relativePath), new RegExp(component, "u"));
|
||||
|
||||
assert.doesNotMatch(read("src/views/SkillsView.vue"), /\.skill-card\b|\.skill-glyph\b/u);
|
||||
assert.doesNotMatch(read("src/views/admin/ProviderProfilesView.vue"), /\.provider-card\b|\.provider-glyph\b/u);
|
||||
assert.doesNotMatch(read("src/views/projects/ProjectsView.vue"), /\.project-card\b|\.project-glyph\b/u);
|
||||
assert.doesNotMatch(read("src/views/user/ApiKeysView.vue"), /\.api-key-card\b|\.api-key-glyph\b/u);
|
||||
});
|
||||
|
||||
test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => {
|
||||
const issueBody = readIssueBodyFixture();
|
||||
assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u);
|
||||
|
||||
@@ -3,13 +3,11 @@ import { computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.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 AccessPermissionWorkspace from "./AccessPermissionWorkspace.vue";
|
||||
import AccessUserIndex from "./AccessUserIndex.vue";
|
||||
import AccessUserInspector from "./AccessUserInspector.vue";
|
||||
import { useConsoleViewState } from "@/composables/useConsoleViewState";
|
||||
import { useAccessStore } from "@/stores/access";
|
||||
import {
|
||||
@@ -135,16 +133,14 @@ function closeInspector(): void {
|
||||
access.clearSelection();
|
||||
void router.push({ name: "Access", query: route.query });
|
||||
}
|
||||
function onRoleChange(event: Event): void {
|
||||
const role = (event.target as HTMLSelectElement).value as AccessUserRole;
|
||||
function onRoleChange(role: AccessUserRole): void {
|
||||
void access.updateSelectedUser({ role });
|
||||
}
|
||||
function onStatusChange(event: Event): void {
|
||||
const status = (event.target as HTMLSelectElement).value as AccessUserStatus;
|
||||
function onStatusChange(status: AccessUserStatus): void {
|
||||
void access.updateSelectedUser({ status });
|
||||
}
|
||||
function onToolChange(toolId: string, event: Event): void {
|
||||
void access.setTool(toolId, (event.target as HTMLInputElement).checked);
|
||||
function onToolChange(toolId: string, allowed: boolean): void {
|
||||
void access.setTool(toolId, allowed);
|
||||
}
|
||||
function valueText(value: unknown): string {
|
||||
return typeof value === "string" && value.trim()
|
||||
@@ -182,219 +178,18 @@ function valueText(value: unknown): string {
|
||||
: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>
|
||||
<AccessUserIndex v-model:query="query" :rows="filteredRows" :selected-id="access.selectedUserId" :refreshing="access.loading" @select="selectRow" @refresh="access.refresh" />
|
||||
</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: 'matrix' },
|
||||
{ value: 'list', label: '清单', icon: 'list' },
|
||||
]"
|
||||
/></template>
|
||||
<div
|
||||
v-if="viewMode === 'matrix'"
|
||||
class="permission-matrix"
|
||||
role="grid"
|
||||
aria-label="用户权限矩阵"
|
||||
:aria-colcount="access.toolRows.length + 1"
|
||||
:style="{ '--permission-tool-count': access.toolRows.length }"
|
||||
>
|
||||
<div class="permission-matrix-head" role="row">
|
||||
<span role="columnheader">用户</span
|
||||
><span
|
||||
v-for="tool in access.toolRows"
|
||||
:key="tool.id"
|
||||
role="columnheader"
|
||||
>{{ tool.label }}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
v-for="row in filteredRows"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
role="row"
|
||||
:data-selected="row.id === access.selectedUserId"
|
||||
@click="selectRow(row)"
|
||||
>
|
||||
<span role="gridcell"
|
||||
><strong>{{ accessUserLabel(row) }}</strong
|
||||
><small>{{ row.role }} · {{ row.status }}</small></span
|
||||
>
|
||||
<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>
|
||||
<AccessPermissionWorkspace v-model:view="viewMode" :rows="filteredRows" :tools="access.toolRows" :selected-id="access.selectedUserId" :fact-steps="factSteps" :openfga="openfga" @select="selectRow" />
|
||||
|
||||
<template #inspector>
|
||||
<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
|
||||
: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>
|
||||
<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>
|
||||
<AccessUserInspector v-if="access.selectedUser" :user="access.selectedUser" :tools="access.toolRows" :tuples="access.detailView.tuples" :mutation-error="access.mutationError" :saving="access.savingKey !== null" @close="closeInspector" @role="onRoleChange" @status="onStatusChange" @tool="onToolChange" />
|
||||
</template>
|
||||
</OperationsWorkspace>
|
||||
</AsyncBoundary>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.access-operations {
|
||||
min-height: 610px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import FactPath, { type FactPathStep } from "@/components/console/FactPath.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
import SectionFrame from "@/components/layout/SectionFrame.vue";
|
||||
import { accessUserLabel, type AccessToolRow, type AccessUserRow } from "@/stores/admin-access-view";
|
||||
|
||||
defineProps<{
|
||||
rows: AccessUserRow[];
|
||||
tools: AccessToolRow[];
|
||||
selectedId: string | null;
|
||||
factSteps: FactPathStep[];
|
||||
openfga: Record<string, unknown>;
|
||||
view: "matrix" | "list";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [row: AccessUserRow];
|
||||
"update:view": [value: "matrix" | "list"];
|
||||
}>();
|
||||
|
||||
function valueText(value: unknown): string {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "boolean") return value ? "yes" : "no";
|
||||
return "-";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="operations-main-stack">
|
||||
<FactPath :steps="factSteps" label="授权事实路径" />
|
||||
<SectionFrame :title="view === 'matrix' ? '权限矩阵' : '授权清单'" description="同一授权 read model 的矩阵比较与紧凑清单。" :body-padding="false">
|
||||
<template #actions>
|
||||
<ViewModeSwitch :model-value="view" :options="[{ value: 'matrix', label: '矩阵', icon: 'matrix' }, { value: 'list', label: '清单', icon: 'list' }]" @update:model-value="emit('update:view', $event as 'matrix' | 'list')" />
|
||||
</template>
|
||||
<div v-if="view === 'matrix'" class="permission-matrix" role="grid" aria-label="用户权限矩阵" :aria-colcount="tools.length + 1" :style="{ '--permission-tool-count': tools.length }">
|
||||
<div class="permission-matrix-head" role="row">
|
||||
<span role="columnheader">用户</span>
|
||||
<span v-for="tool in tools" :key="tool.id" role="columnheader">{{ tool.label }}</span>
|
||||
</div>
|
||||
<button v-for="row in rows" :key="row.id" type="button" role="row" :data-selected="row.id === selectedId" @click="emit('select', row)">
|
||||
<span role="gridcell"><strong>{{ accessUserLabel(row) }}</strong><small>{{ row.role }} · {{ row.status }}</small></span>
|
||||
<span v-for="tool in tools" :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 rows" :key="row.id" type="button" role="listitem" :data-selected="row.id === selectedId" @click="emit('select', 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>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import { accessUserLabel, type AccessUserRow } from "@/stores/admin-access-view";
|
||||
|
||||
defineProps<{
|
||||
rows: AccessUserRow[];
|
||||
selectedId: string | null;
|
||||
query: string;
|
||||
refreshing: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [row: AccessUserRow];
|
||||
refresh: [];
|
||||
"update:query": [value: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityIndex
|
||||
:items="rows"
|
||||
:item-key="(row) => row.id"
|
||||
:selected-id="selectedId"
|
||||
label="授权用户索引"
|
||||
empty-text="没有匹配用户。"
|
||||
@select="emit('select', $event)"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="operations-search">
|
||||
<span>用户索引</span>
|
||||
<input :value="query" type="search" placeholder="用户、角色、状态或 tool" @input="emit('update:query', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
<button class="table-action" type="button" :disabled="refreshing" @click="emit('refresh')">
|
||||
{{ refreshing ? "刷新中" : "刷新" }}
|
||||
</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>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import { accessUserLabel, type AccessToolRow, type AccessUserRow } from "@/stores/admin-access-view";
|
||||
import type { AccessUserRole, AccessUserStatus } from "@/types";
|
||||
|
||||
defineProps<{
|
||||
user: AccessUserRow;
|
||||
tools: AccessToolRow[];
|
||||
tuples: Record<string, unknown>[];
|
||||
mutationError: string | null;
|
||||
saving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
role: [value: AccessUserRole];
|
||||
status: [value: AccessUserStatus];
|
||||
tool: [toolId: string, allowed: boolean];
|
||||
}>();
|
||||
|
||||
function valueText(value: unknown): string {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "boolean") return value ? "yes" : "no";
|
||||
return "-";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InspectorPanel :title="accessUserLabel(user)" :subtitle="user.id" @close="emit('close')">
|
||||
<p v-if="mutationError" class="form-error">{{ mutationError }}</p>
|
||||
<div class="form-grid compact-form-grid">
|
||||
<label><span>Role</span><select :value="user.role" :disabled="saving" @change="emit('role', ($event.target as HTMLSelectElement).value as AccessUserRole)"><option value="admin">admin</option><option value="member">member</option><option value="viewer">viewer</option></select></label>
|
||||
<label><span>Status</span><select :value="user.status" :disabled="saving" @change="emit('status', ($event.target as HTMLSelectElement).value as AccessUserStatus)"><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select></label>
|
||||
</div>
|
||||
<section>
|
||||
<h3>Tool grants</h3>
|
||||
<div class="tool-toggle-grid">
|
||||
<label v-for="tool in tools" :key="tool.id" class="tool-toggle-row"><span><strong>{{ tool.label }}</strong><small>{{ tool.id }}</small></span><input type="checkbox" :checked="tool.allowed" :disabled="saving" @change="emit('tool', tool.id, ($event.target as HTMLInputElement).checked)" /></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tuple-list">
|
||||
<h3>Tuples</h3>
|
||||
<p v-if="!tuples.length" class="muted-box">暂无 tuple。</p>
|
||||
<ul v-else><li v-for="tuple in 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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, type RouteLocationRaw } from "vue-router";
|
||||
import EntityCard from "@/components/common/EntityCard.vue";
|
||||
import type { ApiKeyRecord } from "@/types";
|
||||
|
||||
defineProps<{ apiKey: ApiKeyRecord; selected: boolean; to: RouteLocationRaw }>();
|
||||
function formatDate(value: string | null | undefined): string { if (!value) return "从未使用"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink class="industrial-card-link" :to="to"><EntityCard icon="key" :title="apiKey.name || apiKey.id" :subtitle="apiKey.id" :status="apiKey.revokedAt ? 'disabled' : 'active'" :status-label="apiKey.revokedAt ? 'revoked' : 'active'" :selected="selected" :interactive="false"><template #meta><code>{{ apiKey.prefix || '-' }}</code><span>LAST USED {{ formatDate(apiKey.lastUsedAt) }}</span></template><template #footer><span>查看 Key 详情</span><span aria-hidden="true">→</span></template></EntityCard></RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import type { RedeemCodeSummary } from "@/types";
|
||||
|
||||
defineProps<{ codes: RedeemCodeSummary[]; selectedId: string | null; status: string }>();
|
||||
const emit = defineEmits<{ select: [code: RedeemCodeSummary]; "update:status": [value: string] }>();
|
||||
const codeKey = (code: RedeemCodeSummary): string => String(code.id || code.codePrefix || "");
|
||||
const formatNumber = (value: unknown): string => Number(value ?? 0).toLocaleString();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityIndex :items="codes" :item-key="codeKey" :selected-id="selectedId" label="兑换码索引" @select="emit('select', $event)">
|
||||
<template #toolbar><select :value="status" class="input" @change="emit('update:status', ($event.target as HTMLSelectElement).value)"><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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import type { RedeemCodeSummary } from "@/types";
|
||||
|
||||
defineProps<{ code: RedeemCodeSummary; pending: boolean }>();
|
||||
const emit = defineEmits<{ close: []; toggle: [code: RedeemCodeSummary] }>();
|
||||
const formatNumber = (value: unknown): string => Number(value ?? 0).toLocaleString();
|
||||
function formatDate(value: unknown): string { if (typeof value !== "string" || !value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InspectorPanel :title="code.displayName || code.codePrefix || code.id || '兑换码'" :subtitle="code.id" @close="emit('close')">
|
||||
<dl class="inspector-facts"><div><dt>Status</dt><dd>{{ code.status }}</dd></div><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>Per user</dt><dd>{{ formatNumber(code.perUserLimit) }}</dd></div><div><dt>Reason</dt><dd>{{ code.reason || "-" }}</dd></div><div><dt>Expires</dt><dd>{{ formatDate(code.expiresAt) }}</dd></div></dl>
|
||||
<button class="table-action" type="button" :disabled="pending" @click="emit('toggle', code)">{{ code.status === "disabled" ? "启用" : "禁用" }}</button>
|
||||
</InspectorPanel>
|
||||
</template>
|
||||
@@ -2,8 +2,7 @@
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef } from "vue";
|
||||
import { nextOverlayId, useOverlaySurface } from "@/composables/useOverlaySurface";
|
||||
import OverlaySurface from "./OverlaySurface.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean;
|
||||
@@ -21,43 +20,9 @@ const props = withDefaults(defineProps<{
|
||||
initialFocus: ""
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const surface = ref<HTMLElement | null>(null);
|
||||
const titleId = nextOverlayId("console-dialog-title");
|
||||
const descriptionId = nextOverlayId("console-dialog-description");
|
||||
const describedBy = computed(() => props.description ? descriptionId : undefined);
|
||||
|
||||
useOverlaySurface(toRef(props, "open"), surface, close, { initialFocusSelector: props.initialFocus || undefined });
|
||||
|
||||
function close(): void {
|
||||
if (!props.busy) emit("close");
|
||||
}
|
||||
defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="console-overlay-backdrop" role="presentation" @mousedown.self="closeOnBackdrop ? close() : undefined">
|
||||
<section
|
||||
ref="surface"
|
||||
class="console-overlay-surface"
|
||||
:data-wide="wide ? 'true' : 'false'"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-busy="busy ? 'true' : 'false'"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="console-overlay-header">
|
||||
<div class="console-overlay-title-stack">
|
||||
<h2 :id="titleId">{{ title }}</h2>
|
||||
<p v-if="description" :id="descriptionId">{{ description }}</p>
|
||||
</div>
|
||||
<button type="button" class="console-overlay-close" :disabled="busy" aria-label="关闭" @click="close">×</button>
|
||||
</header>
|
||||
<div class="console-overlay-body"><slot /></div>
|
||||
<footer v-if="$slots.footer" class="console-overlay-footer"><slot name="footer" /></footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
<OverlaySurface :open="open" :title="title" :description="description" :wide="wide" :close-on-backdrop="closeOnBackdrop" :busy="busy" :initial-focus="initialFocus" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
|
||||
</template>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef } from "vue";
|
||||
import { nextOverlayId, useOverlaySurface } from "@/composables/useOverlaySurface";
|
||||
import OverlaySurface from "./OverlaySurface.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean;
|
||||
@@ -19,43 +18,9 @@ const props = withDefaults(defineProps<{
|
||||
busy: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const surface = ref<HTMLElement | null>(null);
|
||||
const titleId = nextOverlayId("console-drawer-title");
|
||||
const descriptionId = nextOverlayId("console-drawer-description");
|
||||
const describedBy = computed(() => props.description ? descriptionId : undefined);
|
||||
|
||||
useOverlaySurface(toRef(props, "open"), surface, close);
|
||||
|
||||
function close(): void {
|
||||
if (!props.busy) emit("close");
|
||||
}
|
||||
defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="console-overlay-backdrop console-drawer-backdrop" role="presentation" @mousedown.self="closeOnBackdrop ? close() : undefined">
|
||||
<aside
|
||||
ref="surface"
|
||||
class="console-overlay-surface console-drawer"
|
||||
:data-placement="placement"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-busy="busy ? 'true' : 'false'"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="console-overlay-header">
|
||||
<div class="console-overlay-title-stack">
|
||||
<h2 :id="titleId">{{ title }}</h2>
|
||||
<p v-if="description" :id="descriptionId">{{ description }}</p>
|
||||
</div>
|
||||
<button type="button" class="console-overlay-close" :disabled="busy" aria-label="关闭" @click="close">×</button>
|
||||
</header>
|
||||
<div class="console-overlay-body"><slot /></div>
|
||||
<footer v-if="$slots.footer" class="console-overlay-footer"><slot name="footer" /></footer>
|
||||
</aside>
|
||||
</div>
|
||||
</Teleport>
|
||||
<OverlaySurface kind="drawer" :open="open" :title="title" :description="description" :placement="placement" :close-on-backdrop="closeOnBackdrop" :busy="busy" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef } from "vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import { nextOverlayId, useOverlaySurface } from "@/composables/useOverlaySurface";
|
||||
|
||||
const props = withDefaults(defineProps<{ open: boolean; title: string; description?: string; kind?: "dialog" | "drawer"; placement?: "left" | "right"; wide?: boolean; closeOnBackdrop?: boolean; busy?: boolean; initialFocus?: string }>(), { description: "", kind: "dialog", placement: "right", wide: false, closeOnBackdrop: true, busy: false, initialFocus: "" });
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const surface = ref<HTMLElement | null>(null);
|
||||
const titleId = nextOverlayId("console-overlay-title");
|
||||
const descriptionId = nextOverlayId("console-overlay-description");
|
||||
const describedBy = computed(() => props.description ? descriptionId : undefined);
|
||||
useOverlaySurface(toRef(props, "open"), surface, close, { initialFocusSelector: props.initialFocus || undefined });
|
||||
function close(): void { if (!props.busy) emit("close"); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body"><div v-if="open" class="console-overlay-backdrop" :class="{ 'console-drawer-backdrop': kind === 'drawer' }" role="presentation" @mousedown.self="closeOnBackdrop ? close() : undefined"><component :is="kind === 'drawer' ? 'aside' : 'section'" ref="surface" class="console-overlay-surface" :class="{ 'console-drawer': kind === 'drawer' }" :data-placement="kind === 'drawer' ? placement : undefined" :data-wide="kind === 'dialog' && wide ? 'true' : undefined" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="describedBy" :aria-busy="busy ? 'true' : 'false'" tabindex="-1"><header class="console-overlay-header"><div class="console-overlay-title-stack"><h2 :id="titleId">{{ title }}</h2><p v-if="description" :id="descriptionId">{{ description }}</p></div><button type="button" class="console-overlay-close" :disabled="busy" aria-label="关闭" @click="close"><ConsoleIcon name="close" /></button></header><div class="console-overlay-body"><slot /></div><footer v-if="$slots.footer" class="console-overlay-footer"><slot name="footer" /></footer></component></div></Teleport>
|
||||
</template>
|
||||
@@ -90,6 +90,7 @@ const paths: Record<string, string[]> = {
|
||||
info: ["M12 11v6", "M12 7h.01", "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20"],
|
||||
logout: ["M10 5H5v14h5", "M14 8l4 4-4 4", "M18 12H9"],
|
||||
empty: ["M5 7h14v12H5z", "M8 4h8", "M9 12h6", "M12 9v6"],
|
||||
close: ["M6 6l12 12", "M18 6 6 18"],
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import DataGrid, { type DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import type { HwpodDeviceTopologyItem } from "@/types";
|
||||
import HwpodDeviceCard from "./HwpodDeviceCard.vue";
|
||||
|
||||
defineProps<{ devices: HwpodDeviceTopologyItem[]; selectedId: string | null; view: "cards" | "list"; density: "compact" | "comfortable"; sort: string }>();
|
||||
const emit = defineEmits<{ select: [device: HwpodDeviceTopologyItem]; sort: [key: string] }>();
|
||||
const columns: DataGridColumn[] = [{ key: "name", label: "HWPOD", sortable: true, width: "190px" }, { key: "nodeName", label: "Node", width: "170px" }, { key: "status", label: "状态", sortable: true, width: "100px" }, { key: "elements", label: "目标 / 工作区", width: "230px" }, { key: "debugIo", label: "调试 / IO", width: "210px" }, { key: "capabilities", label: "能力", width: "140px" }, { key: "blocker", label: "主要 blocker", width: "220px" }, { key: "operation", label: "用户操作", width: "160px" }];
|
||||
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配" }[status] || status || "unknown");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="resource-stage">
|
||||
<ResourceCollection v-if="view === 'cards'" class="device-collection" :items="devices" :item-key="(device) => device.hwpodId" :view="view" :density="density" :selected-id="selectedId" :show-controls="false" aria-label="HWPOD 设备卡片" @select="emit('select', $event)"><template #card="{ item, select }"><HwpodDeviceCard :device="item" @select="select" /></template></ResourceCollection>
|
||||
<DataGrid v-else :columns="columns" :rows="devices" :row-key="(device) => device.hwpodId" :selected-id="selectedId" :density="density" :sort="sort" aria-label="HWPOD 设备列表" @select="emit('select', $event)" @sort="emit('sort', $event)"><template #cell-name="{ row }"><span class="grid-stack"><strong>{{ row.name }}</strong><code>{{ row.hwpodId }}</code></span></template><template #cell-nodeName="{ row }"><span class="grid-stack"><strong>{{ row.nodeName }}</strong><code>{{ row.nodeId }}</code></span></template><template #cell-status="{ row }"><span class="status-chip" :data-status="row.status">{{ statusLabel(row.status) }}</span></template><template #cell-elements="{ row }"><span class="grid-stack"><strong>{{ row.elements.target.label }}</strong><small>{{ row.elements.workspace.label }}</small></span></template><template #cell-debugIo="{ row }"><span class="grid-stack"><strong>{{ row.elements.debugProbe.label }}</strong><small>{{ row.elements.ioProbe.label }}</small></span></template><template #cell-capabilities="{ row }"><span class="grid-stack"><strong>{{ row.actualCapabilities.length }}/{{ row.declaredCapabilities.length }}</strong><small v-if="row.missingCapabilities.length">缺 {{ row.missingCapabilities.join(', ') }}</small></span></template><template #cell-blocker="{ row }"><span class="grid-stack"><strong>{{ row.blockers[0]?.code || '无' }}</strong><small>{{ row.blockers[0]?.summary || '当前可操作' }}</small></span></template><template #cell-operation="{ row }"><span class="grid-stack"><strong>{{ row.operation?.status || '无' }}</strong><small>{{ row.operation ? `${row.operation.opCount} ops` : '尚无用户操作' }}</small></span></template></DataGrid>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import DataGrid, { type DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import type { HwpodNodeReadinessStage, HwpodNodeTopologyItem } from "@/types";
|
||||
import HwpodReadinessRail from "./HwpodReadinessRail.vue";
|
||||
|
||||
defineProps<{ nodes: HwpodNodeTopologyItem[]; selectedId: string | null; view: "cards" | "list"; density: "compact" | "comfortable"; sort: string }>();
|
||||
const emit = defineEmits<{ select: [node: HwpodNodeTopologyItem]; sort: [key: string] }>();
|
||||
const columns: DataGridColumn[] = [{ key: "name", label: "Node", sortable: true, width: "210px" }, { key: "status", label: "连接", sortable: true, width: "100px" }, { key: "deviceCount", label: "HWPOD", align: "right", width: "80px" }, { key: "inFlight", label: "In-flight", width: "100px" }, { key: "capabilities", label: "能力", width: "130px" }, { key: "mounted", label: "挂载 HWPOD", width: "220px" }, { key: "readiness", label: "接入阶段", width: "180px" }, { key: "lastSeen", label: "最后心跳", width: "150px" }, { key: "blocker", label: "Blocker", width: "220px" }];
|
||||
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配" }[status] || status || "unknown");
|
||||
function formatTime(value: string | null): string { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="resource-stage">
|
||||
<ResourceCollection v-if="view === 'cards'" class="node-collection" :items="nodes" :item-key="(node) => node.nodeId" :view="view" :density="density" :selected-id="selectedId" :show-controls="false" aria-label="HWPOD Node 卡片" @select="emit('select', $event)"><template #card="{ item: node, select }"><article class="node-card" :data-status="node.status" :data-testid="`hwpod-node-${node.nodeId}`"><header><div><span>{{ node.platform }} · {{ node.runtimeKind || '未识别运行时' }}</span><h2>{{ node.name }}</h2><code>{{ node.nodeId }}</code></div><span class="status-chip" :data-status="node.status">{{ statusLabel(node.status) }}</span></header><section class="node-gauges"><div><small>HWPOD</small><strong>{{ node.deviceCount }}</strong></div><div><small>in-flight</small><strong>{{ node.inFlight.count }}/{{ node.inFlight.max }}</strong></div><div><small>capabilities</small><strong>{{ node.actualCapabilities.length }}/{{ node.declaredCapabilities.length }}</strong></div></section><section class="mounted-hwpods" aria-label="挂载 HWPOD"><header><strong>挂载 HWPOD</strong><span>{{ node.hwpods.length }}</span></header><ul v-if="node.hwpods.length"><li v-for="device in node.hwpods" :key="device.hwpodId"><div><strong>{{ device.name }}</strong><code>{{ device.hwpodId }}</code></div><span class="status-chip" :data-status="device.status">{{ statusLabel(device.status) }}</span></li></ul><p v-else>节点在线,但 owning registry 尚未绑定 HWPOD spec。</p></section><HwpodReadinessRail :stages="node.readiness" /><footer><span>最后心跳 {{ formatTime(node.lastSeenAt) }}</span><strong v-if="node.blockers[0]">{{ node.blockers[0].code }}</strong><button type="button" @click="select">查看详情</button></footer></article></template></ResourceCollection>
|
||||
<DataGrid v-else :columns="columns" :rows="nodes" :row-key="(node) => node.nodeId" :selected-id="selectedId" :density="density" :sort="sort" aria-label="HWPOD Node 列表" @select="emit('select', $event)" @sort="emit('sort', $event)"><template #cell-name="{ row }"><span class="grid-stack"><strong>{{ row.name }}</strong><code>{{ row.nodeId }}</code><small>{{ row.platform }} · {{ row.runtimeKind || '未识别运行时' }}</small></span></template><template #cell-status="{ row }"><span class="status-chip" :data-status="row.status">{{ statusLabel(row.status) }}</span></template><template #cell-deviceCount="{ row }"><strong>{{ row.deviceCount }}</strong></template><template #cell-inFlight="{ row }"><strong>{{ row.inFlight.count }}/{{ row.inFlight.max }}</strong></template><template #cell-capabilities="{ row }"><span class="grid-stack"><strong>{{ row.actualCapabilities.length }}/{{ row.declaredCapabilities.length }}</strong><small v-if="row.missingCapabilities.length">缺 {{ row.missingCapabilities.join(', ') }}</small></span></template><template #cell-mounted="{ row }"><span class="grid-stack"><strong>{{ row.hwpods.map((item) => item.name).join(', ') || '无' }}</strong><small>{{ row.hwpods.map((item) => item.hwpodId).join(', ') || '未绑定 spec' }}</small></span></template><template #cell-readiness="{ row }"><span class="grid-stack"><strong>{{ row.readiness.filter((stage: HwpodNodeReadinessStage) => stage.status === 'ready').length }}/{{ row.readiness.length }} ready</strong><small>{{ row.readiness.find((stage: HwpodNodeReadinessStage) => stage.status !== 'ready')?.label || '全部阶段 ready' }}</small></span></template><template #cell-lastSeen="{ row }">{{ formatTime(row.lastSeenAt) }}</template><template #cell-blocker="{ row }"><span class="grid-stack"><strong>{{ row.blockers[0]?.code || '无' }}</strong><small>{{ row.blockers[0]?.summary || '当前可操作' }}</small></span></template></DataGrid>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import HwpodReadinessRail from "./HwpodReadinessRail.vue";
|
||||
import type { HwlabNodeUpdateMetadata, HwpodNodeReadinessStage, HwpodNodeTopologyItem } from "@/types";
|
||||
|
||||
defineProps<{ metadata: HwlabNodeUpdateMetadata | null; nodes: HwpodNodeTopologyItem[]; selectedNodeId: string; stages: HwpodNodeReadinessStage[]; copied: boolean }>();
|
||||
const emit = defineEmits<{ copy: []; node: [nodeId: string] }>();
|
||||
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常" }[status] || status || "unknown");
|
||||
const formatSize = (value: number): string => value > 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MiB` : `${Math.ceil(value / 1024)} KiB`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="onboarding-grid"><article class="artifact-panel"><span class="panel-kicker">Python single file</span><h2>{{ metadata?.artifact.fileName || 'hwlab-node.py' }}</h2><p>使用 Windows 交互用户的原生 Python/tkinter 运行,并主动出站连接 Cloud API。下载完成不等于 HWPOD ready。</p><dl><div><dt>版本</dt><dd>{{ metadata?.artifact.version || '-' }}</dd></div><div><dt>大小</dt><dd>{{ formatSize(metadata?.artifact.sizeBytes || 0) }}</dd></div><div class="sha-row"><dt>SHA-256</dt><dd><code>{{ metadata?.artifact.sha256 || '未发布' }}</code><button type="button" :disabled="!metadata?.artifact.sha256" @click="emit('copy')">{{ copied ? '已复制' : '复制' }}</button></dd></div></dl><a v-if="metadata?.artifact.downloadUrl" class="download-button" :href="metadata.artifact.downloadUrl" download="hwlab-node.py">下载 Python 单文件</a><p v-else class="download-blocked">发布 artifact 当前不可用。</p><h3>发布说明</h3><ul><li v-for="note in metadata?.releaseNotes || []" :key="note">{{ note }}</li></ul><h3>安装条件</h3><ul><li v-for="condition in metadata?.installConditions || []" :key="condition.id">{{ condition.label }}</li></ul></article><article class="readiness-panel"><header><div><span class="panel-kicker">Staged readiness</span><h2>接入进度</h2></div><label>Node<select :value="selectedNodeId" @change="emit('node', ($event.target as HTMLSelectElement).value)"><option value="">未选择</option><option v-for="node in nodes" :key="node.nodeId" :value="node.nodeId">{{ node.name }} · {{ statusLabel(node.status) }}</option></select></label></header><HwpodReadinessRail :stages="stages" /><aside class="outbound-note"><strong>网络边界</strong><span>节点只使用主动出站 WebSocket。网页不生成凭据,不要求用户 PC 开放入站端口。</span></aside></article></section>
|
||||
</template>
|
||||
@@ -189,7 +189,6 @@ const model = reactive(useMdtodoWorkspaceController());
|
||||
:detail-error="model.taskData.taskDetailError"
|
||||
:detail-partial="model.taskDetailPartial"
|
||||
:mutation-disabled="model.mutationDisabled"
|
||||
:body-html="model.selectedTaskBodyHtml"
|
||||
:links="model.selectedTaskLinks"
|
||||
:editing-title="model.editingTitle"
|
||||
:editing-body="model.editingBody"
|
||||
@@ -227,8 +226,6 @@ const model = reactive(useMdtodoWorkspaceController());
|
||||
<MdtodoReportPanel
|
||||
:report="model.report.reportPreview"
|
||||
:active-link="model.report.activeReportLink"
|
||||
:report-html="model.reportPreviewHtml"
|
||||
:fullscreen-html="model.reportPreviewHtml"
|
||||
:loading="model.report.reportLoading"
|
||||
:error="model.report.reportError"
|
||||
:collapsed="model.reportPaneCollapsed"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { MdtodoReportPreviewRecord, MdtodoTaskLinkRecord } from "@/api";
|
||||
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import ContentViewer from "@/components/common/ContentViewer.vue";
|
||||
|
||||
defineProps<{
|
||||
report: MdtodoReportPreviewRecord | null;
|
||||
activeLink: MdtodoTaskLinkRecord | null;
|
||||
reportHtml: string;
|
||||
fullscreenHtml: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
collapsed: boolean;
|
||||
@@ -63,7 +62,7 @@ const emit = defineEmits<{
|
||||
</header>
|
||||
<article v-if="!collapsed" data-testid="mdtodo-report-preview">
|
||||
<div v-if="loading">读取报告…</div>
|
||||
<div v-else class="report-markdown" v-html="reportHtml" />
|
||||
<ContentViewer v-else class="report-markdown" :content="report?.content || ''" mode="markdown" title="报告预览" :filename="report?.relativePath || 'report.md'" :line-numbers="false" />
|
||||
<p v-if="error">{{ error }}</p>
|
||||
</article>
|
||||
</aside>
|
||||
@@ -74,11 +73,7 @@ const emit = defineEmits<{
|
||||
wide
|
||||
@close="emit('closeFullscreen')"
|
||||
>
|
||||
<article
|
||||
class="report-markdown fullscreen"
|
||||
data-testid="mdtodo-report-fullscreen-dialog"
|
||||
v-html="fullscreenHtml"
|
||||
/>
|
||||
<ContentViewer class="report-markdown fullscreen" data-testid="mdtodo-report-fullscreen-dialog" :content="report?.content || ''" mode="markdown" :title="activeLink?.label || report?.relativePath || 'Report'" :filename="report?.relativePath || 'report.md'" :line-numbers="false" />
|
||||
<template #footer
|
||||
><button type="button" @click="emit('closeFullscreen')">
|
||||
关闭
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
MdtodoTaskRecord,
|
||||
} from "@/api";
|
||||
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||||
import ContentViewer from "@/components/common/ContentViewer.vue";
|
||||
import type { ApiError, ErrorDiagnostic, ProviderProfile } from "@/types";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -14,7 +15,6 @@ const props = defineProps<{
|
||||
detailError: string | null;
|
||||
detailPartial: boolean;
|
||||
mutationDisabled: boolean;
|
||||
bodyHtml: string;
|
||||
links: MdtodoTaskLinkRecord[];
|
||||
editingTitle: boolean;
|
||||
editingBody: boolean;
|
||||
@@ -172,15 +172,7 @@ function updateStatus(event: Event): void {
|
||||
"
|
||||
@keydown.esc="emit('cancelBodyEdit')"
|
||||
/>
|
||||
<article
|
||||
v-else
|
||||
class="markdown-document"
|
||||
data-testid="mdtodo-body-read"
|
||||
tabindex="0"
|
||||
title="双击编辑 Markdown 正文"
|
||||
@dblclick="!mutationDisabled && emit('beginBodyEdit')"
|
||||
v-html="bodyHtml"
|
||||
/>
|
||||
<div v-else class="markdown-document" data-testid="mdtodo-body-read" title="双击编辑 Markdown 正文" @dblclick="!mutationDisabled && emit('beginBodyEdit')"><ContentViewer :content="detail?.body || task.bodyPreview || ''" mode="markdown" title="任务正文" :filename="`${task.taskId || task.rxxId || 'task'}.md`" :line-numbers="false" /></div>
|
||||
</template>
|
||||
<div v-else class="document-empty">从左侧任务索引选择一个 Rxx。</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, type RouteLocationRaw } from "vue-router";
|
||||
import EntityCard from "@/components/common/EntityCard.vue";
|
||||
import type { ProjectRecord, ProjectSource } from "@/api";
|
||||
|
||||
defineProps<{ project: ProjectRecord; sources: ProjectSource[]; selected: boolean; to: RouteLocationRaw }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink class="industrial-card-link" :data-project-id="project.projectId" :to="to"><EntityCard icon="project" :title="project.name || project.title || project.projectId" :subtitle="project.projectId" :status="project.status || 'unknown'" :status-label="project.status || 'unknown'" :selected="selected" :interactive="false"><div class="project-source-chips" aria-label="项目 source"><span v-for="source in sources" :key="source.sourceId" class="source-chip" :data-status="source.status || 'unknown'">{{ source.displayName || source.sourceId }} · {{ source.status || 'unknown' }}</span><span v-if="!sources.length" class="source-chip muted">无可见 source</span></div><template #footer><span>查看详情</span><span aria-hidden="true">→</span></template></EntityCard></RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import type { AdminSecretsRow } from "@/stores/admin-secrets-view";
|
||||
|
||||
defineProps<{ rows: AdminSecretsRow[]; total: number; selectedId: string | null; query: string; status: string }>();
|
||||
const emit = defineEmits<{ select: [row: AdminSecretsRow]; "update:query": [value: string]; "update:status": [value: string] }>();
|
||||
const rowKey = (row: AdminSecretsRow): string => `${row.cluster}:${row.namespace}:${row.name}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityIndex :items="rows" :item-key="rowKey" :selected-id="selectedId" label="ExternalSecret 库存" @select="emit('select', $event)">
|
||||
<template #toolbar>
|
||||
<label class="operations-search"><span>ExternalSecret 库存</span><input :value="query" type="search" placeholder="名称、namespace、cluster 或 target" @input="emit('update:query', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<select :value="status" class="input" @change="emit('update:status', ($event.target as HTMLSelectElement).value)"><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>{{ rows.length }} / {{ total }} 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>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import FactPath, { type FactPathStep } from "@/components/console/FactPath.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
import SectionFrame from "@/components/layout/SectionFrame.vue";
|
||||
import type { AdminSecretsRow } from "@/stores/admin-secrets-view";
|
||||
|
||||
defineProps<{ selected: AdminSecretsRow | null; rows: AdminSecretsRow[]; factSteps: FactPathStep[]; generatedAt: string; view: "flow" | "list" }>();
|
||||
const emit = defineEmits<{ select: [row: AdminSecretsRow]; inspect: [row: AdminSecretsRow]; "update:view": [value: "flow" | "list"] }>();
|
||||
const rowKey = (row: AdminSecretsRow): string => `${row.cluster}:${row.namespace}:${row.name}`;
|
||||
function displayDate(value: string): string { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="selected" class="operations-main-stack">
|
||||
<div class="view-mode-row"><ViewModeSwitch :model-value="view" :options="[{ value: 'flow', label: '流向', icon: 'flow' }, { value: 'list', label: '清单', icon: 'list' }]" @update:model-value="emit('update:view', $event as 'flow' | 'list')" /></div>
|
||||
<FactPath v-if="view === '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 rows" :key="rowKey(row)" type="button" :data-selected="rowKey(row) === rowKey(selected)" @click="emit('select', 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(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="emit('inspect', selected)">打开详情 Inspector</button></SectionFrame>
|
||||
</div>
|
||||
<EmptyState v-else title="没有匹配对象" description="调整库存筛选。" />
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import type { AdminBillingUserSummary, ApiKeyRecord, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, UsageLedgerRow, UsageReservationRow } from "@/types";
|
||||
|
||||
type DetailTab = "profile" | "credits" | "entitlements" | "keys" | "reservations" | "ledger";
|
||||
interface EditForm { email: string; username: string; displayName: string; password: string; role: string; status: string; planId: string }
|
||||
interface CreditForm { deltaCredits: number; reason: string; idempotencyKey: string }
|
||||
|
||||
defineProps<{ userId: string; user: AuthUser | null; summary: AdminBillingUserSummary | null; plans: BillingPlanDetail[]; tab: string; loading: boolean; error: string | null; pending: string | null; editForm: EditForm; creditForm: CreditForm; entitlements: ResourceEntitlementSummary[]; apiKeys: ApiKeyRecord[]; reservations: UsageReservationRow[]; ledgerRows: UsageLedgerRow[] }>();
|
||||
const emit = defineEmits<{ close: []; tab: [tab: string]; save: []; adjust: [] }>();
|
||||
const formatNumber = (value: unknown): string => Number(value ?? 0).toLocaleString();
|
||||
function formatDate(value: string | null | undefined): string { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
|
||||
const formatSigned = (value: unknown): string => { const number = Number(value ?? 0); return `${number > 0 ? "+" : ""}${number.toLocaleString()}`; };
|
||||
const auditKey = (item: UsageLedgerRow): string => String(item.id || item.idempotencyKey || item.createdAt || Math.random());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InspectorPanel :title="user?.displayName || user?.username || userId" :subtitle="userId" @close="emit('close')">
|
||||
<template #tabs><button v-for="item in ['profile', 'credits', 'entitlements', 'keys', 'reservations', 'ledger'] as DetailTab[]" :key="item" type="button" role="tab" :aria-selected="tab === item" @click="emit('tab', item)">{{ item }}</button></template>
|
||||
<p v-if="loading" class="muted-box">详情加载中…</p><p v-else-if="error" class="form-error">{{ error }}</p>
|
||||
<div v-if="!loading && tab === 'profile'" class="form-grid"><label>邮箱<input v-model="editForm.email" class="input" /></label><label>用户名<input v-model="editForm.username" class="input" /></label><label>显示名<input v-model="editForm.displayName" class="input" /></label><label>新密码<input v-model="editForm.password" class="input" type="password" /></label><label>角色<select v-model="editForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="editForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label><label>Plan<select v-model="editForm.planId" class="input"><option v-for="item in plans" :key="item.plan?.id" :value="item.plan?.id">{{ item.plan?.displayName || item.plan?.id }}</option></select></label><button class="table-action" type="button" :disabled="pending === `save:${userId}`" @click="emit('save')">保存资料</button></div>
|
||||
<div v-else-if="tab === 'credits'" class="form-grid"><p class="muted-box">Available {{ formatNumber(summary?.credits?.available) }} · Reserved {{ formatNumber(summary?.credits?.reserved) }}</p><label>Delta<input v-model.number="creditForm.deltaCredits" class="input" type="number" /></label><label>Reason<input v-model="creditForm.reason" class="input" /></label><label>Idempotency key<input v-model="creditForm.idempotencyKey" class="input" /></label><button class="table-action" type="button" :disabled="pending === `credits:${userId}`" @click="emit('adjust')">调整 Credits</button></div>
|
||||
<div v-else-if="tab === 'entitlements'" class="inspector-fact-list"><article v-for="item in entitlements" :key="item.id || item.resourceType"><strong>{{ item.resourceType }}</strong><small>{{ item.enabled === false ? "disabled" : "enabled" }} · monthly {{ formatNumber(item.monthlyUsageCredits) }} / {{ item.monthlyQuotaUnlimited ? "unlimited" : formatNumber(item.monthlyQuotaCredits) }}</small><small>concurrent {{ formatNumber(item.activeReservations) }} / {{ item.concurrencyUnlimited ? "unlimited" : formatNumber(item.maxConcurrentReservations) }} · RPM {{ formatNumber(item.rpmUsed) }} / {{ item.rpmUnlimited ? "unlimited" : formatNumber(item.rpmLimit) }}</small></article><p v-if="!entitlements.length" class="muted-box">暂无 entitlement。</p></div>
|
||||
<div v-else-if="tab === 'keys'" class="inspector-fact-list"><article v-for="item in apiKeys" :key="item.id"><strong>{{ item.name || item.id }}</strong><small>{{ item.prefix || "-" }} · {{ item.revokedAt ? "revoked" : "active" }}</small><small>created {{ formatDate(item.createdAt) }} · last used {{ formatDate(item.lastUsedAt) }}</small></article><p v-if="!apiKeys.length" class="muted-box">暂无 API key。</p></div>
|
||||
<div v-else-if="tab === 'reservations'" class="inspector-fact-list"><article v-for="item in reservations" :key="item.reservationId"><strong>{{ item.reservationId }}</strong><small>{{ item.serviceId }} · {{ item.resourceType || "generic" }} · {{ item.status }}</small><small>{{ formatNumber(item.estimatedCredits) }} credits · expires {{ formatDate(item.expiresAt) }}</small></article><p v-if="!reservations.length" class="muted-box">没有 active reservation。</p></div>
|
||||
<div v-else class="inspector-fact-list"><article v-for="item in ledgerRows" :key="auditKey(item)"><strong>{{ item.kind }} · {{ formatSigned(item.deltaCredits) }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.reason }} · {{ item.status }}</small><small>{{ formatNumber(item.balanceBefore) }} → {{ formatNumber(item.balanceAfter) }} · {{ item.idempotencyKey || "-" }}</small></article><p v-if="!ledgerRows.length" class="muted-box">暂无用户 ledger。</p></div>
|
||||
</InspectorPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import DataGrid, { type DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import SectionFrame from "@/components/layout/SectionFrame.vue";
|
||||
import type { AdminBillingUserSummary } from "@/types";
|
||||
|
||||
defineProps<{ users: AdminBillingUserSummary[]; selectedId: string | null; view: "list" | "grid"; pendingId: string | null }>();
|
||||
const emit = defineEmits<{ select: [userId: string]; status: [user: AdminBillingUserSummary] }>();
|
||||
const columns: DataGridColumn[] = [{ key: "user", label: "User" }, { key: "status", label: "Status" }, { key: "credits", label: "Credits" }, { key: "usage", label: "Usage" }, { key: "keys", label: "Keys" }, { key: "actions", label: "Action", align: "right" }];
|
||||
const userKey = (item: AdminBillingUserSummary): string => String(item.user?.id || "");
|
||||
const formatNumber = (value: unknown): string => Number(value ?? 0).toLocaleString();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionFrame :title="view === 'grid' ? '用户实体网格' : '用户紧凑列表'" description="选择动作与状态写操作使用同级控件,避免交互元素嵌套。" :body-padding="false">
|
||||
<div v-if="view === 'grid'" class="user-entity-grid"><article v-for="item in users" :key="userKey(item)" class="user-entity-card" :data-selected="userKey(item) === selectedId"><button class="user-entity-select" type="button" @click="emit('select', item.user?.id || '')"><ConsoleIcon name="users" :size="20" /><strong>{{ item.user?.displayName || item.user?.username }}</strong><small>{{ item.user?.email || item.user?.id }}</small><dl><div><dt>Available</dt><dd>{{ formatNumber(item.credits?.available) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(item.usage?.totalCredits) }}</dd></div><div><dt>Keys</dt><dd>{{ formatNumber(item.apiKeys?.activeCount) }}</dd></div></dl></button><button class="table-action" type="button" :disabled="pendingId === item.user?.id" @click="emit('status', item)">{{ item.user?.status === "disabled" ? "启用" : "禁用" }}</button></article></div>
|
||||
<DataGrid v-else :columns="columns" :rows="users" :row-key="userKey" :selected-id="selectedId" aria-label="用户列表" @select="emit('select', $event.user?.id || '')"><template #cell-user="{ row }"><strong>{{ row.user?.displayName || row.user?.username }}</strong><small>{{ row.user?.email || row.user?.id }} · {{ row.planId || "default" }}</small></template><template #cell-status="{ row }"><span class="status-pill" :data-status="row.user?.status">{{ row.user?.role }} / {{ row.user?.status }}</span></template><template #cell-credits="{ row }">{{ formatNumber(row.credits?.available) }} / {{ formatNumber(row.credits?.reserved) }}</template><template #cell-usage="{ row }">{{ formatNumber(row.usage?.totalCredits) }}</template><template #cell-keys="{ row }">{{ formatNumber(row.apiKeys?.activeCount) }} / {{ formatNumber(row.apiKeys?.totalCount) }}</template><template #cell-actions="{ row }"><button class="table-action" type="button" :disabled="pendingId === row.user?.id" @click.stop="emit('status', row)">{{ row.user?.status === "disabled" ? "启用" : "禁用" }}</button></template></DataGrid>
|
||||
</SectionFrame>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import Pagination from "@/components/common/Pagination.vue";
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import type { AdminBillingUserSummary } from "@/types";
|
||||
|
||||
defineProps<{ users: AdminBillingUserSummary[]; selectedId: string | null; search: string; status: string; role: string; page: number; pageSize: number; total: number }>();
|
||||
const emit = defineEmits<{ select: [userId: string]; apply: []; page: [value: number]; "update:search": [value: string]; "update:status": [value: string]; "update:role": [value: string] }>();
|
||||
const userKey = (item: AdminBillingUserSummary): string => String(item.user?.id || "");
|
||||
const formatNumber = (value: unknown): string => Number(value ?? 0).toLocaleString();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntityIndex :items="users" :item-key="userKey" :selected-id="selectedId" label="用户索引" @select="emit('select', $event.user?.id || '')">
|
||||
<template #toolbar><div class="user-index-filters admin-users-filters"><input :value="search" class="input" aria-label="搜索用户" placeholder="搜索用户名、邮箱或 user id" @input="emit('update:search', ($event.target as HTMLInputElement).value)" @keyup.enter="emit('apply')" /><select :value="status" class="input" aria-label="用户状态" @change="emit('update:status', ($event.target as HTMLSelectElement).value); emit('apply')"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select><select :value="role" class="input" aria-label="用户角色" @change="emit('update:role', ($event.target as HTMLSelectElement).value); emit('apply')"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select></div></template>
|
||||
<template #item="{ item }"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId || "default" }}</small><span class="entity-index-meta"><span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ formatNumber(item.credits?.available) }} cr</code></span></template>
|
||||
<template #footer><Pagination :page="page" :page-size="pageSize" :total="total" @update:page="emit('page', $event)" /></template>
|
||||
</EntityIndex>
|
||||
</template>
|
||||
@@ -467,6 +467,39 @@ button:focus-visible,
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.industrial-fact-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.industrial-fact-row div {
|
||||
min-width: 0;
|
||||
border-top: 1px solid #dce3e1;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.industrial-fact-row dt {
|
||||
color: var(--console-graphite-600);
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.industrial-fact-row dd {
|
||||
overflow: hidden;
|
||||
margin: 3px 0 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.industrial-card-link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.resource-collection-virtual {
|
||||
border: 1px solid var(--console-border);
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -11,9 +11,9 @@ import ContentViewer from "@/components/common/ContentViewer.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import EntityCard from "@/components/common/EntityCard.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import VirtualList from "@/components/common/VirtualList.vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||
import { useForm } from "@/composables/useForm";
|
||||
@@ -235,11 +235,7 @@ function routeFilePath(): string {
|
||||
<template #cell-actions="{ row }"><button class="table-action" type="button" @click="inspectSkill(row as SkillRecord)">Tree</button></template>
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="filteredRows.length" :items="filteredRows" :item-key="rowKey" view="cards" :selected-id="selectedSkill?.id" :show-controls="false" aria-label="Skill 卡片" @select="inspectSkill">
|
||||
<template #card="{ item: row }"><article class="skill-card" :data-selected="selectedSkill?.id === row.id">
|
||||
<header><span class="skill-glyph" aria-hidden="true"><ConsoleIcon name="skills" /></span><div><strong>{{ row.name || row.id }}</strong><small>{{ row.description || '未提供描述' }}</small></div><span class="status-pill" :data-status="row.source || 'pending'">{{ row.source || '-' }}</span></header>
|
||||
<dl><div><dt>版本</dt><dd>{{ row.version || row.commitId || '-' }}</dd></div><div><dt>文件</dt><dd>{{ row.fileCount ?? 0 }} / {{ formatBytes(row.sizeBytes) }}</dd></div><div><dt>更新</dt><dd>{{ displayDate(row.updatedAt || row.createdAt) }}</dd></div></dl>
|
||||
<footer><code>{{ row.manifestPath || 'manifest unavailable' }}</code><button class="table-action" type="button" @click="inspectSkill(row)">查看目录 →</button></footer>
|
||||
</article></template>
|
||||
<template #card="{ item: row }"><EntityCard icon="skills" :title="row.name || row.id" :subtitle="row.description || '未提供描述'" :status="row.source || 'pending'" :selected="selectedSkill?.id === row.id" @select="inspectSkill(row)"><dl class="industrial-fact-row"><div><dt>版本</dt><dd>{{ row.version || row.commitId || '-' }}</dd></div><div><dt>文件</dt><dd>{{ row.fileCount ?? 0 }} / {{ formatBytes(row.sizeBytes) }}</dd></div><div><dt>更新</dt><dd>{{ displayDate(row.updatedAt || row.createdAt) }}</dd></div></dl><template #footer><code>{{ row.manifestPath || 'manifest unavailable' }}</code><button class="table-action" type="button" @click="inspectSkill(row)">查看目录 →</button></template></EntityCard></template>
|
||||
</ResourceCollection>
|
||||
<EmptyState v-else title="没有匹配 Skill" description="调整来源或搜索条件;技能事实仍来自同源 Skills API。" />
|
||||
</TablePageLayout>
|
||||
@@ -291,20 +287,6 @@ function routeFilePath(): string {
|
||||
.skills-view-toggle button { height: 28px; border: 0; border-radius: 5px; background: transparent; padding: 0 11px; color: #64757d; font-size: 10px; font-weight: 800; }
|
||||
.skills-view-toggle button[data-active="true"] { background: #183b43; color: #fff; }
|
||||
.skills-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 9px; padding: 10px; }
|
||||
.skill-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; }
|
||||
.skill-card[data-selected="true"] { border-color: #1ba39e; box-shadow: inset 3px 0 #1ba39e; }
|
||||
.skill-card header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; gap: 9px; align-items: center; }
|
||||
.skill-card header > div { display: grid; min-width: 0; }
|
||||
.skill-card header strong,
|
||||
.skill-card header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.skill-card header small { color: #6b7c84; }
|
||||
.skill-glyph { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 6px; background: #dcefed; color: #126f6a; font: 900 10px/1 ui-monospace, monospace; }
|
||||
.skill-card dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin: 0; }
|
||||
.skill-card dl div { display: grid; gap: 3px; min-width: 0; border-top: 1px solid #e0e7e9; padding-top: 7px; }
|
||||
.skill-card dt { color: #708087; font-size: 9px; font-weight: 850; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.skill-card dd { overflow: hidden; margin: 0; color: #30464e; font: 750 10px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.skill-card footer { display: flex; justify-content: space-between; gap: 8px; align-items: center; min-width: 0; }
|
||||
.skill-card footer code { overflow: hidden; color: #697b82; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.skills-detail-grid { min-height: 360px; }
|
||||
.skills-tree-panel,
|
||||
.skills-preview-panel { min-width: 0; box-shadow: inset 3px 0 #1ba39e; }
|
||||
|
||||
@@ -7,8 +7,8 @@ import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import BillingCodeIndex from "@/components/billing/BillingCodeIndex.vue";
|
||||
import BillingCodeInspector from "@/components/billing/BillingCodeInspector.vue";
|
||||
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
import AuditTimeline from "@/components/console/AuditTimeline.vue";
|
||||
@@ -271,39 +271,7 @@ function formatDate(value: unknown): string {
|
||||
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
|
||||
>
|
||||
<template #index><BillingCodeIndex :codes="codes" :selected-id="selectedCode ? codeKey(selectedCode) : null" :status="filters.status" @select="selectCode" @update:status="(value) => { filters.status = value; persistFilters(); }" /></template>
|
||||
<div class="billing-main-stack">
|
||||
<nav class="operations-tabs" role="tablist" aria-label="兑换运营数据">
|
||||
<button
|
||||
@@ -469,56 +437,7 @@ function formatDate(value: unknown): string {
|
||||
></template
|
||||
>
|
||||
</div>
|
||||
<template #inspector
|
||||
><InspectorPanel
|
||||
v-if="selectedCode"
|
||||
:title="
|
||||
selectedCode.displayName ||
|
||||
selectedCode.codePrefix ||
|
||||
selectedCode.id ||
|
||||
'兑换码'
|
||||
"
|
||||
:subtitle="selectedCode.id"
|
||||
@close="closeInspector"
|
||||
><dl class="inspector-facts">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ selectedCode.status }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Credits</dt>
|
||||
<dd>{{ formatNumber(selectedCode.creditAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Usage</dt>
|
||||
<dd>
|
||||
{{ formatNumber(selectedCode.redeemedCount) }} /
|
||||
{{ formatNumber(selectedCode.maxRedemptions) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Per user</dt>
|
||||
<dd>{{ formatNumber(selectedCode.perUserLimit) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reason</dt>
|
||||
<dd>{{ selectedCode.reason || "-" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires</dt>
|
||||
<dd>{{ formatDate(selectedCode.expiresAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
class="table-action"
|
||||
type="button"
|
||||
:disabled="pending === `status:${selectedCode.id}`"
|
||||
@click="setStatus(selectedCode)"
|
||||
>
|
||||
{{ selectedCode.status === "disabled" ? "启用" : "禁用" }}
|
||||
</button></InspectorPanel
|
||||
></template
|
||||
>
|
||||
<template #inspector><BillingCodeInspector v-if="selectedCode" :code="selectedCode" :pending="pending === `status:${selectedCode.id}`" @close="closeInspector" @toggle="setStatus" /></template>
|
||||
</OperationsWorkspace>
|
||||
<BaseDialog
|
||||
:open="createOpen"
|
||||
@@ -583,7 +502,7 @@ function formatDate(value: unknown): string {
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.billing-operations {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import HwpodDeviceCard from "@/components/hwpod/HwpodDeviceCard.vue";
|
||||
import HwpodReadinessRail from "@/components/hwpod/HwpodReadinessRail.vue";
|
||||
import HwpodDevicesWorkspace from "@/components/hwpod/HwpodDevicesWorkspace.vue";
|
||||
import HwpodNodesWorkspace from "@/components/hwpod/HwpodNodesWorkspace.vue";
|
||||
import HwpodOnboardingWorkspace from "@/components/hwpod/HwpodOnboardingWorkspace.vue";
|
||||
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
||||
@@ -63,27 +62,6 @@ const statusItems = computed(() => [
|
||||
{ id: "hwpods", label: "HWPOD available", value: `${summary.value?.availableDeviceCount ?? 0}/${summary.value?.deviceCount ?? 0}`, tone: (summary.value?.busyDeviceCount ?? 0) > 0 ? "warn" as const : "neutral" as const },
|
||||
{ id: "invalid", label: "invalid specs", value: summary.value?.invalidSpecCount ?? 0, tone: (summary.value?.invalidSpecCount ?? 0) > 0 ? "error" as const : "neutral" as const }
|
||||
]);
|
||||
const deviceColumns = [
|
||||
{ key: "name", label: "HWPOD", sortable: true, width: "190px" },
|
||||
{ key: "nodeName", label: "Node", width: "170px" },
|
||||
{ key: "status", label: "状态", sortable: true, width: "100px" },
|
||||
{ key: "elements", label: "目标 / 工作区", width: "230px" },
|
||||
{ key: "debugIo", label: "调试 / IO", width: "210px" },
|
||||
{ key: "capabilities", label: "能力", width: "140px" },
|
||||
{ key: "blocker", label: "主要 blocker", width: "250px" },
|
||||
{ key: "operation", label: "最近操作", width: "150px" }
|
||||
];
|
||||
const nodeColumns = [
|
||||
{ key: "name", label: "Node", sortable: true, width: "220px" },
|
||||
{ key: "status", label: "状态", sortable: true, width: "110px" },
|
||||
{ key: "deviceCount", label: "HWPOD", sortable: true, width: "90px" },
|
||||
{ key: "inFlight", label: "in-flight", width: "100px" },
|
||||
{ key: "capabilities", label: "能力", width: "150px" },
|
||||
{ key: "mounted", label: "挂载 HWPOD", width: "220px" },
|
||||
{ key: "readiness", label: "Readiness", width: "210px" },
|
||||
{ key: "lastSeen", label: "最后心跳", width: "160px" },
|
||||
{ key: "blocker", label: "主要 blocker", width: "250px" }
|
||||
];
|
||||
const statusFilterOptions = computed(() => tab.value === "nodes"
|
||||
? [
|
||||
{ value: "online", label: "在线" },
|
||||
@@ -176,8 +154,7 @@ function probeCurrent(): void {
|
||||
void hwpod.refreshConsole(resource, { q: effectiveQuery.value, status: viewState.filter.value, sort: sortField.value as "name" | "status" | "lastSeenAt" | "deviceCount", direction: sortDirection.value, cursor: effectiveCursor.value, probe: true });
|
||||
}
|
||||
|
||||
function chooseNode(event: Event): void {
|
||||
const nodeId = (event.target as HTMLSelectElement).value;
|
||||
function chooseNodeId(nodeId: string): void {
|
||||
void router.replace({ name: "HwpodOnboarding", params: nodeId ? { nodeId } : {}, query: collectionQuery() });
|
||||
}
|
||||
|
||||
@@ -199,12 +176,6 @@ function statusLabel(status: string): string {
|
||||
return ({ available: "可用", busy: "执行中", offline: "离线", "capability-mismatch": "能力不匹配", "workspace-blocked": "工作区阻塞", unprobed: "待探测", online: "在线", unconfigured: "无 Spec" }[status] ?? status);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
if (!value) return "未观测";
|
||||
const date = new Date(value);
|
||||
@@ -270,128 +241,9 @@ function text(value: unknown): string {
|
||||
</aside>
|
||||
|
||||
<BoundedWorkspace class="hwpod-workspace" label="HWPOD 资源工作区">
|
||||
<section v-if="tab === 'devices'" class="resource-stage">
|
||||
<ResourceCollection
|
||||
v-if="view === 'cards'"
|
||||
class="device-collection"
|
||||
:items="devices"
|
||||
:item-key="(device) => device.hwpodId"
|
||||
:view="view"
|
||||
:density="density"
|
||||
:selected-id="selectedDevice?.hwpodId"
|
||||
:show-controls="false"
|
||||
aria-label="HWPOD 设备卡片"
|
||||
@select="selectDeviceItem"
|
||||
>
|
||||
<template #card="{ item, select }"><HwpodDeviceCard :device="item" @select="select" /></template>
|
||||
</ResourceCollection>
|
||||
<DataGrid
|
||||
v-else
|
||||
:columns="deviceColumns"
|
||||
:rows="devices"
|
||||
:row-key="(device) => device.hwpodId"
|
||||
:selected-id="selectedDevice?.hwpodId"
|
||||
:density="density"
|
||||
:sort="viewState.sort.value"
|
||||
aria-label="HWPOD 设备列表"
|
||||
@select="selectDeviceItem"
|
||||
@sort="toggleSort"
|
||||
>
|
||||
<template #cell-name="{ row }"><span class="grid-stack"><strong>{{ row.name }}</strong><code>{{ row.hwpodId }}</code></span></template>
|
||||
<template #cell-nodeName="{ row }"><span class="grid-stack"><strong>{{ row.nodeName }}</strong><code>{{ row.nodeId }}</code></span></template>
|
||||
<template #cell-status="{ row }"><span class="status-chip" :data-status="row.status">{{ statusLabel(row.status) }}</span></template>
|
||||
<template #cell-elements="{ row }"><span class="grid-stack"><strong>{{ row.elements.target.label }}</strong><small>{{ row.elements.workspace.label }}</small></span></template>
|
||||
<template #cell-debugIo="{ row }"><span class="grid-stack"><strong>{{ row.elements.debugProbe.label }}</strong><small>{{ row.elements.ioProbe.label }}</small></span></template>
|
||||
<template #cell-capabilities="{ row }"><span class="grid-stack"><strong>{{ row.actualCapabilities.length }}/{{ row.declaredCapabilities.length }}</strong><small v-if="row.missingCapabilities.length">缺 {{ row.missingCapabilities.join(', ') }}</small></span></template>
|
||||
<template #cell-blocker="{ row }"><span class="grid-stack"><strong>{{ row.blockers[0]?.code || '无' }}</strong><small>{{ row.blockers[0]?.summary || '当前可操作' }}</small></span></template>
|
||||
<template #cell-operation="{ row }"><span class="grid-stack"><strong>{{ row.operation?.status || '无' }}</strong><small>{{ row.operation ? `${row.operation.opCount} ops` : '尚无用户操作' }}</small></span></template>
|
||||
</DataGrid>
|
||||
</section>
|
||||
|
||||
<section v-else-if="tab === 'nodes'" class="resource-stage">
|
||||
<ResourceCollection
|
||||
v-if="view === 'cards'"
|
||||
class="node-collection"
|
||||
:items="nodes"
|
||||
:item-key="(node) => node.nodeId"
|
||||
:view="view"
|
||||
:density="density"
|
||||
:selected-id="selectedNodeDetail?.nodeId"
|
||||
:show-controls="false"
|
||||
aria-label="HWPOD Node 卡片"
|
||||
@select="selectNodeItem"
|
||||
>
|
||||
<template #card="{ item: node, select }">
|
||||
<article class="node-card" :data-status="node.status" :data-testid="`hwpod-node-${node.nodeId}`">
|
||||
<header><div><span>{{ node.platform }} · {{ node.runtimeKind || '未识别运行时' }}</span><h2>{{ node.name }}</h2><code>{{ node.nodeId }}</code></div><span class="status-chip" :data-status="node.status">{{ statusLabel(node.status) }}</span></header>
|
||||
<section class="node-gauges">
|
||||
<div><small>HWPOD</small><strong>{{ node.deviceCount }}</strong></div>
|
||||
<div><small>in-flight</small><strong>{{ node.inFlight.count }}/{{ node.inFlight.max }}</strong></div>
|
||||
<div><small>capabilities</small><strong>{{ node.actualCapabilities.length }}/{{ node.declaredCapabilities.length }}</strong></div>
|
||||
</section>
|
||||
<section class="mounted-hwpods" aria-label="挂载 HWPOD">
|
||||
<header><strong>挂载 HWPOD</strong><span>{{ node.hwpods.length }}</span></header>
|
||||
<ul v-if="node.hwpods.length">
|
||||
<li v-for="device in node.hwpods" :key="device.hwpodId">
|
||||
<div><strong>{{ device.name }}</strong><code>{{ device.hwpodId }}</code></div>
|
||||
<span class="status-chip" :data-status="device.status">{{ statusLabel(device.status) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>节点在线,但 owning registry 尚未绑定 HWPOD spec。</p>
|
||||
</section>
|
||||
<HwpodReadinessRail :stages="node.readiness" />
|
||||
<footer><span>最后心跳 {{ formatTime(node.lastSeenAt) }}</span><strong v-if="node.blockers[0]">{{ node.blockers[0].code }}</strong><button type="button" @click="select">查看详情</button></footer>
|
||||
</article>
|
||||
</template>
|
||||
</ResourceCollection>
|
||||
<DataGrid
|
||||
v-else
|
||||
:columns="nodeColumns"
|
||||
:rows="nodes"
|
||||
:row-key="(node) => node.nodeId"
|
||||
:selected-id="selectedNodeDetail?.nodeId"
|
||||
:density="density"
|
||||
:sort="viewState.sort.value"
|
||||
aria-label="HWPOD Node 列表"
|
||||
@select="selectNodeItem"
|
||||
@sort="toggleSort"
|
||||
>
|
||||
<template #cell-name="{ row }"><span class="grid-stack"><strong>{{ row.name }}</strong><code>{{ row.nodeId }}</code><small>{{ row.platform }} · {{ row.runtimeKind || '未识别运行时' }}</small></span></template>
|
||||
<template #cell-status="{ row }"><span class="status-chip" :data-status="row.status">{{ statusLabel(row.status) }}</span></template>
|
||||
<template #cell-deviceCount="{ row }"><strong>{{ row.deviceCount }}</strong></template>
|
||||
<template #cell-inFlight="{ row }"><strong>{{ row.inFlight.count }}/{{ row.inFlight.max }}</strong></template>
|
||||
<template #cell-capabilities="{ row }"><span class="grid-stack"><strong>{{ row.actualCapabilities.length }}/{{ row.declaredCapabilities.length }}</strong><small v-if="row.missingCapabilities.length">缺 {{ row.missingCapabilities.join(', ') }}</small></span></template>
|
||||
<template #cell-mounted="{ row }"><span class="grid-stack"><strong>{{ row.hwpods.map((item) => item.name).join(', ') || '无' }}</strong><small>{{ row.hwpods.map((item) => item.hwpodId).join(', ') || '未绑定 spec' }}</small></span></template>
|
||||
<template #cell-readiness="{ row }"><span class="grid-stack"><strong>{{ row.readiness.filter((stage: HwpodNodeReadinessStage) => stage.status === 'ready').length }}/{{ row.readiness.length }} ready</strong><small>{{ row.readiness.find((stage: HwpodNodeReadinessStage) => stage.status !== 'ready')?.label || '全部阶段 ready' }}</small></span></template>
|
||||
<template #cell-lastSeen="{ row }">{{ formatTime(row.lastSeenAt) }}</template>
|
||||
<template #cell-blocker="{ row }"><span class="grid-stack"><strong>{{ row.blockers[0]?.code || '无' }}</strong><small>{{ row.blockers[0]?.summary || '当前可操作' }}</small></span></template>
|
||||
</DataGrid>
|
||||
</section>
|
||||
|
||||
<section v-else class="onboarding-grid">
|
||||
<article class="artifact-panel">
|
||||
<span class="panel-kicker">Python single file</span>
|
||||
<h2>{{ artifact?.fileName || 'hwlab-node.py' }}</h2>
|
||||
<p>使用 Windows 交互用户的原生 Python/tkinter 运行,并主动出站连接 Cloud API。下载完成不等于 HWPOD ready。</p>
|
||||
<dl>
|
||||
<div><dt>版本</dt><dd>{{ artifact?.version || '-' }}</dd></div>
|
||||
<div><dt>大小</dt><dd>{{ formatSize(artifact?.sizeBytes || 0) }}</dd></div>
|
||||
<div class="sha-row"><dt>SHA-256</dt><dd><code>{{ artifact?.sha256 || '未发布' }}</code><button type="button" :disabled="!artifact?.sha256" @click="copySha">{{ copied ? '已复制' : '复制' }}</button></dd></div>
|
||||
</dl>
|
||||
<a v-if="artifact?.downloadUrl" class="download-button" :href="artifact.downloadUrl" download="hwlab-node.py">下载 Python 单文件</a>
|
||||
<p v-else class="download-blocked">发布 artifact 当前不可用。</p>
|
||||
<h3>发布说明</h3>
|
||||
<ul><li v-for="note in hwpod.nodeUpdate?.releaseNotes || []" :key="note">{{ note }}</li></ul>
|
||||
<h3>安装条件</h3>
|
||||
<ul><li v-for="condition in hwpod.nodeUpdate?.installConditions || []" :key="condition.id">{{ condition.label }}</li></ul>
|
||||
</article>
|
||||
|
||||
<article class="readiness-panel">
|
||||
<header><div><span class="panel-kicker">Staged readiness</span><h2>接入进度</h2></div><label>Node<select :value="selectedNode?.nodeId || ''" @change="chooseNode"><option value="">未选择</option><option v-for="node in nodes" :key="node.nodeId" :value="node.nodeId">{{ node.name }} · {{ statusLabel(node.status) }}</option></select></label></header>
|
||||
<HwpodReadinessRail :stages="onboardingStages" />
|
||||
<aside class="outbound-note"><strong>网络边界</strong><span>节点只使用主动出站 WebSocket。网页不生成凭据,不要求用户 PC 开放入站端口。</span></aside>
|
||||
</article>
|
||||
</section>
|
||||
<HwpodDevicesWorkspace v-if="tab === 'devices'" :devices="devices" :selected-id="selectedDevice?.hwpodId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectDeviceItem" @sort="toggleSort" />
|
||||
<HwpodNodesWorkspace v-else-if="tab === 'nodes'" :nodes="nodes" :selected-id="selectedNodeDetail?.nodeId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectNodeItem" @sort="toggleSort" />
|
||||
<HwpodOnboardingWorkspace v-else :metadata="hwpod.nodeUpdate" :nodes="nodes" :selected-node-id="selectedNode?.nodeId || ''" :stages="onboardingStages" :copied="copied" @copy="copySha" @node="chooseNodeId" />
|
||||
</BoundedWorkspace>
|
||||
<nav v-if="collectionPage && tab !== 'onboarding'" class="collection-pagination" aria-label="HWPOD 资源分页">
|
||||
<span>本页 {{ collectionPage.returned }} / 共 {{ collectionPage.total }}</span>
|
||||
@@ -439,7 +291,7 @@ function text(value: unknown): string {
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.hwpod-console { display: grid; min-width: 0; min-height: 100%; align-content: start; gap: 12px; padding: 8px; color: var(--console-graphite-900); }
|
||||
.console-tabs { display: flex; gap: 2px; width: fit-content; max-width: 100%; padding: 3px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-border); overflow-x: auto; }
|
||||
.console-tabs button { min-height: 32px; border: 0; border-radius: 5px; background: var(--console-surface-raised); color: var(--console-graphite-600); padding: 0 12px; font-size: 12px; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
||||
|
||||
@@ -12,8 +12,8 @@ import ContentViewer from "@/components/common/ContentViewer.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import EntityCard from "@/components/common/EntityCard.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||
import { useForm } from "@/composables/useForm";
|
||||
@@ -316,11 +316,7 @@ function restoreSelectedProfile(): void {
|
||||
</template>
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="filteredRows.length" :items="filteredRows" :item-key="rowKey" view="cards" :selected-id="selectedProfile?.profile" :show-controls="false" aria-label="Provider Profile 卡片" @select="selectProfile">
|
||||
<template #card="{ item: row }"><article class="provider-card" :data-selected="selectedProfile?.profile === row.profile">
|
||||
<header><button class="provider-glyph" type="button" :aria-label="`选择 ${row.profile}`" @click="selectProfile(row)"><ConsoleIcon name="provider" /></button><div><button class="provider-name" type="button" @click="selectProfile(row)">{{ row.profile }}</button><small>{{ row.secretRefText }}</small></div><span class="status-pill" :data-status="row.configured ? 'active' : 'disabled'">{{ row.configured ? 'configured' : 'missing' }}</span></header>
|
||||
<dl><div><dt>Fingerprint</dt><dd>key {{ row.credentialHashSuffix || '-' }} / cfg {{ row.configHashSuffix || '-' }}</dd></div><div><dt>Bridge</dt><dd>{{ row.bridgeText || '-' }}</dd></div><div><dt>Validation</dt><dd>{{ row.validationStatus || 'not run' }}<small v-if="row.failureKind">{{ row.failureKind }}</small></dd></div></dl>
|
||||
<footer class="table-actions-inline"><button class="table-action" type="button" @click="openCredential(row)">Key</button><button class="table-action" type="button" @click="openConfig(row)">Config</button><button class="table-action" type="button" @click="openTest(row)">测试</button><button class="table-action" type="button" :disabled="validatingProfile !== null" @click="validateProfile(row)">{{ validatingProfile === row.profile ? '验证中' : 'Validate' }}</button><button class="table-action danger" type="button" @click="openRemove(row)">Remove</button></footer>
|
||||
</article></template>
|
||||
<template #card="{ item: row }"><EntityCard icon="provider" :title="row.profile" :subtitle="row.secretRefText" :status="row.configured ? 'active' : 'disabled'" :status-label="row.configured ? 'configured' : 'missing'" :selected="selectedProfile?.profile === row.profile" @select="selectProfile(row)"><dl class="industrial-fact-row"><div><dt>Fingerprint</dt><dd>key {{ row.credentialHashSuffix || '-' }} / cfg {{ row.configHashSuffix || '-' }}</dd></div><div><dt>Bridge</dt><dd>{{ row.bridgeText || '-' }}</dd></div><div><dt>Validation</dt><dd>{{ row.validationStatus || 'not run' }}<small v-if="row.failureKind">{{ row.failureKind }}</small></dd></div></dl><template #footer><div class="table-actions-inline"><button class="table-action" type="button" @click="openCredential(row)">Key</button><button class="table-action" type="button" @click="openConfig(row)">Config</button><button class="table-action" type="button" @click="openTest(row)">测试</button><button class="table-action" type="button" :disabled="validatingProfile !== null" @click="validateProfile(row)">{{ validatingProfile === row.profile ? '验证中' : 'Validate' }}</button><button class="table-action danger" type="button" @click="openRemove(row)">Remove</button></div></template></EntityCard></template>
|
||||
</ResourceCollection>
|
||||
<EmptyState v-else title="没有匹配 Profile" description="调整配置状态或搜索;凭据仅显示 SecretRef 与 fingerprint。" />
|
||||
</TablePageLayout>
|
||||
@@ -423,19 +419,6 @@ function restoreSelectedProfile(): void {
|
||||
.provider-view-toggle { display: inline-flex; padding: 2px; border: 1px solid #b8c5cc; border-radius: 7px; background: #eef3f4; }
|
||||
.provider-view-toggle button { height: 28px; border: 0; border-radius: 5px; background: transparent; padding: 0 11px; color: #64757d; font-size: 10px; font-weight: 800; }
|
||||
.provider-view-toggle button[data-active="true"] { background: #183b43; color: #fff; }
|
||||
.provider-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 9px; padding: 10px; }
|
||||
.provider-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; }
|
||||
.provider-card[data-selected="true"] { border-color: #1ba39e; box-shadow: inset 3px 0 #1ba39e; }
|
||||
.provider-card header { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; gap: 9px; align-items: center; }
|
||||
.provider-card header > div { display: grid; min-width: 0; }
|
||||
.provider-card header small { overflow: hidden; color: #6b7c84; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.provider-glyph { width: 38px; height: 38px; border: 0; border-radius: 6px; background: #dcefed; color: #126f6a; font: 900 10px/1 ui-monospace, monospace; }
|
||||
.provider-name { overflow: hidden; border: 0; background: none; padding: 0; color: #176d6a; font-weight: 850; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.provider-card dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin: 0; }
|
||||
.provider-card dl div { display: grid; gap: 3px; min-width: 0; border-top: 1px solid #e0e7e9; padding-top: 7px; }
|
||||
.provider-card dt { color: #708087; font-size: 9px; font-weight: 850; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.provider-card dd { overflow: hidden; margin: 0; font: 750 10px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.provider-card footer { flex-wrap: wrap; justify-content: flex-end; }
|
||||
.provider-validation-panel { box-shadow: inset 3px 0 #1ba39e; }
|
||||
.provider-test-output {
|
||||
max-height: 180px;
|
||||
@@ -453,6 +436,5 @@ function restoreSelectedProfile(): void {
|
||||
.provider-filter-bar { grid-template-columns: 1fr; }
|
||||
.provider-view-toggle { justify-self: stretch; }
|
||||
.provider-view-toggle button { flex: 1; }
|
||||
.provider-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,13 +6,11 @@ import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.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 SecretInventoryIndex from "@/components/secrets/SecretInventoryIndex.vue";
|
||||
import SecretInventoryWorkspace from "@/components/secrets/SecretInventoryWorkspace.vue";
|
||||
import { useConsoleViewState } from "@/composables/useConsoleViewState";
|
||||
import {
|
||||
normalizeAdminSecretsRows,
|
||||
@@ -266,147 +264,15 @@ function displayDate(value: string): string {
|
||||
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>
|
||||
<SecretInventoryIndex v-model:query="query" v-model:status="statusFilter" :rows="filteredRows" :total="rows.length" :selected-id="selected ? rowKey(selected) : null" @select="openSecret" />
|
||||
</template>
|
||||
<div v-if="selected" class="operations-main-stack">
|
||||
<div class="view-mode-row">
|
||||
<ViewModeSwitch
|
||||
v-model="viewMode"
|
||||
:options="[
|
||||
{ value: 'flow', label: '流向', icon: 'flow' },
|
||||
{ value: 'list', label: '清单', icon: 'list' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<FactPath
|
||||
v-if="viewMode === 'flow'"
|
||||
:steps="factSteps"
|
||||
label="Secret 脱敏事实路径"
|
||||
/>
|
||||
<SectionFrame
|
||||
v-else
|
||||
title="库存清单"
|
||||
description="同一 ExternalSecret DTO 的紧凑字段视图。"
|
||||
:body-padding="false"
|
||||
><div class="secret-compact-list">
|
||||
<button
|
||||
v-for="row in filteredRows"
|
||||
:key="rowKey(row)"
|
||||
type="button"
|
||||
:data-selected="selected && rowKey(row) === rowKey(selected)"
|
||||
@click="openSecret(row)"
|
||||
>
|
||||
<strong>{{ row.namespace }}/{{ row.name }}</strong
|
||||
><StatusBadge :status="row.status" :label="row.status" /><code
|
||||
>{{ row.storeKind }}/{{ row.storeName }}</code
|
||||
><code>{{ row.targetSecretName }}</code
|
||||
><span>{{ row.ageText }}</span>
|
||||
</button>
|
||||
</div></SectionFrame
|
||||
>
|
||||
<SectionFrame
|
||||
title="ExternalSecret 事实"
|
||||
description="库存主面只保留可扫描的来源、目标、状态与 fingerprint 元数据。"
|
||||
>
|
||||
<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="调整库存筛选。" />
|
||||
<SecretInventoryWorkspace v-model:view="viewMode" :selected="selected" :rows="filteredRows" :fact-steps="factSteps" :generated-at="summary?.generatedAt || ''" @select="openSecret" @inspect="openSecret" />
|
||||
</OperationsWorkspace>
|
||||
</AsyncBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.secrets-operations {
|
||||
min-height: 610px;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,14 @@ import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import Pagination from "@/components/common/Pagination.vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import EntityIndex from "@/components/console/EntityIndex.vue";
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
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 UserDirectoryCollection from "@/components/users/UserDirectoryCollection.vue";
|
||||
import UserDirectoryIndex from "@/components/users/UserDirectoryIndex.vue";
|
||||
import UserAccountInspector from "@/components/users/UserAccountInspector.vue";
|
||||
import { useConsoleViewState } from "@/composables/useConsoleViewState";
|
||||
import type {
|
||||
AccessUserStatus,
|
||||
@@ -611,162 +610,10 @@ function metadataPreview(value: unknown): string {
|
||||
label="用户身份运营"
|
||||
:inspector-open="Boolean(selectedUserId)"
|
||||
>
|
||||
<template #index>
|
||||
<EntityIndex
|
||||
:items="users"
|
||||
:item-key="userKey"
|
||||
:selected-id="selectedUserId"
|
||||
label="用户索引"
|
||||
@select="(item) => selectUser(item.user?.id)"
|
||||
>
|
||||
<template #toolbar
|
||||
><div class="user-index-filters admin-users-filters">
|
||||
<input
|
||||
v-model="filters.search"
|
||||
class="input"
|
||||
aria-label="搜索用户"
|
||||
placeholder="搜索用户名、邮箱或 user id"
|
||||
@keyup.enter="applyFilters"
|
||||
/><select
|
||||
v-model="filters.status"
|
||||
class="input"
|
||||
aria-label="用户状态"
|
||||
@change="applyFilters"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="pending">pending</option></select
|
||||
><select
|
||||
v-model="filters.role"
|
||||
class="input"
|
||||
aria-label="用户角色"
|
||||
@change="applyFilters"
|
||||
>
|
||||
<option value="">全部角色</option>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div></template
|
||||
>
|
||||
<template #item="{ item }"
|
||||
><strong>{{
|
||||
item.user?.displayName || item.user?.username || item.user?.id
|
||||
}}</strong
|
||||
><small
|
||||
>{{ item.user?.email || item.user?.username }} ·
|
||||
{{ item.planId || "default" }}</small
|
||||
><span class="entity-index-meta"
|
||||
><span class="status-pill" :data-status="item.user?.status"
|
||||
>{{ item.user?.role }} / {{ item.user?.status }}</span
|
||||
><code
|
||||
>{{ formatNumber(item.credits?.available) }} cr</code
|
||||
></span
|
||||
></template
|
||||
>
|
||||
<template #footer
|
||||
><Pagination
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@update:page="setPage"
|
||||
/></template>
|
||||
</EntityIndex>
|
||||
</template>
|
||||
<template #index><UserDirectoryIndex v-model:search="filters.search" v-model:status="filters.status" v-model:role="filters.role" :users="users" :selected-id="selectedUserId" :page="page" :page-size="pageSize" :total="total" @select="selectUser" @apply="applyFilters" @page="setPage" /></template>
|
||||
|
||||
<div class="user-main-stack">
|
||||
<SectionFrame
|
||||
:title="viewMode === 'grid' ? '用户实体网格' : '用户紧凑列表'"
|
||||
description="选择动作与状态写操作使用同级控件,避免交互元素嵌套。"
|
||||
:body-padding="false"
|
||||
>
|
||||
<div v-if="viewMode === 'grid'" class="user-entity-grid">
|
||||
<article
|
||||
v-for="item in users"
|
||||
:key="userKey(item)"
|
||||
class="user-entity-card"
|
||||
:data-selected="userKey(item) === selectedUserId"
|
||||
>
|
||||
<button
|
||||
class="user-entity-select"
|
||||
type="button"
|
||||
@click="selectUser(item.user?.id)"
|
||||
>
|
||||
<ConsoleIcon name="users" :size="20" /><strong>{{
|
||||
item.user?.displayName || item.user?.username
|
||||
}}</strong
|
||||
><small>{{ item.user?.email || item.user?.id }}</small>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Available</dt>
|
||||
<dd>{{ formatNumber(item.credits?.available) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Usage</dt>
|
||||
<dd>{{ formatNumber(item.usage?.totalCredits) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Keys</dt>
|
||||
<dd>{{ formatNumber(item.apiKeys?.activeCount) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</button>
|
||||
<button
|
||||
class="table-action"
|
||||
type="button"
|
||||
:disabled="pending === `status:${item.user?.id}`"
|
||||
@click="setUserStatus(item)"
|
||||
>
|
||||
{{ item.user?.status === "disabled" ? "启用" : "禁用" }}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
<DataGrid
|
||||
v-else
|
||||
:columns="userColumns"
|
||||
:rows="users"
|
||||
:row-key="userKey"
|
||||
:selected-id="selectedUserId"
|
||||
aria-label="用户列表"
|
||||
@select="(item) => selectUser(item.user?.id)"
|
||||
>
|
||||
<template #cell-user="{ row }"
|
||||
><strong>{{
|
||||
row.user?.displayName || row.user?.username
|
||||
}}</strong
|
||||
><small
|
||||
>{{ row.user?.email || row.user?.id }} ·
|
||||
{{ row.planId || "default" }}</small
|
||||
></template
|
||||
>
|
||||
<template #cell-status="{ row }"
|
||||
><span class="status-pill" :data-status="row.user?.status"
|
||||
>{{ row.user?.role }} / {{ row.user?.status }}</span
|
||||
></template
|
||||
>
|
||||
<template #cell-credits="{ row }"
|
||||
>{{ formatNumber(row.credits?.available) }} /
|
||||
{{ formatNumber(row.credits?.reserved) }}</template
|
||||
>
|
||||
<template #cell-usage="{ row }">{{
|
||||
formatNumber(row.usage?.totalCredits)
|
||||
}}</template>
|
||||
<template #cell-keys="{ row }"
|
||||
>{{ formatNumber(row.apiKeys?.activeCount) }} /
|
||||
{{ formatNumber(row.apiKeys?.totalCount) }}</template
|
||||
>
|
||||
<template #cell-actions="{ row }"
|
||||
><button
|
||||
class="table-action"
|
||||
type="button"
|
||||
:disabled="pending === `status:${row.user?.id}`"
|
||||
@click.stop="setUserStatus(row)"
|
||||
>
|
||||
{{ row.user?.status === "disabled" ? "启用" : "禁用" }}
|
||||
</button></template
|
||||
>
|
||||
</DataGrid>
|
||||
</SectionFrame>
|
||||
<UserDirectoryCollection :users="users" :selected-id="selectedUserId" :view="viewMode" :pending-id="pending?.startsWith('status:') ? pending.slice(7) : null" @select="selectUser" @status="setUserStatus" />
|
||||
|
||||
<SectionFrame
|
||||
title="全局 Usage / Ledger"
|
||||
@@ -934,203 +781,7 @@ function metadataPreview(value: unknown): string {
|
||||
</SectionFrame>
|
||||
</div>
|
||||
|
||||
<template #inspector>
|
||||
<InspectorPanel
|
||||
v-if="selectedUserId"
|
||||
:title="
|
||||
selectedUser?.displayName ||
|
||||
selectedUser?.username ||
|
||||
selectedUserId
|
||||
"
|
||||
:subtitle="selectedUserId"
|
||||
@close="closeInspector"
|
||||
>
|
||||
<template #tabs
|
||||
><button
|
||||
v-for="tab in [
|
||||
'profile',
|
||||
'credits',
|
||||
'entitlements',
|
||||
'keys',
|
||||
'reservations',
|
||||
'ledger',
|
||||
]"
|
||||
:key="tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="detailTab === tab"
|
||||
@click="detailTab = tab"
|
||||
>
|
||||
{{ tab }}
|
||||
</button></template
|
||||
>
|
||||
<p v-if="detailLoading" class="muted-box">详情加载中…</p>
|
||||
<p v-else-if="mutationError" class="form-error">
|
||||
{{ mutationError }}
|
||||
</p>
|
||||
<div
|
||||
v-if="!detailLoading && detailTab === 'profile'"
|
||||
class="form-grid"
|
||||
>
|
||||
<label>邮箱<input v-model="editForm.email" class="input" /></label
|
||||
><label
|
||||
>用户名<input
|
||||
v-model="editForm.username"
|
||||
class="input" /></label
|
||||
><label
|
||||
>显示名<input
|
||||
v-model="editForm.displayName"
|
||||
class="input" /></label
|
||||
><label
|
||||
>新密码<input
|
||||
v-model="editForm.password"
|
||||
class="input"
|
||||
type="password" /></label
|
||||
><label
|
||||
>角色<select v-model="editForm.role" class="input">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select></label
|
||||
><label
|
||||
>状态<select v-model="editForm.status" class="input">
|
||||
<option value="active">active</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select></label
|
||||
><label
|
||||
>Plan<select v-model="editForm.planId" class="input">
|
||||
<option
|
||||
v-for="item in plans"
|
||||
:key="item.plan?.id"
|
||||
:value="item.plan?.id"
|
||||
>
|
||||
{{ item.plan?.displayName || item.plan?.id }}
|
||||
</option>
|
||||
</select></label
|
||||
><button
|
||||
class="table-action"
|
||||
type="button"
|
||||
:disabled="pending === `save:${selectedUserId}`"
|
||||
@click="saveUser"
|
||||
>
|
||||
保存资料
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="detailTab === 'credits'" class="form-grid">
|
||||
<p class="muted-box">
|
||||
Available
|
||||
{{ formatNumber(selectedSummary?.credits?.available) }} ·
|
||||
Reserved {{ formatNumber(selectedSummary?.credits?.reserved) }}
|
||||
</p>
|
||||
<label
|
||||
>Delta<input
|
||||
v-model.number="creditForm.deltaCredits"
|
||||
class="input"
|
||||
type="number" /></label
|
||||
><label
|
||||
>Reason<input
|
||||
v-model="creditForm.reason"
|
||||
class="input" /></label
|
||||
><label
|
||||
>Idempotency key<input
|
||||
v-model="creditForm.idempotencyKey"
|
||||
class="input" /></label
|
||||
><button
|
||||
class="table-action"
|
||||
type="button"
|
||||
:disabled="pending === `credits:${selectedUserId}`"
|
||||
@click="adjustCredits"
|
||||
>
|
||||
调整 Credits
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailTab === 'entitlements'"
|
||||
class="inspector-fact-list"
|
||||
>
|
||||
<article
|
||||
v-for="item in selectedEntitlements"
|
||||
:key="item.id || item.resourceType"
|
||||
>
|
||||
<strong>{{ item.resourceType }}</strong
|
||||
><small
|
||||
>{{ item.enabled === false ? "disabled" : "enabled" }} ·
|
||||
monthly {{ formatNumber(item.monthlyUsageCredits) }} /
|
||||
{{
|
||||
item.monthlyQuotaUnlimited
|
||||
? "unlimited"
|
||||
: formatNumber(item.monthlyQuotaCredits)
|
||||
}}</small
|
||||
><small
|
||||
>concurrent {{ formatNumber(item.activeReservations) }} /
|
||||
{{
|
||||
item.concurrencyUnlimited
|
||||
? "unlimited"
|
||||
: formatNumber(item.maxConcurrentReservations)
|
||||
}}
|
||||
· RPM {{ formatNumber(item.rpmUsed) }} /
|
||||
{{
|
||||
item.rpmUnlimited
|
||||
? "unlimited"
|
||||
: formatNumber(item.rpmLimit)
|
||||
}}</small
|
||||
>
|
||||
</article>
|
||||
<p v-if="!selectedEntitlements.length" class="muted-box">
|
||||
暂无 entitlement。
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="detailTab === 'keys'" class="inspector-fact-list">
|
||||
<article v-for="item in apiKeys" :key="item.id">
|
||||
<strong>{{ item.name || item.id }}</strong
|
||||
><small
|
||||
>{{ item.prefix || "-" }} ·
|
||||
{{ item.revokedAt ? "revoked" : "active" }}</small
|
||||
><small
|
||||
>created {{ formatDate(item.createdAt) }} · last used
|
||||
{{ formatDate(item.lastUsedAt) }}</small
|
||||
>
|
||||
</article>
|
||||
<p v-if="!apiKeys.length" class="muted-box">暂无 API key。</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailTab === 'reservations'"
|
||||
class="inspector-fact-list"
|
||||
>
|
||||
<article v-for="item in reservations" :key="item.reservationId">
|
||||
<strong>{{ item.reservationId }}</strong
|
||||
><small
|
||||
>{{ item.serviceId }} · {{ item.resourceType || "generic" }} ·
|
||||
{{ item.status }}</small
|
||||
><small
|
||||
>{{ formatNumber(item.estimatedCredits) }} credits · expires
|
||||
{{ formatDate(item.expiresAt) }}</small
|
||||
>
|
||||
</article>
|
||||
<p v-if="!reservations.length" class="muted-box">
|
||||
没有 active reservation。
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="inspector-fact-list">
|
||||
<article v-for="item in ledgerRows" :key="auditKey(item)">
|
||||
<strong
|
||||
>{{ item.kind }} ·
|
||||
{{ formatSigned(item.deltaCredits) }}</strong
|
||||
><small
|
||||
>{{ formatDate(item.createdAt) }} · {{ item.reason }} ·
|
||||
{{ item.status }}</small
|
||||
><small
|
||||
>{{ formatNumber(item.balanceBefore) }} →
|
||||
{{ formatNumber(item.balanceAfter) }} ·
|
||||
{{ item.idempotencyKey || "-" }}</small
|
||||
>
|
||||
</article>
|
||||
<p v-if="!ledgerRows.length" class="muted-box">
|
||||
暂无用户 ledger。
|
||||
</p>
|
||||
</div>
|
||||
</InspectorPanel>
|
||||
</template>
|
||||
<template #inspector><UserAccountInspector v-if="selectedUserId" :user-id="selectedUserId" :user="selectedUser" :summary="selectedSummary" :plans="plans" :tab="detailTab" :loading="detailLoading" :error="mutationError" :pending="pending" :edit-form="editForm" :credit-form="creditForm" :entitlements="selectedEntitlements" :api-keys="apiKeys" :reservations="reservations" :ledger-rows="ledgerRows" @close="closeInspector" @tab="detailTab = $event" @save="saveUser" @adjust="adjustCredits" /></template>
|
||||
</OperationsWorkspace>
|
||||
<BaseDrawer
|
||||
:open="createOpen"
|
||||
@@ -1194,7 +845,7 @@ function metadataPreview(value: unknown): string {
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.user-operations-page,
|
||||
.user-main-stack {
|
||||
min-width: 0;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { projectManagementAPI, type MdtodoTaskRecord, type ProjectNavigationResp
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import ProjectResourceCard from "@/components/projects/ProjectResourceCard.vue";
|
||||
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
||||
@@ -257,20 +257,8 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
|
||||
</header>
|
||||
<EmptyState v-if="!filteredProjects.length" title="没有匹配项目" description="调整搜索或状态筛选;服务返回的项目事实没有被浏览器改写。" />
|
||||
<ResourceCollection v-else :items="filteredProjects" :item-key="projectKey" :item-title="projectTitle" :item-subtitle="projectSubtitle" :item-status="projectStatus" :view="viewMode" :density="density" :selected-id="selectedProject?.projectId" :show-controls="false" aria-label="项目集合" @select="openProject">
|
||||
<template #card="{ item: project }">
|
||||
<RouterLink class="project-card" :class="{ selected: selectedProject?.projectId === project.projectId }" :data-project-id="project.projectId" :to="{ name: 'ProjectDetail', params: { projectId: project.projectId }, query: route.query }">
|
||||
<div class="project-card-main"><span class="project-glyph" aria-hidden="true"><ConsoleIcon name="project" /></span><span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span><div><h3>{{ projectTitle(project) }}</h3><p>{{ project.projectId }}</p></div></div>
|
||||
<div class="project-source-chips" aria-label="项目 source"><span v-for="source in projectSources(project)" :key="source.sourceId" class="source-chip" :data-status="source.status || 'unknown'">{{ source.displayName || source.sourceId }} · {{ source.status || 'unknown' }}</span><span v-if="!projectSources(project).length" class="source-chip muted">无可见 source</span></div>
|
||||
<span class="project-open-hint">查看详情 →</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template #list="{ item: project }">
|
||||
<RouterLink class="project-card" :class="{ selected: selectedProject?.projectId === project.projectId }" :data-project-id="project.projectId" :to="{ name: 'ProjectDetail', params: { projectId: project.projectId }, query: route.query }">
|
||||
<div class="project-card-main"><span class="project-glyph" aria-hidden="true"><ConsoleIcon name="project" /></span><span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span><div><h3>{{ projectTitle(project) }}</h3><p>{{ project.projectId }}</p></div></div>
|
||||
<div class="project-source-chips" aria-label="项目 source"><span v-for="source in projectSources(project)" :key="source.sourceId" class="source-chip" :data-status="source.status || 'unknown'">{{ source.displayName || source.sourceId }} · {{ source.status || 'unknown' }}</span><span v-if="!projectSources(project).length" class="source-chip muted">无可见 source</span></div>
|
||||
<span class="project-open-hint">查看详情 →</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template #card="{ item: project }"><ProjectResourceCard :project="project" :sources="projectSources(project)" :selected="selectedProject?.projectId === project.projectId" :to="{ name: 'ProjectDetail', params: { projectId: project.projectId }, query: route.query }" /></template>
|
||||
<template #list="{ item: project }"><ProjectResourceCard :project="project" :sources="projectSources(project)" :selected="selectedProject?.projectId === project.projectId" :to="{ name: 'ProjectDetail', params: { projectId: project.projectId }, query: route.query }" /></template>
|
||||
</ResourceCollection>
|
||||
</section>
|
||||
|
||||
@@ -359,18 +347,7 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
|
||||
.project-collection { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; min-width: 0; }
|
||||
.project-collection[data-density="compact"] { gap: 6px; }
|
||||
.project-collection[data-view="list"] { grid-template-columns: 1fr; }
|
||||
.project-card { display: grid; min-width: 0; gap: 10px; border: 1px solid #d0dae3; border-radius: 8px; background: linear-gradient(145deg, #fff 0%, #f4f7f8 100%); padding: 13px; color: inherit; text-decoration: none; box-shadow: 0 5px 16px rgba(26, 45, 54, .05); transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease; }
|
||||
.project-card:hover,
|
||||
.project-card.selected { border-color: #178b8f; box-shadow: 0 8px 24px rgba(18, 91, 96, .12); transform: translateY(-1px); }
|
||||
.project-collection[data-density="compact"] .project-card { padding: 9px 11px; }
|
||||
.project-collection[data-view="list"] .project-card { grid-template-columns: minmax(240px, .75fr) minmax(180px, 1fr) auto; align-items: center; }
|
||||
.project-card-main { display: grid; min-width: 0; gap: 6px; }
|
||||
.project-card-main { grid-template-columns: 38px auto minmax(0, 1fr); align-items: center; }
|
||||
.project-glyph { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #68b7b8; border-radius: 7px; background: #d9f1ef; color: #115e62; font: 800 11px/1 ui-monospace, monospace; letter-spacing: .08em; }
|
||||
.project-card h3 { margin: 0; color: #0f172a; font-size: 18px; line-height: 1.2; }
|
||||
.project-card p { margin: 0; color: #64748b; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.project-source-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.project-open-hint { color: #187174; font-size: 11px; font-weight: 800; letter-spacing: .03em; }
|
||||
.project-inspector { position: sticky; top: 0; align-self: start; display: grid; max-height: calc(100dvh - 120px); min-height: 0; gap: 14px; overflow: auto; padding: 14px; border-color: #a7c8c7; box-shadow: inset 3px 0 #14b8a6; }
|
||||
.project-inspector-header { display: flex; gap: 12px; justify-content: space-between; border-bottom: 1px solid #d8e1e7; padding-bottom: 12px; }
|
||||
.project-inspector-header small { color: #0f766e; font: 800 10px/1 ui-monospace, monospace; letter-spacing: .12em; }
|
||||
@@ -418,7 +395,6 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
|
||||
.project-search { grid-column: 1 / -1; }
|
||||
.project-summary-grid { grid-template-columns: 1fr 1fr; }
|
||||
.project-collection { grid-template-columns: 1fr; }
|
||||
.project-collection[data-view="list"] .project-card { grid-template-columns: 1fr; }
|
||||
.project-root-grid.lower { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@ import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
||||
import ApiKeyResourceCard from "@/components/api-keys/ApiKeyResourceCard.vue";
|
||||
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
||||
@@ -196,26 +196,8 @@ function formatDate(value: unknown): string {
|
||||
<header><div><span>USER BILLING AUTHORITY</span><h2>当前 API Keys</h2></div><small>prefix / lifecycle / timestamps</small></header>
|
||||
<EmptyState v-if="!filteredKeys.length" title="没有匹配 API key" description="调整搜索或状态筛选;当前不会虚构 scope、轮换或额外权限。" />
|
||||
<ResourceCollection v-else :items="filteredKeys" :item-key="keyId" :item-title="keyTitle" :item-subtitle="keySubtitle" :item-status="keyStatus" :view="viewMode" :density="density" :selected-id="selectedKey?.id" :show-controls="false" aria-label="API Key 集合" @select="selectKey">
|
||||
<template #card="{ item: key }">
|
||||
<RouterLink class="api-key-card" :class="{ selected: selectedKey?.id === key.id }" :to="{ name: 'ApiKeyDetail', params: { keyId: key.id }, query: route.query }">
|
||||
<span class="api-key-glyph" aria-hidden="true"><ConsoleIcon name="key" /></span>
|
||||
<div class="api-key-name"><strong>{{ key.name || key.id }}</strong><code>{{ key.id }}</code></div>
|
||||
<code class="api-key-prefix">{{ key.prefix || '-' }}</code>
|
||||
<span class="status-pill" :data-status="key.revokedAt ? 'disabled' : 'active'">{{ key.revokedAt ? 'revoked' : 'active' }}</span>
|
||||
<div class="api-key-time"><span>LAST USED</span><strong>{{ formatDate(key.lastUsedAt) }}</strong></div>
|
||||
<span class="api-key-arrow" aria-hidden="true">→</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template #list="{ item: key }">
|
||||
<RouterLink class="api-key-card" :class="{ selected: selectedKey?.id === key.id }" :to="{ name: 'ApiKeyDetail', params: { keyId: key.id }, query: route.query }">
|
||||
<span class="api-key-glyph" aria-hidden="true"><ConsoleIcon name="key" /></span>
|
||||
<div class="api-key-name"><strong>{{ key.name || key.id }}</strong><code>{{ key.id }}</code></div>
|
||||
<code class="api-key-prefix">{{ key.prefix || '-' }}</code>
|
||||
<span class="status-pill" :data-status="key.revokedAt ? 'disabled' : 'active'">{{ key.revokedAt ? 'revoked' : 'active' }}</span>
|
||||
<div class="api-key-time"><span>LAST USED</span><strong>{{ formatDate(key.lastUsedAt) }}</strong></div>
|
||||
<span class="api-key-arrow" aria-hidden="true">→</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template #card="{ item: key }"><ApiKeyResourceCard :api-key="key" :selected="selectedKey?.id === key.id" :to="{ name: 'ApiKeyDetail', params: { keyId: key.id }, query: route.query }" /></template>
|
||||
<template #list="{ item: key }"><ApiKeyResourceCard :api-key="key" :selected="selectedKey?.id === key.id" :to="{ name: 'ApiKeyDetail', params: { keyId: key.id }, query: route.query }" /></template>
|
||||
</ResourceCollection>
|
||||
</section>
|
||||
|
||||
@@ -286,21 +268,6 @@ function formatDate(value: unknown): string {
|
||||
.api-key-collection { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 9px; }
|
||||
.api-key-collection[data-view="list"] { grid-template-columns: 1fr; }
|
||||
.api-key-collection[data-density="compact"] { gap: 5px; }
|
||||
.api-key-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; gap: 10px; align-items: center; min-width: 0; border: 1px solid #d0dbe1; border-radius: 8px; background: linear-gradient(145deg, #fff, #f5f8f9); padding: 12px; color: inherit; text-decoration: none; }
|
||||
.api-key-card:hover,
|
||||
.api-key-card.selected { border-color: #31918f; box-shadow: 0 7px 18px rgba(28, 91, 91, .1); }
|
||||
.api-key-collection[data-density="compact"] .api-key-card { padding: 8px 10px; }
|
||||
.api-key-collection[data-view="list"] .api-key-card { grid-template-columns: 42px minmax(170px, 1fr) minmax(110px, .55fr) 90px minmax(130px, .6fr) auto; }
|
||||
.api-key-glyph { display: grid; width: 38px; height: 38px; place-items: center; border: 1px solid #71b8b4; border-radius: 7px; background: #def2ef; color: #14615f; font: 850 9px/1 ui-monospace, monospace; }
|
||||
.api-key-name { display: grid; min-width: 0; gap: 4px; }
|
||||
.api-key-name strong { overflow: hidden; color: #1b2e36; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.api-key-name code,
|
||||
.api-key-prefix { overflow: hidden; color: #63757d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.api-key-time { display: none; min-width: 0; gap: 3px; }
|
||||
.api-key-collection[data-view="list"] .api-key-time { display: grid; }
|
||||
.api-key-time span { color: #7b8990; font-size: 8px; font-weight: 800; letter-spacing: .08em; }
|
||||
.api-key-time strong { color: #3e5059; font-size: 10px; }
|
||||
.api-key-arrow { color: #277e7c; }
|
||||
.api-key-inspector { position: sticky; top: 0; align-self: start; display: grid; gap: 13px; max-height: calc(100dvh - 120px); overflow: auto; padding: 14px; box-shadow: inset 3px 0 #17a39e; }
|
||||
.api-key-inspector > header a { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid #c7d1d7; border-radius: 6px; color: #344850; text-decoration: none; }
|
||||
.api-key-inspector dl { display: grid; gap: 0; margin: 0; }
|
||||
@@ -329,7 +296,6 @@ function formatDate(value: unknown): string {
|
||||
.api-key-summary-strip p { grid-column: 1 / -1; border-top: 1px solid #dbe3e7; padding: 10px 13px; }
|
||||
.api-key-collection,
|
||||
.api-key-collection[data-view="list"] { grid-template-columns: 1fr; }
|
||||
.api-key-collection[data-view="list"] .api-key-card { grid-template-columns: 42px minmax(0, 1fr) auto; }
|
||||
.api-key-collection[data-view="list"] .api-key-prefix,
|
||||
.api-key-collection[data-view="list"] .api-key-time { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user