|
|
|
@@ -1,11 +1,14 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
|
|
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
|
|
import type { LocationQueryRaw } from "vue-router";
|
|
|
|
|
import { usageAPI } from "@/api";
|
|
|
|
|
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
|
|
|
|
import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
|
|
|
|
import DataGrid from "@/components/common/DataGrid.vue";
|
|
|
|
|
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
|
|
|
|
|
import EmptyState from "@/components/common/EmptyState.vue";
|
|
|
|
|
import AuditTimeline from "@/components/console/AuditTimeline.vue";
|
|
|
|
|
import Pagination from "@/components/common/Pagination.vue";
|
|
|
|
|
import ConsoleIcon from "@/components/console/ConsoleIcon.vue";
|
|
|
|
|
import EntityIndex from "@/components/console/EntityIndex.vue";
|
|
|
|
|
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
|
|
|
@@ -15,7 +18,7 @@ import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
|
|
|
|
import SectionFrame from "@/components/layout/SectionFrame.vue";
|
|
|
|
|
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
|
|
|
|
import { useConsoleViewState } from "@/composables/useConsoleViewState";
|
|
|
|
|
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AdminUsageRow, AuthUser, BillingPlanDetail, UsageLedgerRow } from "@/types";
|
|
|
|
|
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AdminUsageRow, ApiKeyRecord, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, UsageLedgerRow, UsageReservationRow } from "@/types";
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
const router = useRouter();
|
|
|
|
@@ -30,60 +33,185 @@ const detailLoading = ref(false);
|
|
|
|
|
const error = ref<string | null>(null);
|
|
|
|
|
const mutationError = ref<string | null>(null);
|
|
|
|
|
const pending = ref<string | null>(null);
|
|
|
|
|
const page = ref(Math.max(1, Number(route.query.cursor) || 1));
|
|
|
|
|
const pageSize = 25;
|
|
|
|
|
const total = ref(0);
|
|
|
|
|
const stateAuthority = ref("pk01-postgres");
|
|
|
|
|
const createOpen = ref(false);
|
|
|
|
|
const auditTab = ref<"usage" | "ledger">("ledger");
|
|
|
|
|
const auditTab = ref<"usage" | "ledger">(route.query.audit === "ledger" ? "ledger" : "usage");
|
|
|
|
|
const auditLoading = ref(false);
|
|
|
|
|
const auditError = ref<string | null>(null);
|
|
|
|
|
const auditUsageRows = ref<AdminUsageRow[]>([]);
|
|
|
|
|
const auditLedgerRows = ref<UsageLedgerRow[]>([]);
|
|
|
|
|
const filters = reactive({ search: typeof route.query.q === "string" ? route.query.q : "", status: typeof route.query.filter === "string" ? route.query.filter : "", role: typeof route.query.role === "string" ? route.query.role : "" });
|
|
|
|
|
const auditPage = ref(Math.max(1, Number(route.query.auditPage) || 1));
|
|
|
|
|
const auditPageSize = 25;
|
|
|
|
|
const auditTotal = ref(0);
|
|
|
|
|
const filters = reactive({ search: textQuery("q"), status: textQuery("filter"), role: textQuery("role") });
|
|
|
|
|
const auditFilters = reactive({ search: textQuery("auditSearch"), userId: textQuery("auditUser"), serviceId: textQuery("service"), resourceType: textQuery("resource"), apiKeyId: textQuery("apiKey"), status: textQuery("auditStatus"), kind: textQuery("kind"), source: textQuery("source"), idempotencyKey: textQuery("idempotency"), from: textQuery("from"), to: textQuery("to") });
|
|
|
|
|
const createForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 });
|
|
|
|
|
const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", planId: "default" });
|
|
|
|
|
const creditForm = reactive({ deltaCredits: 0, reason: "admin_adjust", idempotencyKey: "" });
|
|
|
|
|
|
|
|
|
|
const selectedSummary = computed(() => detail.value?.summary ?? users.value.find((item) => item.user?.id === selectedUserId.value) ?? null);
|
|
|
|
|
const selectedUser = computed(() => selectedSummary.value?.user ?? null);
|
|
|
|
|
const selectedEntitlements = computed<ResourceEntitlementSummary[]>(() => selectedSummary.value?.entitlements ?? []);
|
|
|
|
|
const ledgerRows = computed<UsageLedgerRow[]>(() => detail.value?.ledger?.rows ?? []);
|
|
|
|
|
const apiKeys = computed<ApiKeyRecord[]>(() => detail.value?.apiKeys?.keys ?? []);
|
|
|
|
|
const reservations = computed<UsageReservationRow[]>(() => detail.value?.reservations?.rows ?? detail.value?.reservations?.active ?? selectedSummary.value?.reservations?.active ?? []);
|
|
|
|
|
const viewMode = computed({ get: () => viewState.view.value, set: (value: "list" | "grid") => void viewState.update({ view: value === "list" ? undefined : value }, false) });
|
|
|
|
|
const detailTab = computed({ get: () => viewState.tab.value || "profile", set: (value: string) => void viewState.update({ tab: value === "profile" ? undefined : value }, false) });
|
|
|
|
|
const asyncState = computed(() => loading.value ? (users.value.length ? "refreshing" : "initial-loading") : error.value ? (users.value.length ? "partial" : "error") : loaded.value ? "ready" : "empty");
|
|
|
|
|
const statusItems = computed(() => [{ id: "users", label: "Users", value: total.value }, { id: "active", label: "Active", value: users.value.filter((item) => item.user?.status === "active").length, tone: "ok" as const }, { id: "authority", label: "Authority", value: stateAuthority.value }, { id: "selected", label: "Selected", value: selectedUser.value?.username || "-" }]);
|
|
|
|
|
const statusItems = computed(() => [{ id: "users", label: "Users", value: total.value }, { id: "active", label: "Active on page", value: users.value.filter((item) => item.user?.status === "active").length, tone: "ok" as const }, { id: "authority", label: "Authority", value: stateAuthority.value }, { id: "selected", label: "Selected", value: selectedUser.value?.username || "-" }]);
|
|
|
|
|
const userColumns: DataGridColumn[] = [{ key: "user", label: "User" }, { key: "status", label: "Status" }, { key: "credits", label: "Credits", align: "right" }, { key: "usage", label: "Usage", align: "right" }, { key: "keys", label: "Keys", align: "right" }, { key: "actions", label: "Action", align: "right" }];
|
|
|
|
|
const usageColumns: DataGridColumn[] = [{ key: "time", label: "Time" }, { key: "user", label: "User" }, { key: "service", label: "Service" }, { key: "resource", label: "Resource" }, { key: "credits", label: "Credits", align: "right" }, { key: "status", label: "Status" }, { key: "metadata", label: "Metadata" }];
|
|
|
|
|
const ledgerColumns: DataGridColumn[] = [{ key: "time", label: "Time" }, { key: "user", label: "User" }, { key: "action", label: "Action" }, { key: "delta", label: "Delta", align: "right" }, { key: "balance", label: "Before/After", align: "right" }, { key: "operator", label: "Operator" }, { key: "idempotency", label: "Idempotency" }];
|
|
|
|
|
|
|
|
|
|
onMounted(async () => { await Promise.all([loadUsers(), loadPlans(), loadAudit()]); if (selectedUserId.value) await selectUser(selectedUserId.value, false); });
|
|
|
|
|
watch(() => route.params.userId, (value) => { const userId = typeof value === "string" ? value : ""; if (userId) void selectUser(userId, false); else { selectedUserId.value = null; detail.value = null; } });
|
|
|
|
|
async function loadUsers(): Promise<void> { loading.value = true; const response = await usageAPI.adminUsers({ page: 1, pageSize: 100, search: filters.search.trim(), status: filters.status, role: filters.role }); if (!response.ok) error.value = response.error ?? `HTTP ${response.status}`; else { users.value = response.data?.users ?? []; total.value = Number(response.data?.total ?? users.value.length); stateAuthority.value = String(response.data?.state?.stateAuthority ?? "pk01-postgres"); error.value = null; } loading.value = false; loaded.value = true; }
|
|
|
|
|
async function applyFilters(): Promise<void> { await router.push({ query: { ...route.query, q: filters.search || undefined, filter: filters.status || undefined, role: filters.role || undefined } }); await loadUsers(); }
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await Promise.all([loadPlans(), loadUsers(false), loadAudit(false)]);
|
|
|
|
|
if (selectedUserId.value) await selectUser(selectedUserId.value, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
watch(() => route.params.userId, (value) => {
|
|
|
|
|
const userId = typeof value === "string" ? value : "";
|
|
|
|
|
if (userId && userId !== selectedUserId.value) void selectUser(userId, false);
|
|
|
|
|
if (!userId) { selectedUserId.value = null; detail.value = null; }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
watch(() => route.query, () => {
|
|
|
|
|
const nextPage = Math.max(1, Number(route.query.cursor) || 1);
|
|
|
|
|
const nextAuditPage = Math.max(1, Number(route.query.auditPage) || 1);
|
|
|
|
|
const nextAuditTab = route.query.audit === "ledger" ? "ledger" : "usage";
|
|
|
|
|
const userChanged = filters.search !== textQuery("q") || filters.status !== textQuery("filter") || filters.role !== textQuery("role") || page.value !== nextPage;
|
|
|
|
|
const auditChanged = auditPage.value !== nextAuditPage || auditTab.value !== nextAuditTab || Object.entries(auditQueryMap).some(([key, queryKey]) => auditFilters[key as keyof typeof auditFilters] !== textQuery(queryKey));
|
|
|
|
|
if (userChanged) { filters.search = textQuery("q"); filters.status = textQuery("filter"); filters.role = textQuery("role"); page.value = nextPage; void loadUsers(false); }
|
|
|
|
|
if (auditChanged) { for (const [key, queryKey] of Object.entries(auditQueryMap)) auditFilters[key as keyof typeof auditFilters] = textQuery(queryKey); auditPage.value = nextAuditPage; auditTab.value = nextAuditTab; void loadAudit(false); }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const auditQueryMap = { search: "auditSearch", userId: "auditUser", serviceId: "service", resourceType: "resource", apiKeyId: "apiKey", status: "auditStatus", kind: "kind", source: "source", idempotencyKey: "idempotency", from: "from", to: "to" } as const;
|
|
|
|
|
function textQuery(key: string): string { return typeof route.query[key] === "string" ? String(route.query[key]) : ""; }
|
|
|
|
|
async function loadPlans(): Promise<void> { const response = await usageAPI.adminPlans(); if (response.ok) plans.value = response.data?.plans ?? []; }
|
|
|
|
|
async function selectUser(userId: string | undefined, updateRoute = true): Promise<void> { if (!userId) return; if (updateRoute) await router.push({ name: "UserDetail", params: { userId }, query: route.query }); selectedUserId.value = userId; detailLoading.value = true; const response = await usageAPI.adminUser(userId); if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { detail.value = response.data?.detail ?? null; fillEditForm(detail.value?.summary?.user ?? null); } detailLoading.value = false; }
|
|
|
|
|
async function loadUsers(updateRoute = true): Promise<void> {
|
|
|
|
|
if (updateRoute) await syncUserQuery();
|
|
|
|
|
loading.value = true;
|
|
|
|
|
const response = await usageAPI.adminUsers({ page: page.value, pageSize, search: filters.search.trim(), status: filters.status, role: filters.role });
|
|
|
|
|
if (!response.ok) error.value = response.error ?? `HTTP ${response.status}`;
|
|
|
|
|
else { users.value = response.data?.users ?? []; total.value = Number(response.data?.total ?? response.data?.count ?? users.value.length); stateAuthority.value = String(response.data?.state?.stateAuthority ?? "pk01-postgres"); error.value = null; }
|
|
|
|
|
loading.value = false; loaded.value = true;
|
|
|
|
|
}
|
|
|
|
|
async function applyFilters(): Promise<void> { page.value = 1; await loadUsers(); }
|
|
|
|
|
async function setPage(value: number): Promise<void> { page.value = value; await loadUsers(); }
|
|
|
|
|
async function syncUserQuery(): Promise<void> { const query = { ...route.query, q: filters.search.trim() || undefined, filter: filters.status || undefined, role: filters.role || undefined, cursor: page.value > 1 ? String(page.value) : undefined }; await router.push({ query }); }
|
|
|
|
|
async function selectUser(userId: string | undefined, updateRoute = true): Promise<void> {
|
|
|
|
|
if (!userId) return;
|
|
|
|
|
if (updateRoute && route.params.userId !== userId) await router.push({ name: "UserDetail", params: { userId }, query: route.query });
|
|
|
|
|
selectedUserId.value = userId; detailLoading.value = true; mutationError.value = null;
|
|
|
|
|
const response = await usageAPI.adminUser(userId);
|
|
|
|
|
if (!response.ok) { mutationError.value = response.error ?? `HTTP ${response.status}`; detail.value = null; }
|
|
|
|
|
else { detail.value = response.data?.detail ?? null; fillEditForm(detail.value?.summary?.user ?? users.value.find((item) => item.user?.id === userId)?.user); }
|
|
|
|
|
detailLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
function closeInspector(): void { selectedUserId.value = null; detail.value = null; void router.push({ name: "Users", query: route.query }); }
|
|
|
|
|
function fillEditForm(user: (AuthUser & { email?: string }) | null): void { editForm.email = user?.email ?? ""; editForm.username = user?.username ?? ""; editForm.displayName = user?.displayName ?? ""; editForm.password = ""; editForm.role = user?.role === "admin" ? "admin" : "user"; editForm.status = String(user?.status ?? "active"); editForm.planId = selectedSummary.value?.planId ?? "default"; }
|
|
|
|
|
async function createUser(): Promise<void> { pending.value = "create"; const response = await usageAPI.createAdminUser({ ...createForm, initialCredits: Number(createForm.initialCredits) }); pending.value = null; if (!response.ok) { mutationError.value = response.error ?? `HTTP ${response.status}`; return; } createOpen.value = false; await loadUsers(); if (response.data?.user?.id) await selectUser(response.data.user.id); }
|
|
|
|
|
async function saveUser(): Promise<void> { if (!selectedUser.value?.id) return; pending.value = "save"; const body: Record<string, unknown> = { email: editForm.email, username: editForm.username, displayName: editForm.displayName, role: editForm.role, status: editForm.status, planId: editForm.planId }; if (editForm.password) body.password = editForm.password; const response = await usageAPI.updateAdminUser(selectedUser.value.id, body); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await loadUsers(); await selectUser(selectedUser.value.id, false); } }
|
|
|
|
|
async function setUserStatus(item: AdminBillingUserSummary): Promise<void> { const userId = item.user?.id; if (!userId) return; const status: AccessUserStatus = item.user?.status === "disabled" ? "active" : "disabled"; pending.value = `status:${userId}`; const response = await usageAPI.updateAdminUserStatus(userId, status); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else await loadUsers(); }
|
|
|
|
|
async function adjustCredits(): Promise<void> { if (!selectedUser.value?.id) return; pending.value = "credits"; const response = await usageAPI.adjustAdminCredits(selectedUser.value.id, { deltaCredits: Number(creditForm.deltaCredits), reason: creditForm.reason, idempotencyKey: creditForm.idempotencyKey || undefined }); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await selectUser(selectedUser.value.id, false); await loadAudit(); } }
|
|
|
|
|
async function loadAudit(): Promise<void> { auditLoading.value = true; const response = auditTab.value === "usage" ? await usageAPI.adminUsage({ page: 1, pageSize: 100 }) : await usageAPI.adminLedger({ page: 1, pageSize: 100 }); if (response.ok) { if (auditTab.value === "usage") auditUsageRows.value = response.data?.rows ?? []; else auditLedgerRows.value = response.data?.rows ?? []; } auditLoading.value = false; }
|
|
|
|
|
function userKey(item: AdminBillingUserSummary): string { return item.user?.id ?? item.user?.username ?? "unknown"; }
|
|
|
|
|
function auditKey(item: AdminUsageRow | UsageLedgerRow): string { return item.id; }
|
|
|
|
|
function formatNumber(value: unknown): string { return new Intl.NumberFormat("zh-CN").format(Number(value ?? 0)); }
|
|
|
|
|
function fillEditForm(user: (AuthUser & { email?: string }) | null | undefined): void { editForm.email = user?.email ?? ""; editForm.username = user?.username ?? ""; editForm.displayName = user?.displayName ?? ""; editForm.password = ""; editForm.role = user?.role === "admin" ? "admin" : "user"; editForm.status = String(user?.status ?? "active"); editForm.planId = selectedSummary.value?.planId ?? "default"; }
|
|
|
|
|
async function createUser(): Promise<void> { pending.value = "create"; mutationError.value = null; const response = await usageAPI.createAdminUser({ ...createForm, initialCredits: Number(createForm.initialCredits || 0) }); pending.value = null; if (!response.ok) { mutationError.value = response.error ?? `HTTP ${response.status}`; return; } Object.assign(createForm, { email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 }); createOpen.value = false; await loadUsers(false); if (response.data?.user?.id) await selectUser(response.data.user.id); }
|
|
|
|
|
async function saveUser(): Promise<void> { const userId = selectedUser.value?.id; if (!userId) return; pending.value = `save:${userId}`; mutationError.value = null; const body: Record<string, unknown> = { email: editForm.email.trim(), username: editForm.username.trim(), displayName: editForm.displayName.trim(), role: editForm.role, status: editForm.status, planId: editForm.planId || "default" }; if (editForm.password) body.password = editForm.password; const response = await usageAPI.updateAdminUser(userId, body); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await loadUsers(false); await selectUser(userId, false); } }
|
|
|
|
|
async function setUserStatus(item: AdminBillingUserSummary): Promise<void> { const userId = item.user?.id; if (!userId) return; const status: AccessUserStatus = item.user?.status === "disabled" ? "active" : "disabled"; pending.value = `status:${userId}`; mutationError.value = null; const response = await usageAPI.updateAdminUserStatus(userId, status); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await loadUsers(false); if (selectedUserId.value === userId) await selectUser(userId, false); } }
|
|
|
|
|
async function adjustCredits(): Promise<void> { const userId = selectedUser.value?.id; if (!userId) return; pending.value = `credits:${userId}`; mutationError.value = null; const response = await usageAPI.adjustAdminCredits(userId, { deltaCredits: Number(creditForm.deltaCredits), reason: creditForm.reason.trim() || "admin_adjust", idempotencyKey: creditForm.idempotencyKey.trim() || undefined }); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { creditForm.deltaCredits = 0; creditForm.idempotencyKey = ""; await Promise.all([loadUsers(false), selectUser(userId, false), loadAudit(false)]); } }
|
|
|
|
|
function auditParams(extra: Record<string, unknown> = {}): Record<string, unknown> { const params: Record<string, unknown> = { page: auditPage.value, pageSize: auditPageSize, ...extra }; for (const [key, value] of Object.entries(auditFilters)) if (String(value).trim()) params[key] = String(value).trim(); if (auditTab.value === "usage") { delete params.kind; delete params.source; } else { delete params.serviceId; delete params.resourceType; delete params.apiKeyId; } return params; }
|
|
|
|
|
async function loadAudit(updateRoute = true): Promise<void> { if (updateRoute) await syncAuditQuery(); auditLoading.value = true; auditError.value = null; const response = auditTab.value === "usage" ? await usageAPI.adminUsage(auditParams()) : await usageAPI.adminLedger(auditParams()); auditLoading.value = false; if (!response.ok) { auditError.value = response.error ?? `HTTP ${response.status}`; return; } auditTotal.value = Number(response.data?.total ?? response.data?.count ?? 0); if (auditTab.value === "usage") auditUsageRows.value = response.data?.rows ?? []; else auditLedgerRows.value = response.data?.rows ?? []; }
|
|
|
|
|
async function syncAuditQuery(): Promise<void> { const query: LocationQueryRaw = { ...route.query, audit: auditTab.value === "ledger" ? "ledger" : undefined, auditPage: auditPage.value > 1 ? String(auditPage.value) : undefined }; for (const [key, queryKey] of Object.entries(auditQueryMap)) query[queryKey] = auditFilters[key as keyof typeof auditFilters].trim() || undefined; await router.push({ query }); }
|
|
|
|
|
async function setAuditTab(value: "usage" | "ledger"): Promise<void> { auditTab.value = value; auditPage.value = 1; await loadAudit(); }
|
|
|
|
|
async function setAuditPage(value: number): Promise<void> { auditPage.value = value; await loadAudit(); }
|
|
|
|
|
function downloadAudit(): void { window.location.assign(auditTab.value === "usage" ? usageAPI.adminUsageExportUrl(auditParams({ limit: 1000 })) : usageAPI.adminLedgerExportUrl(auditParams({ limit: 1000 }))); }
|
|
|
|
|
function userKey(item: AdminBillingUserSummary): string { return item.user?.id ?? item.user?.username ?? "unknown-user"; }
|
|
|
|
|
function auditKey(item: AdminUsageRow | UsageLedgerRow): string { return item.id ?? `${item.userId ?? "user"}:${item.createdAt ?? "time"}`; }
|
|
|
|
|
function formatNumber(value: unknown): string { const number = Number(value ?? 0); return Number.isFinite(number) ? new Intl.NumberFormat("zh-CN").format(number) : "0"; }
|
|
|
|
|
function formatSigned(value: unknown): string { const number = Number(value ?? 0); return `${number > 0 ? "+" : ""}${formatNumber(number)}`; }
|
|
|
|
|
function formatDate(value: unknown): string { const text = String(value ?? ""); if (!text) return "-"; const date = new Date(text); return Number.isNaN(date.getTime()) ? text : date.toLocaleString("zh-CN"); }
|
|
|
|
|
function metadataPreview(value: unknown): string { if (!value || typeof value !== "object" || Array.isArray(value)) return "-"; return Object.entries(value as Record<string, unknown>).slice(0, 4).map(([key, item]) => `${key}=${typeof item === "object" ? "[structured]" : String(item)}`).join(" · ") || "-"; }
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<section class="route-stack user-operations-page">
|
|
|
|
|
<PageCommandBar eyebrow="Admin" title="身份运营工作区" description="用户集合为主面;资料、plan、API key、reservation 与 ledger 渐进进入 Inspector。"><template #actions><ViewModeSwitch v-model="viewMode" :options="[{ value: 'list', label: '紧凑列表', icon: 'mdtodo' }, { value: 'grid', label: '实体网格', icon: 'users' }]" /><button class="table-action" type="button" @click="createOpen = true">创建用户</button></template></PageCommandBar>
|
|
|
|
|
<AsyncBoundary :state="asyncState" :has-content="Boolean(users.length)" label="加载身份运营" @retry="loadUsers"><template #error><EmptyState title="身份运营加载失败" :description="error || '请求未完成'" action-label="重试" @action="loadUsers" /></template>
|
|
|
|
|
<PageCommandBar eyebrow="Admin" title="身份运营工作区" description="用户索引、身份主面与账务 Inspector 共享同一服务端分页数据集。">
|
|
|
|
|
<template #actions><ViewModeSwitch v-model="viewMode" :options="[{ value: 'list', label: '紧凑列表', icon: 'list' }, { value: 'grid', label: '实体网格', icon: 'grid' }]" /><button class="table-action" type="button" @click="createOpen = true">创建用户</button></template>
|
|
|
|
|
</PageCommandBar>
|
|
|
|
|
<AsyncBoundary :state="asyncState" :has-content="Boolean(users.length)" label="加载身份运营" @retry="loadUsers">
|
|
|
|
|
<template #error><EmptyState title="身份运营加载失败" :description="error || '请求未完成'" action-label="重试" @action="loadUsers" /></template>
|
|
|
|
|
<StatusStrip :items="statusItems" />
|
|
|
|
|
<OperationsWorkspace class="user-operations" label="用户身份运营" :inspector-open="Boolean(selectedUser)">
|
|
|
|
|
<template #index><EntityIndex :items="users" :item-key="userKey" :selected-id="selectedUserId" label="用户索引" @select="(item) => selectUser(item.user?.id)"><template #toolbar><input v-model="filters.search" class="input" placeholder="用户名、邮箱或 user id" @keyup.enter="applyFilters"><div class="user-filter-row"><select v-model="filters.status" class="input" @change="applyFilters"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select><select v-model="filters.role" class="input" @change="applyFilters"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select></div></template><template #item="{ item }"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId || 'default' }}</small><span class="entity-index-meta"><span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ formatNumber(item.credits?.available) }} cr</code></span></template></EntityIndex></template>
|
|
|
|
|
<div class="user-main-stack"><SectionFrame :title="viewMode === 'grid' ? '实体网格' : '身份清单'" description="两种视图消费同一 AdminBillingUserSummary 集合与稳定 user id。" :body-padding="false"><div v-if="viewMode === 'grid'" class="user-entity-grid"><button v-for="item in users" :key="userKey(item)" type="button" @click="selectUser(item.user?.id)"><ConsoleIcon name="users" :size="20" /><strong>{{ item.user?.displayName || item.user?.username }}</strong><small>{{ item.user?.email || item.user?.id }}</small><span class="status-pill" :data-status="item.user?.status">{{ item.user?.status }}</span><dl><div><dt>Available</dt><dd>{{ formatNumber(item.credits?.available) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(item.usage?.totalCredits) }}</dd></div><div><dt>Keys</dt><dd>{{ formatNumber(item.apiKeys?.activeCount) }}</dd></div></dl></button></div><div v-else class="user-compact-list"><button v-for="item in users" :key="userKey(item)" type="button" @click="selectUser(item.user?.id)"><strong>{{ item.user?.displayName || item.user?.username }}</strong><span>{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ item.planId || 'default' }}</code><code>{{ formatNumber(item.credits?.available) }} available</code><code>{{ formatNumber(item.usage?.totalCredits) }} usage</code><button class="table-action" type="button" :disabled="pending === `status:${item.user?.id}`" @click.stop="setUserStatus(item)">{{ item.user?.status === 'disabled' ? '启用' : '禁用' }}</button></button></div></SectionFrame>
|
|
|
|
|
<SectionFrame title="全局审计" description="不再与用户集合纵向堆成超长页;按需切换 usage / ledger。"><template #actions><button class="table-action" type="button" :aria-pressed="auditTab === 'usage'" @click="auditTab = 'usage'; loadAudit()">Usage</button><button class="table-action" type="button" :aria-pressed="auditTab === 'ledger'" @click="auditTab = 'ledger'; loadAudit()">Ledger</button></template><AuditTimeline v-if="auditTab === 'usage'" :items="auditUsageRows" :item-key="auditKey"><template #item="{ item }"><strong>{{ item.serviceId }} · {{ item.userId }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.status }} · {{ item.id }}</small></template></AuditTimeline><AuditTimeline v-else :items="auditLedgerRows" :item-key="auditKey"><template #item="{ item }"><strong>{{ item.kind }} · {{ item.userId }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.status }} · {{ item.id }}</small></template></AuditTimeline><p v-if="auditLoading" class="muted-box">审计加载中…</p></SectionFrame></div>
|
|
|
|
|
<template #inspector><InspectorPanel v-if="selectedUser" :title="selectedUser.displayName || selectedUser.username || selectedUser.id || '未命名用户'" :subtitle="selectedUser.id" @close="closeInspector"><template #tabs><button type="button" :aria-selected="detailTab === 'profile'" @click="detailTab = 'profile'">资料</button><button type="button" :aria-selected="detailTab === 'credits'" @click="detailTab = 'credits'">Credits</button><button type="button" :aria-selected="detailTab === 'ledger'" @click="detailTab = 'ledger'">Ledger</button></template><p v-if="mutationError" class="form-error">{{ mutationError }}</p><div v-if="detailTab === 'profile'" class="form-grid"><label>邮箱<input v-model="editForm.email" class="input"></label><label>用户名<input v-model="editForm.username" class="input"></label><label>显示名<input v-model="editForm.displayName" class="input"></label><label>角色<select v-model="editForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="editForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label><label>Plan<select v-model="editForm.planId" class="input"><option v-for="item in plans" :key="item.plan?.id" :value="item.plan?.id">{{ item.plan?.displayName || item.plan?.id }}</option></select></label><button class="table-action" type="button" :disabled="pending === 'save'" @click="saveUser">保存资料</button></div><div v-else-if="detailTab === 'credits'" class="form-grid"><p class="muted-box">Available {{ formatNumber(selectedSummary?.credits?.available) }} · Reserved {{ formatNumber(selectedSummary?.credits?.reserved) }}</p><label>Delta<input v-model.number="creditForm.deltaCredits" class="input" type="number"></label><label>Reason<input v-model="creditForm.reason" class="input"></label><label>Idempotency key<input v-model="creditForm.idempotencyKey" class="input"></label><button class="table-action" type="button" :disabled="pending === 'credits'" @click="adjustCredits">调整 Credits</button></div><AuditTimeline v-else :items="ledgerRows" :item-key="auditKey"><template #item="{ item }"><strong>{{ item.kind }} · {{ item.deltaCredits }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.reason }} · {{ item.id }}</small></template></AuditTimeline></InspectorPanel></template>
|
|
|
|
|
<p v-if="mutationError" class="form-error">{{ mutationError }}</p>
|
|
|
|
|
<OperationsWorkspace label="用户身份运营" :inspector-open="Boolean(selectedUserId)">
|
|
|
|
|
<template #index>
|
|
|
|
|
<EntityIndex :items="users" :item-key="userKey" :selected-id="selectedUserId" label="用户索引" @select="(item) => selectUser(item.user?.id)">
|
|
|
|
|
<template #toolbar><div class="user-index-filters"><input v-model="filters.search" class="input" aria-label="搜索用户" placeholder="用户名、邮箱或 user id" @keyup.enter="applyFilters"><select v-model="filters.status" class="input" aria-label="用户状态" @change="applyFilters"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select><select v-model="filters.role" class="input" aria-label="用户角色" @change="applyFilters"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select></div></template>
|
|
|
|
|
<template #item="{ item }"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId || 'default' }}</small><span class="entity-index-meta"><span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ formatNumber(item.credits?.available) }} cr</code></span></template>
|
|
|
|
|
<template #footer><Pagination :page="page" :page-size="pageSize" :total="total" @update:page="setPage" /></template>
|
|
|
|
|
</EntityIndex>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<div class="user-main-stack">
|
|
|
|
|
<SectionFrame :title="viewMode === 'grid' ? '用户实体网格' : '用户紧凑列表'" description="选择动作与状态写操作使用同级控件,避免交互元素嵌套。" :body-padding="false">
|
|
|
|
|
<div v-if="viewMode === 'grid'" class="user-entity-grid">
|
|
|
|
|
<article v-for="item in users" :key="userKey(item)" class="user-entity-card" :data-selected="userKey(item) === selectedUserId">
|
|
|
|
|
<button class="user-entity-select" type="button" @click="selectUser(item.user?.id)"><ConsoleIcon name="users" :size="20" /><strong>{{ item.user?.displayName || item.user?.username }}</strong><small>{{ item.user?.email || item.user?.id }}</small><dl><div><dt>Available</dt><dd>{{ formatNumber(item.credits?.available) }}</dd></div><div><dt>Usage</dt><dd>{{ formatNumber(item.usage?.totalCredits) }}</dd></div><div><dt>Keys</dt><dd>{{ formatNumber(item.apiKeys?.activeCount) }}</dd></div></dl></button>
|
|
|
|
|
<button class="table-action" type="button" :disabled="pending === `status:${item.user?.id}`" @click="setUserStatus(item)">{{ item.user?.status === 'disabled' ? '启用' : '禁用' }}</button>
|
|
|
|
|
</article>
|
|
|
|
|
</div>
|
|
|
|
|
<DataGrid v-else :columns="userColumns" :rows="users" :row-key="userKey" :selected-id="selectedUserId" aria-label="用户列表" @select="(item) => selectUser(item.user?.id)">
|
|
|
|
|
<template #cell-user="{ row }"><strong>{{ row.user?.displayName || row.user?.username }}</strong><small>{{ row.user?.email || row.user?.id }} · {{ row.planId || 'default' }}</small></template>
|
|
|
|
|
<template #cell-status="{ row }"><span class="status-pill" :data-status="row.user?.status">{{ row.user?.role }} / {{ row.user?.status }}</span></template>
|
|
|
|
|
<template #cell-credits="{ row }">{{ formatNumber(row.credits?.available) }} / {{ formatNumber(row.credits?.reserved) }}</template>
|
|
|
|
|
<template #cell-usage="{ row }">{{ formatNumber(row.usage?.totalCredits) }}</template>
|
|
|
|
|
<template #cell-keys="{ row }">{{ formatNumber(row.apiKeys?.activeCount) }} / {{ formatNumber(row.apiKeys?.totalCount) }}</template>
|
|
|
|
|
<template #cell-actions="{ row }"><button class="table-action" type="button" :disabled="pending === `status:${row.user?.id}`" @click.stop="setUserStatus(row)">{{ row.user?.status === 'disabled' ? '启用' : '禁用' }}</button></template>
|
|
|
|
|
</DataGrid>
|
|
|
|
|
</SectionFrame>
|
|
|
|
|
|
|
|
|
|
<SectionFrame title="全局 Usage / Ledger" description="保留旧页全部筛选、服务端分页、错误/partial 状态与 CSV export。" :body-padding="false">
|
|
|
|
|
<div class="audit-command-row"><div role="tablist" aria-label="运营审计数据"><button type="button" role="tab" :aria-selected="auditTab === 'usage'" @click="setAuditTab('usage')">Usage</button><button type="button" role="tab" :aria-selected="auditTab === 'ledger'" @click="setAuditTab('ledger')">Ledger</button></div><button class="table-action" type="button" @click="downloadAudit">CSV / Export</button></div>
|
|
|
|
|
<div class="audit-filter-grid"><input v-model="auditFilters.search" class="input" placeholder="搜索记录或原因" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.userId" class="input" placeholder="user id" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'usage'" v-model="auditFilters.serviceId" class="input" placeholder="service" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'usage'" v-model="auditFilters.resourceType" class="input" placeholder="resource" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'usage'" v-model="auditFilters.apiKeyId" class="input" placeholder="api key id" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'ledger'" v-model="auditFilters.kind" class="input" placeholder="kind" @keyup.enter="() => loadAudit()"><input v-if="auditTab === 'ledger'" v-model="auditFilters.source" class="input" placeholder="source" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.status" class="input" placeholder="status" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.idempotencyKey" class="input" placeholder="idempotency key" @keyup.enter="() => loadAudit()"><input v-model="auditFilters.from" class="input" type="datetime-local" @change="() => loadAudit()"><input v-model="auditFilters.to" class="input" type="datetime-local" @change="() => loadAudit()"><button class="table-action" type="button" @click="auditPage = 1; loadAudit()">应用筛选</button></div>
|
|
|
|
|
<p v-if="auditError" class="form-error">{{ auditError }}</p><p v-if="auditLoading" class="muted-box">审计加载中…</p>
|
|
|
|
|
<DataGrid v-else-if="auditTab === 'usage'" :columns="usageColumns" :rows="auditUsageRows" :row-key="auditKey" :interactive="false" aria-label="全局 Usage">
|
|
|
|
|
<template #cell-time="{ row }">{{ formatDate(row.createdAt) }}</template><template #cell-user="{ row }"><code>{{ row.userId }}</code><small>{{ row.username || row.email }}</small></template><template #cell-service="{ row }"><code>{{ row.serviceId }}</code></template><template #cell-resource="{ row }">{{ row.resourceType || row.id }}</template><template #cell-credits="{ row }">{{ formatNumber(row.credits) }}</template><template #cell-status="{ row }">{{ row.status }}</template><template #cell-metadata="{ row }">{{ metadataPreview(row.metadata) }}</template>
|
|
|
|
|
</DataGrid>
|
|
|
|
|
<DataGrid v-else :columns="ledgerColumns" :rows="auditLedgerRows" :row-key="auditKey" :interactive="false" aria-label="全局 Ledger">
|
|
|
|
|
<template #cell-time="{ row }">{{ formatDate(row.createdAt) }}</template><template #cell-user="{ row }"><code>{{ row.userId }}</code><small>{{ row.username || row.email }}</small></template><template #cell-action="{ row }">{{ row.kind }} · {{ row.reason }}</template><template #cell-delta="{ row }">{{ formatSigned(row.deltaCredits) }}</template><template #cell-balance="{ row }">{{ formatNumber(row.balanceBefore) }} → {{ formatNumber(row.balanceAfter) }}</template><template #cell-operator="{ row }">{{ row.operatorUsername || row.operatorUserId || row.source }}</template><template #cell-idempotency="{ row }"><code>{{ row.idempotencyKey || '-' }}</code></template>
|
|
|
|
|
</DataGrid>
|
|
|
|
|
<Pagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @update:page="setAuditPage" />
|
|
|
|
|
</SectionFrame>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template #inspector>
|
|
|
|
|
<InspectorPanel v-if="selectedUserId" :title="selectedUser?.displayName || selectedUser?.username || selectedUserId" :subtitle="selectedUserId" @close="closeInspector">
|
|
|
|
|
<template #tabs><button v-for="tab in ['profile', 'credits', 'entitlements', 'keys', 'reservations', 'ledger']" :key="tab" type="button" role="tab" :aria-selected="detailTab === tab" @click="detailTab = tab">{{ tab }}</button></template>
|
|
|
|
|
<p v-if="detailLoading" class="muted-box">详情加载中…</p><p v-else-if="mutationError" class="form-error">{{ mutationError }}</p>
|
|
|
|
|
<div v-if="!detailLoading && detailTab === 'profile'" class="form-grid"><label>邮箱<input v-model="editForm.email" class="input"></label><label>用户名<input v-model="editForm.username" class="input"></label><label>显示名<input v-model="editForm.displayName" class="input"></label><label>新密码<input v-model="editForm.password" class="input" type="password"></label><label>角色<select v-model="editForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="editForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label><label>Plan<select v-model="editForm.planId" class="input"><option v-for="item in plans" :key="item.plan?.id" :value="item.plan?.id">{{ item.plan?.displayName || item.plan?.id }}</option></select></label><button class="table-action" type="button" :disabled="pending === `save:${selectedUserId}`" @click="saveUser">保存资料</button></div>
|
|
|
|
|
<div v-else-if="detailTab === 'credits'" class="form-grid"><p class="muted-box">Available {{ formatNumber(selectedSummary?.credits?.available) }} · Reserved {{ formatNumber(selectedSummary?.credits?.reserved) }}</p><label>Delta<input v-model.number="creditForm.deltaCredits" class="input" type="number"></label><label>Reason<input v-model="creditForm.reason" class="input"></label><label>Idempotency key<input v-model="creditForm.idempotencyKey" class="input"></label><button class="table-action" type="button" :disabled="pending === `credits:${selectedUserId}`" @click="adjustCredits">调整 Credits</button></div>
|
|
|
|
|
<div v-else-if="detailTab === 'entitlements'" class="inspector-fact-list"><article v-for="item in selectedEntitlements" :key="item.id || item.resourceType"><strong>{{ item.resourceType }}</strong><small>{{ item.enabled === false ? 'disabled' : 'enabled' }} · monthly {{ formatNumber(item.monthlyUsageCredits) }} / {{ item.monthlyQuotaUnlimited ? 'unlimited' : formatNumber(item.monthlyQuotaCredits) }}</small><small>concurrent {{ formatNumber(item.activeReservations) }} / {{ item.concurrencyUnlimited ? 'unlimited' : formatNumber(item.maxConcurrentReservations) }} · RPM {{ formatNumber(item.rpmUsed) }} / {{ item.rpmUnlimited ? 'unlimited' : formatNumber(item.rpmLimit) }}</small></article><p v-if="!selectedEntitlements.length" class="muted-box">暂无 entitlement。</p></div>
|
|
|
|
|
<div v-else-if="detailTab === 'keys'" class="inspector-fact-list"><article v-for="item in apiKeys" :key="item.id"><strong>{{ item.name || item.id }}</strong><small>{{ item.prefix || '-' }} · {{ item.revokedAt ? 'revoked' : 'active' }}</small><small>created {{ formatDate(item.createdAt) }} · last used {{ formatDate(item.lastUsedAt) }}</small></article><p v-if="!apiKeys.length" class="muted-box">暂无 API key。</p></div>
|
|
|
|
|
<div v-else-if="detailTab === 'reservations'" class="inspector-fact-list"><article v-for="item in reservations" :key="item.reservationId"><strong>{{ item.reservationId }}</strong><small>{{ item.serviceId }} · {{ item.resourceType || 'generic' }} · {{ item.status }}</small><small>{{ formatNumber(item.estimatedCredits) }} credits · expires {{ formatDate(item.expiresAt) }}</small></article><p v-if="!reservations.length" class="muted-box">没有 active reservation。</p></div>
|
|
|
|
|
<div v-else class="inspector-fact-list"><article v-for="item in ledgerRows" :key="auditKey(item)"><strong>{{ item.kind }} · {{ formatSigned(item.deltaCredits) }}</strong><small>{{ formatDate(item.createdAt) }} · {{ item.reason }} · {{ item.status }}</small><small>{{ formatNumber(item.balanceBefore) }} → {{ formatNumber(item.balanceAfter) }} · {{ item.idempotencyKey || '-' }}</small></article><p v-if="!ledgerRows.length" class="muted-box">暂无用户 ledger。</p></div>
|
|
|
|
|
</InspectorPanel>
|
|
|
|
|
</template>
|
|
|
|
|
</OperationsWorkspace>
|
|
|
|
|
<BaseDrawer :open="createOpen" title="创建用户" description="写入 user-billing authority" :busy="pending === 'create'" @close="createOpen = false"><div class="form-grid two-col"><label>邮箱<input v-model="createForm.email" class="input" type="email"></label><label>用户名<input v-model="createForm.username" class="input"></label><label>显示名<input v-model="createForm.displayName" class="input"></label><label>初始 credits<input v-model.number="createForm.initialCredits" class="input" type="number"></label><label>角色<select v-model="createForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="createForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label><label>初始密码<input v-model="createForm.password" class="input" type="password"></label></div><template #footer><button class="table-action" type="button" @click="createOpen = false">取消</button><button class="table-action" type="button" :disabled="pending === 'create'" @click="createUser">创建</button></template></BaseDrawer>
|
|
|
|
|
<BaseDrawer :open="createOpen" title="创建用户" description="写入 user-billing authority" :busy="pending === 'create'" @close="createOpen = false"><div class="form-grid two-col"><label>邮箱<input v-model="createForm.email" class="input" type="email"></label><label>用户名<input v-model="createForm.username" class="input"></label><label>显示名<input v-model="createForm.displayName" class="input"></label><label>密码<input v-model="createForm.password" class="input" type="password"></label><label>初始 credits<input v-model.number="createForm.initialCredits" class="input" type="number"></label><label>角色<select v-model="createForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label><label>状态<select v-model="createForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label></div><template #footer><button class="table-action" type="button" @click="createOpen = false">取消</button><button class="btn btn-primary" type="button" :disabled="pending === 'create'" @click="createUser">创建</button></template></BaseDrawer>
|
|
|
|
|
</AsyncBoundary>
|
|
|
|
|
</section>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.user-operations { min-height: 650px; }.user-filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }.entity-index-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 4px; }.user-main-stack { display: grid; gap: 14px; }.user-entity-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap: 1px; background: #d8e1e4; }.user-entity-grid > button { display: grid; grid-template-columns: auto 1fr auto; gap: 7px; align-items: center; border: 0; padding: 12px; background: white; color: inherit; text-align: left; }.user-entity-grid small, .user-entity-grid dl { grid-column: 1/-1; }.user-entity-grid dl { display: grid; grid-template-columns: repeat(3,1fr); margin: 0; }.user-entity-grid dt { color: #64757d; font-size: 9px; }.user-entity-grid dd { margin: 3px 0 0; }.user-compact-list { overflow: auto; }.user-compact-list > button { display: grid; grid-template-columns: minmax(160px,1.3fr) 110px 100px 120px 110px 64px; gap: 8px; width: 100%; min-width: 760px; align-items: center; border: 0; border-bottom: 1px solid #d8e1e4; padding: 9px 11px; background: white; color: inherit; text-align: left; }.user-compact-list > button:hover { background: #e8f6f5; }
|
|
|
|
|
.user-operations-page,.user-main-stack { min-width: 0; }
|
|
|
|
|
.user-index-filters,.user-main-stack,.form-grid,.inspector-fact-list { display: grid; gap: 8px; }
|
|
|
|
|
.user-entity-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(230px,1fr)); gap: 8px; padding: 10px; }
|
|
|
|
|
.user-entity-card { display: grid; gap: 7px; min-width: 0; border: 1px solid #d2dce1; border-radius: 8px; padding: 8px; background: #fff; }
|
|
|
|
|
.user-entity-card[data-selected="true"] { border-color: #258d89; box-shadow: inset 3px 0 #168d88; }
|
|
|
|
|
.user-entity-select { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 5px 8px; min-width: 0; border: 0; background: transparent; padding: 2px; color: inherit; text-align: left; cursor: pointer; }
|
|
|
|
|
.user-entity-select small,.user-entity-select dl { grid-column: 1 / -1; }
|
|
|
|
|
.user-entity-select dl { display: grid; grid-template-columns: repeat(3,1fr); gap: 5px; margin: 4px 0 0; }
|
|
|
|
|
.user-entity-select dl div { min-width: 0; border: 1px solid #e0e7ea; border-radius: 5px; padding: 5px; }
|
|
|
|
|
.user-entity-select dt { color: #718087; font-size: 9px; }.user-entity-select dd { margin: 2px 0 0; font-weight: 800; }
|
|
|
|
|
.audit-command-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px; }.audit-command-row [role="tablist"] { display: flex; gap: 4px; }.audit-command-row [role="tab"] { min-height: 30px; border: 1px solid #bac8ce; background: #fff; padding: 0 10px; }.audit-command-row [aria-selected="true"] { background: #17383f; color: #fff; }
|
|
|
|
|
.audit-filter-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(130px,1fr)); gap: 7px; padding: 0 10px 10px; }
|
|
|
|
|
.inspector-fact-list article { display: grid; gap: 3px; border: 1px solid #d9e2e6; border-radius: 6px; padding: 8px; background: #f8fafb; }.inspector-fact-list small { overflow-wrap: anywhere; color: #61747c; }
|
|
|
|
|
@media (max-width: 720px) { .user-entity-grid { grid-template-columns: 1fr; }.audit-filter-grid { grid-template-columns: 1fr 1fr; } }
|
|
|
|
|
@media (max-width: 390px) { .audit-filter-grid { grid-template-columns: minmax(0,1fr); }.audit-command-row { align-items: stretch; flex-direction: column; }.user-entity-select dl { grid-template-columns: 1fr; } }
|
|
|
|
|
</style>
|
|
|
|
|