refactor(web): 收口管理域运营台组件
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<script setup lang="ts">
|
||||
import { List, Workflow } from "lucide-vue-next";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import FactPath, { type FactPathStep } from "@/components/console/FactPath.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
@@ -55,21 +56,11 @@ function displayDate(value: string): string {
|
||||
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>
|
||||
<ResourceCollection class="secret-compact-list" :items="rows" :item-key="rowKey" view="list" density="compact" :selected-id="rowKey(selected)" :show-controls="false" empty-text="暂无 ExternalSecret。" aria-label="ExternalSecret 库存清单">
|
||||
<template #list="{ item: row }"><button 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></template>
|
||||
</ResourceCollection>
|
||||
</SectionFrame>
|
||||
<SectionFrame
|
||||
title="ExternalSecret 事实"
|
||||
@@ -126,7 +117,7 @@ function displayDate(value: string): string {
|
||||
type="button"
|
||||
@click="emit('inspect', selected)"
|
||||
>
|
||||
打开详情 Inspector
|
||||
打开脱敏详情
|
||||
</button></SectionFrame
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<script setup lang="ts">
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import AuditTimeline from "@/components/console/AuditTimeline.vue";
|
||||
import type { AdminBillingUserSummary, ApiKeyRecord, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, UsageLedgerRow, UsageReservationRow } from "@/types";
|
||||
|
||||
type DetailTab = "profile" | "credits" | "entitlements" | "keys" | "reservations" | "ledger";
|
||||
@@ -13,7 +14,7 @@ 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());
|
||||
const auditKey = (item: UsageLedgerRow): string => item.id;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,6 +26,6 @@ const auditKey = (item: UsageLedgerRow): string => String(item.id || item.idempo
|
||||
<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>
|
||||
<AuditTimeline v-else :items="ledgerRows" :item-key="auditKey" label="用户 ledger 时间线" empty-text="暂无用户 ledger。"><template #item="{ 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></template></AuditTimeline>
|
||||
</InspectorPanel>
|
||||
</template>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Users } from "lucide-vue-next";
|
||||
import DataGrid, { type DataGridColumn } from "@/components/common/DataGrid.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.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 }>();
|
||||
defineProps<{ users: AdminBillingUserSummary[]; selectedId: string | null; view: "list" | "grid"; density: "compact" | "comfortable"; 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 || "");
|
||||
@@ -21,37 +22,36 @@ const formatNumber = (value: unknown): string => Number(value ?? 0).toLocaleStri
|
||||
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 || '')"
|
||||
>
|
||||
<Users :size="20" aria-hidden="true" />
|
||||
<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>
|
||||
<ResourceCollection
|
||||
v-if="view === 'grid'"
|
||||
class="user-entity-grid"
|
||||
:items="users"
|
||||
:item-key="userKey"
|
||||
view="cards"
|
||||
:density="density"
|
||||
:selected-id="selectedId"
|
||||
:show-controls="false"
|
||||
empty-text="暂无用户。"
|
||||
aria-label="用户实体集合"
|
||||
>
|
||||
<template #card="{ item }">
|
||||
<article class="user-entity-card" :data-selected="userKey(item) === selectedId">
|
||||
<button class="user-entity-select" type="button" @click="emit('select', item.user?.id || '')">
|
||||
<Users :size="20" aria-hidden="true" />
|
||||
<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>
|
||||
</template>
|
||||
</ResourceCollection>
|
||||
<DataGrid
|
||||
v-else
|
||||
:columns="columns"
|
||||
|
||||
@@ -101,6 +101,14 @@ const viewMode = computed({
|
||||
false,
|
||||
),
|
||||
});
|
||||
const density = computed({
|
||||
get: () => viewState.density.value,
|
||||
set: (value: "compact" | "comfortable") =>
|
||||
void viewState.update(
|
||||
{ density: value === "comfortable" ? undefined : value },
|
||||
false,
|
||||
),
|
||||
});
|
||||
const detailTab = computed({
|
||||
get: () => viewState.tab.value || "profile",
|
||||
set: (value: string) =>
|
||||
@@ -373,7 +381,7 @@ async function adjustCredits(): Promise<void> {
|
||||
error, mutationError, pending, page, pageSize, total, stateAuthority,
|
||||
createOpen, filters, createForm, editForm, creditForm, selectedSummary,
|
||||
selectedUser, selectedEntitlements, ledgerRows, apiKeys, reservations,
|
||||
viewMode, detailTab, asyncState, statusItems, loadPlans, loadUsers,
|
||||
viewMode, density, detailTab, asyncState, statusItems, loadPlans, loadUsers,
|
||||
applyFilters, setPage, selectUser, closeInspector, createUser, saveUser,
|
||||
setUserStatus, adjustCredits, userColumns, userKey, formatNumber,
|
||||
...audit, usageColumns, ledgerColumns, auditKey, formatSigned, formatDate,
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
.billing-summary-grid {
|
||||
min-width: 0;
|
||||
}
|
||||
.billing-summary-grid > .resource-collection-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1px;
|
||||
@@ -98,7 +101,7 @@
|
||||
.billing-operations {
|
||||
min-height: 0;
|
||||
}
|
||||
.billing-summary-grid {
|
||||
.billing-summary-grid > .resource-collection-items {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.billing-summary-grid dl {
|
||||
|
||||
@@ -6,6 +6,7 @@ import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import BillingCodeIndex from "@/components/billing/BillingCodeIndex.vue";
|
||||
import BillingCodeInspector from "@/components/billing/BillingCodeInspector.vue";
|
||||
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
|
||||
@@ -93,38 +94,12 @@ const {
|
||||
title="兑换运营摘要"
|
||||
description="状态、额度、使用率与到期时间可直接扫描。"
|
||||
:body-padding="false"
|
||||
><div class="billing-summary-grid">
|
||||
<button
|
||||
v-for="code in codes"
|
||||
:key="codeKey(code)"
|
||||
type="button"
|
||||
@click="selectCode(code)"
|
||||
>
|
||||
<span class="status-pill" :data-status="code.status">{{
|
||||
code.status
|
||||
}}</span
|
||||
><strong>{{ code.codePrefix }}</strong
|
||||
><small>{{ code.displayName || code.reason }}</small>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Credits</dt>
|
||||
<dd>{{ formatNumber(code.creditAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Usage</dt>
|
||||
<dd>
|
||||
{{ formatNumber(code.redeemedCount) }}/{{
|
||||
formatNumber(code.maxRedemptions)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires</dt>
|
||||
<dd>{{ formatDate(code.expiresAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</button>
|
||||
</div></SectionFrame
|
||||
><ResourceCollection class="billing-summary-grid" :items="codes" :item-key="codeKey" view="cards" density="compact" :selected-id="selectedCodeId" :show-controls="false" empty-text="暂无兑换码。" aria-label="兑换码运营摘要" @select="selectCode">
|
||||
<template #card="{ item: code }"><button type="button" @click="selectCode(code)">
|
||||
<span class="status-pill" :data-status="code.status">{{ code.status }}</span><strong>{{ code.codePrefix }}</strong><small>{{ code.displayName || code.reason }}</small>
|
||||
<dl><div><dt>Credits</dt><dd>{{ formatNumber(code.creditAmount) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(code.redeemedCount) }}/{{ formatNumber(code.maxRedemptions) }}</dd></div><div><dt>Expires</dt><dd>{{ formatDate(code.expiresAt) }}</dd></div></dl>
|
||||
</button></template>
|
||||
</ResourceCollection></SectionFrame
|
||||
><SectionFrame
|
||||
v-else
|
||||
title="兑换码紧凑清单"
|
||||
|
||||
@@ -13,11 +13,22 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.user-entity-grid {
|
||||
min-width: 0;
|
||||
}
|
||||
.user-entity-grid > .resource-collection-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
.user-entity-grid > .resource-collection-items[data-density="compact"] {
|
||||
gap: 5px;
|
||||
padding: 6px;
|
||||
}
|
||||
.user-entity-grid > .resource-collection-items[data-density="compact"] .user-entity-card {
|
||||
gap: 5px;
|
||||
padding: 6px;
|
||||
}
|
||||
.user-entity-card {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
@@ -107,7 +118,7 @@
|
||||
color: #61747c;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.user-entity-grid {
|
||||
.user-entity-grid > .resource-collection-items {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.audit-filter-grid {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台 / Users. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<script setup lang="ts">
|
||||
import { Grid2X2, List } from "lucide-vue-next";
|
||||
import { Grid2X2, List, Rows3, Rows4 } from "lucide-vue-next";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import Pagination from "@/components/common/Pagination.vue";
|
||||
import OperationsWorkspace from "@/components/console/OperationsWorkspace.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
@@ -20,7 +21,7 @@ const {
|
||||
users, plans, selectedUserId, loading, detailLoading, error, mutationError,
|
||||
pending, page, pageSize, total, createOpen, filters, createForm, editForm,
|
||||
creditForm, selectedSummary, selectedUser, selectedEntitlements, ledgerRows,
|
||||
apiKeys, reservations, viewMode, detailTab, asyncState, statusItems,
|
||||
apiKeys, reservations, viewMode, density, detailTab, asyncState, statusItems,
|
||||
loadUsers, applyFilters, setPage, selectUser, closeInspector, createUser, saveUser,
|
||||
setUserStatus, adjustCredits, userColumns, userKey, formatNumber,
|
||||
auditTab, auditLoading, auditError, auditUsageRows, auditLedgerRows,
|
||||
@@ -44,6 +45,13 @@ const {
|
||||
{ value: 'list', label: '紧凑列表', icon: List },
|
||||
{ value: 'grid', label: '实体网格', icon: Grid2X2 },
|
||||
]"
|
||||
/><ViewModeSwitch
|
||||
v-model="density"
|
||||
:options="[
|
||||
{ value: 'comfortable', label: '舒适密度', icon: Rows4 },
|
||||
{ value: 'compact', label: '紧凑密度', icon: Rows3 },
|
||||
]"
|
||||
label="用户集合密度"
|
||||
/><button class="table-action" type="button" @click="createOpen = true">
|
||||
创建用户
|
||||
</button></template
|
||||
@@ -72,7 +80,7 @@ const {
|
||||
<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">
|
||||
<UserDirectoryCollection :users="users" :selected-id="selectedUserId" :view="viewMode" :pending-id="pending?.startsWith('status:') ? pending.slice(7) : null" @select="selectUser" @status="setUserStatus" />
|
||||
<UserDirectoryCollection :users="users" :selected-id="selectedUserId" :view="viewMode" :density="density" :pending-id="pending?.startsWith('status:') ? pending.slice(7) : null" @select="selectUser" @status="setUserStatus" />
|
||||
|
||||
<SectionFrame
|
||||
title="全局 Usage / Ledger"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<script setup lang="ts">
|
||||
import { Grid2X2, List, Rows3, Rows4, X } from "lucide-vue-next";
|
||||
import { Grid2X2, List, Rows3, Rows4 } from "lucide-vue-next";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { RouterLink, useRoute, useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiKeysAPI } from "@/api";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
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 InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
|
||||
import ApiKeyResourceCard from "@/components/api-keys/ApiKeyResourceCard.vue";
|
||||
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
||||
@@ -136,6 +137,12 @@ function selectKey(key: ApiKeyRecord): void {
|
||||
void router.push({ name: "ApiKeyDetail", params: { keyId: key.id }, query: route.query });
|
||||
}
|
||||
|
||||
function closeInspector(): void {
|
||||
editingId.value = null;
|
||||
editingName.value = "";
|
||||
void router.push({ name: "ApiKeys", query: route.query });
|
||||
}
|
||||
|
||||
function startRename(key: ApiKeyRecord): void {
|
||||
editingId.value = key.id;
|
||||
editingName.value = key.name || "";
|
||||
@@ -204,9 +211,8 @@ function formatDate(value: unknown): string {
|
||||
</section>
|
||||
|
||||
<template #inspector>
|
||||
<section v-if="selectedKey" class="api-key-inspector">
|
||||
<header><div><span>KEY DETAIL</span><h2>{{ selectedKey.name || selectedKey.id }}</h2></div><RouterLink :to="{ name: 'ApiKeys', query: route.query }" title="关闭详情" aria-label="关闭详情"><X :size="16" aria-hidden="true" /></RouterLink></header>
|
||||
<dl>
|
||||
<InspectorPanel v-if="selectedKey" :title="selectedKey.name || selectedKey.id" :subtitle="selectedKey.id" @close="closeInspector">
|
||||
<dl class="api-key-inspector-facts">
|
||||
<div><dt>ID</dt><dd><code>{{ selectedKey.id }}</code></dd></div>
|
||||
<div><dt>Prefix</dt><dd><code>{{ selectedKey.prefix || '-' }}</code></dd></div>
|
||||
<div><dt>状态</dt><dd>{{ selectedKey.revokedAt ? 'revoked' : 'active' }}</dd></div>
|
||||
@@ -220,7 +226,7 @@ function formatDate(value: unknown): string {
|
||||
<button class="danger" type="button" :disabled="pending === `revoke:${selectedKey.id}`" @click="revokeTarget = selectedKey">撤销 Key</button>
|
||||
</section>
|
||||
<p class="api-key-fact-note">当前 API 未声明 scope 或 rotation 合同,因此详情不会展示或模拟这些能力。</p>
|
||||
</section>
|
||||
</InspectorPanel>
|
||||
</template>
|
||||
</BoundedWorkspace>
|
||||
|
||||
@@ -251,27 +257,22 @@ function formatDate(value: unknown): string {
|
||||
.api-key-summary-strip { display: grid; grid-template-columns: 120px 120px 120px minmax(0, 1fr); border: 1px solid #cbd5dc; border-radius: 8px; overflow: hidden; background: #fff; }
|
||||
.api-key-summary-strip div { display: grid; gap: 3px; padding: 10px 13px; border-right: 1px solid #dbe3e7; }
|
||||
.api-key-summary-strip span,
|
||||
.api-key-collection-panel > header span,
|
||||
.api-key-inspector > header span { color: #16807d; font: 850 9px/1 ui-monospace, monospace; letter-spacing: 0; }
|
||||
.api-key-collection-panel > header span { color: #16807d; font: 850 9px/1 ui-monospace, monospace; letter-spacing: 0; }
|
||||
.api-key-summary-strip strong { color: #19303a; font-size: 20px; }
|
||||
.api-key-summary-strip p { align-self: center; margin: 0; padding: 0 14px; color: #64757e; font-size: 11px; }
|
||||
.api-key-workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; min-height: 0; }
|
||||
.api-key-workspace[data-inspector-open="true"] { grid-template-columns: minmax(0, 1fr) minmax(290px, 360px); }
|
||||
.api-key-collection-panel { display: grid; align-content: start; gap: 11px; padding: 13px; }
|
||||
.api-key-collection-panel > header,
|
||||
.api-key-inspector > header { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e0e7ea; padding-bottom: 10px; }
|
||||
.api-key-collection-panel h2,
|
||||
.api-key-inspector h2 { margin: 5px 0 0; color: #172b34; font-size: 17px; }
|
||||
.api-key-collection-panel > header { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e0e7ea; padding-bottom: 10px; }
|
||||
.api-key-collection-panel h2 { margin: 5px 0 0; color: #172b34; font-size: 17px; }
|
||||
.api-key-collection-panel > header small { color: #697a83; font-size: 10px; }
|
||||
.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-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; }
|
||||
.api-key-inspector dl div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; border-bottom: 1px solid #e2e8eb; padding: 9px 0; }
|
||||
.api-key-inspector dt { color: #74828a; font-size: 10px; }
|
||||
.api-key-inspector dd { min-width: 0; margin: 0; color: #263b44; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.api-key-inspector-facts { display: grid; gap: 0; margin: 0; }
|
||||
.api-key-inspector-facts div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; border-bottom: 1px solid #e2e8eb; padding: 9px 0; }
|
||||
.api-key-inspector-facts dt { color: #74828a; font-size: 10px; }
|
||||
.api-key-inspector-facts dd { min-width: 0; margin: 0; color: #263b44; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.api-key-inspector-actions { display: grid; gap: 7px; }
|
||||
.api-key-inspector-actions button,
|
||||
.api-key-rename button { min-height: 34px; border: 1px solid #aebdc5; border-radius: 6px; background: #fff; color: #30434c; font-weight: 750; cursor: pointer; }
|
||||
@@ -285,7 +286,6 @@ function formatDate(value: unknown): string {
|
||||
.secret-once-box small { color: #7c672f; }
|
||||
@media (max-width: 1120px) {
|
||||
.api-key-workspace[data-inspector-open="true"] { grid-template-columns: 1fr; }
|
||||
.api-key-inspector { position: static; max-height: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.api-key-command-bar { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user