refactor(web): replace users page component tree
This commit is contained in:
@@ -1,639 +1,89 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { 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 Pagination from "@/components/common/Pagination.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import AuditTimeline from "@/components/console/AuditTimeline.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 type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AdminUsageRow, ApiKeyRecord, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, UsageLedgerRow, UsageReservationRow } from "@/types";
|
||||
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";
|
||||
|
||||
const users = ref<AdminBillingUserSummary[]>([]);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const viewState = useConsoleViewState<"list" | "grid">({ view: "list", viewValues: ["list", "grid"], tab: "profile" });
|
||||
const users = ref<AdminBillingUserSummary[]>([]);
|
||||
const plans = ref<BillingPlanDetail[]>([]);
|
||||
const detail = ref<AdminBillingUserDetail | null>(null);
|
||||
const selectedUserId = ref<string | null>(null);
|
||||
const selectedUserId = ref<string | null>(typeof route.params.userId === "string" ? route.params.userId : null);
|
||||
const loading = ref(true);
|
||||
const loaded = ref(false);
|
||||
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 = ref(25);
|
||||
const total = ref(0);
|
||||
const stateAuthority = ref("pk01-postgres");
|
||||
const auditTab = ref<"usage" | "ledger">("usage");
|
||||
const createOpen = ref(false);
|
||||
const auditTab = ref<"usage" | "ledger">("ledger");
|
||||
const auditLoading = ref(false);
|
||||
const auditError = ref<string | null>(null);
|
||||
const auditUsageRows = ref<AdminUsageRow[]>([]);
|
||||
const auditLedgerRows = ref<UsageLedgerRow[]>([]);
|
||||
const auditTotal = ref(0);
|
||||
const auditPage = ref(1);
|
||||
const auditPageSize = 25;
|
||||
const createOpen = ref(false);
|
||||
|
||||
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 auditFilters = reactive({ search: "", userId: "", serviceId: "", resourceType: "", apiKeyId: "", status: "", kind: "", source: "", idempotencyKey: "", from: "", 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 totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
||||
const viewMode = computed<"cards" | "list">({
|
||||
get: () => route.query.view === "list" ? "list" : "cards",
|
||||
set: (value) => void replaceViewQuery(value)
|
||||
});
|
||||
const asyncState = computed(() => {
|
||||
if (loading.value) return users.value.length ? "refreshing" : "initial-loading";
|
||||
if (error.value) return users.value.length ? "partial" : "error";
|
||||
return loaded.value ? "ready" : "empty";
|
||||
});
|
||||
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 auditUsageColumns: 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 auditLedgerColumns: 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" }
|
||||
];
|
||||
const entitlementColumns: DataGridColumn[] = [
|
||||
{ key: "resource", label: "Resource" }, { key: "status", label: "Status" }, { key: "monthly", label: "Monthly" },
|
||||
{ key: "concurrent", label: "Concurrent" }, { key: "rpm", label: "RPM" }
|
||||
];
|
||||
const apiKeyColumns: DataGridColumn[] = [{ key: "key", label: "Key" }, { key: "prefix", label: "Prefix" }, { key: "status", label: "Status" }];
|
||||
const detailLedgerColumns: DataGridColumn[] = [{ key: "time", label: "Time" }, { key: "delta", label: "Delta", align: "right" }, { key: "balance", label: "Balance", align: "right" }];
|
||||
const reservationColumns: DataGridColumn[] = [
|
||||
{ key: "reservation", label: "Reservation" }, { key: "service", label: "Service" }, { key: "credits", label: "Credits", align: "right" },
|
||||
{ key: "status", label: "Status" }, { key: "expires", label: "Expires" }
|
||||
];
|
||||
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 || "-" }]);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPlans();
|
||||
await refresh();
|
||||
await refreshAudit();
|
||||
});
|
||||
|
||||
watch(() => [route.query.q, route.query.filter, route.query.role, route.query.cursor].map((value) => typeof value === "string" ? value : "").join("\u0000"), () => {
|
||||
const nextSearch = typeof route.query.q === "string" ? route.query.q : "";
|
||||
const nextStatus = typeof route.query.filter === "string" ? route.query.filter : "";
|
||||
const nextRole = typeof route.query.role === "string" ? route.query.role : "";
|
||||
const nextPage = Math.max(1, Number(route.query.cursor) || 1);
|
||||
if (filters.search === nextSearch && filters.status === nextStatus && filters.role === nextRole && page.value === nextPage) return;
|
||||
filters.search = nextSearch;
|
||||
filters.status = nextStatus;
|
||||
filters.role = nextRole;
|
||||
page.value = nextPage;
|
||||
void loadUsers(false);
|
||||
});
|
||||
|
||||
watch(() => route.params.userId, (userId) => {
|
||||
const value = typeof userId === "string" ? userId : "";
|
||||
if (value && value !== selectedUserId.value) void selectUser(value, false);
|
||||
if (!value) {
|
||||
selectedUserId.value = null;
|
||||
detail.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPlans(): Promise<void> {
|
||||
const response = await usageAPI.adminPlans();
|
||||
if (response.ok) plans.value = response.data?.plans ?? [];
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await loadUsers(true);
|
||||
}
|
||||
|
||||
async function loadUsers(updateRoute: boolean): Promise<void> {
|
||||
if (updateRoute) await syncViewState();
|
||||
loading.value = true;
|
||||
const response = await usageAPI.adminUsers({ page: page.value, pageSize: pageSize.value, search: filters.search.trim(), status: filters.status, role: filters.role });
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
} else {
|
||||
const payload = response.data ?? {};
|
||||
users.value = payload.users ?? [];
|
||||
total.value = Number(payload.total ?? payload.count ?? users.value.length);
|
||||
stateAuthority.value = String(payload.state?.stateAuthority ?? "pk01-postgres");
|
||||
error.value = null;
|
||||
const routeUserId = typeof route.params.userId === "string" ? route.params.userId : "";
|
||||
if (routeUserId) await selectUser(routeUserId, false);
|
||||
}
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
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 ?? null);
|
||||
}
|
||||
detailLoading.value = false;
|
||||
}
|
||||
|
||||
function closeDetailDrawer(): void {
|
||||
selectedUserId.value = null;
|
||||
detail.value = null;
|
||||
void router.push({ name: "Users", query: route.query });
|
||||
}
|
||||
|
||||
function userKey(item: AdminBillingUserSummary): string {
|
||||
return item.user?.id ?? item.user?.username ?? "unknown-user";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
createForm.email = "";
|
||||
createForm.username = "";
|
||||
createForm.displayName = "";
|
||||
createForm.password = "";
|
||||
createForm.initialCredits = 0;
|
||||
createOpen.value = false;
|
||||
await refresh();
|
||||
const userId = response.data?.user?.id;
|
||||
if (userId) await selectUser(userId);
|
||||
}
|
||||
|
||||
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}`;
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
await selectUser(userId);
|
||||
}
|
||||
|
||||
async function setUserStatus(userId: string | undefined, status: AccessUserStatus): Promise<void> {
|
||||
if (!userId) return;
|
||||
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}`;
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
await selectUser(userId);
|
||||
}
|
||||
|
||||
async function adjustCredits(): Promise<void> {
|
||||
const userId = selectedUser.value?.id;
|
||||
if (!userId) return;
|
||||
pending.value = `credits:${userId}`;
|
||||
mutationError.value = null;
|
||||
const body: Record<string, unknown> = { deltaCredits: Number(creditForm.deltaCredits), reason: creditForm.reason.trim() || "admin_adjust" };
|
||||
if (creditForm.idempotencyKey.trim()) body.idempotencyKey = creditForm.idempotencyKey.trim();
|
||||
const response = await usageAPI.adjustAdminCredits(userId, body);
|
||||
pending.value = null;
|
||||
if (!response.ok) {
|
||||
mutationError.value = response.error ?? `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
creditForm.deltaCredits = 0;
|
||||
creditForm.idempotencyKey = "";
|
||||
await refresh();
|
||||
await selectUser(userId);
|
||||
await refreshAudit();
|
||||
}
|
||||
|
||||
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)) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (text) params[key] = text;
|
||||
}
|
||||
if (auditTab.value === "usage") delete params.kind;
|
||||
if (auditTab.value === "usage") delete params.source;
|
||||
if (auditTab.value === "ledger") delete params.serviceId;
|
||||
if (auditTab.value === "ledger") delete params.resourceType;
|
||||
if (auditTab.value === "ledger") delete params.apiKeyId;
|
||||
return params;
|
||||
}
|
||||
|
||||
async function refreshAudit(): Promise<void> {
|
||||
auditPage.value = 1;
|
||||
await loadAudit();
|
||||
}
|
||||
|
||||
async function loadAudit(): Promise<void> {
|
||||
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 ?? []) as AdminUsageRow[];
|
||||
else auditLedgerRows.value = (response.data?.rows ?? []) as UsageLedgerRow[];
|
||||
}
|
||||
|
||||
function setAuditTab(tab: "usage" | "ledger"): void {
|
||||
auditTab.value = tab;
|
||||
auditPage.value = 1;
|
||||
void loadAudit();
|
||||
}
|
||||
|
||||
function setAuditPage(value: number): void {
|
||||
auditPage.value = value;
|
||||
void loadAudit();
|
||||
}
|
||||
|
||||
function auditUsageKey(row: AdminUsageRow): string { return row.id; }
|
||||
function auditLedgerKey(row: UsageLedgerRow): string { return row.id; }
|
||||
function entitlementKey(row: ResourceEntitlementSummary): string { return row.id || row.resourceType || "entitlement"; }
|
||||
function apiKeyKey(row: ApiKeyRecord): string { return row.id; }
|
||||
function ledgerKey(row: UsageLedgerRow): string { return row.id; }
|
||||
function reservationKey(row: UsageReservationRow): string { return row.reservationId; }
|
||||
|
||||
function downloadAudit(): void {
|
||||
const url = auditTab.value === "usage" ? usageAPI.adminUsageExportUrl(auditParams({ limit: 1000 })) : usageAPI.adminLedgerExportUrl(auditParams({ limit: 1000 }));
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
async function nextPage(delta: number): Promise<void> {
|
||||
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function syncViewState(): Promise<void> {
|
||||
const query = { ...route.query };
|
||||
if (filters.search.trim()) query.q = filters.search.trim();
|
||||
else delete query.q;
|
||||
if (filters.status) query.filter = filters.status;
|
||||
else delete query.filter;
|
||||
if (filters.role) query.role = filters.role;
|
||||
else delete query.role;
|
||||
if (page.value > 1) query.cursor = String(page.value);
|
||||
else delete query.cursor;
|
||||
await router.replace({ query });
|
||||
}
|
||||
|
||||
async function replaceViewQuery(value: "cards" | "list"): Promise<void> {
|
||||
const query = { ...route.query };
|
||||
if (value === "cards") delete query.view;
|
||||
else query.view = value;
|
||||
await router.replace({ query });
|
||||
}
|
||||
|
||||
function nextStatus(status: unknown): AccessUserStatus {
|
||||
return String(status ?? "") === "disabled" ? "active" : "disabled";
|
||||
}
|
||||
|
||||
function statusActionLabel(status: unknown): string {
|
||||
return String(status ?? "") === "disabled" ? "启用" : "禁用";
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown): string {
|
||||
const number = Number(value ?? 0);
|
||||
if (!Number.isFinite(number)) return "0";
|
||||
return new Intl.NumberFormat("zh-CN").format(number);
|
||||
}
|
||||
|
||||
function formatSignedCredits(value: unknown): string {
|
||||
const number = Number(value ?? 0);
|
||||
const sign = number > 0 ? "+" : "";
|
||||
return `${sign}${formatNumber(number)}`;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "-";
|
||||
const date = new Date(text);
|
||||
if (Number.isNaN(date.getTime())) return text;
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function entitlementLimit(current: unknown, limit: unknown, unlimited: unknown): string {
|
||||
return `${formatNumber(current)} / ${unlimited ? "unlimited" : formatNumber(limit)}`;
|
||||
}
|
||||
|
||||
function metadataPreview(value: unknown): string {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "-";
|
||||
const entries = Object.entries(value as Record<string, unknown>).slice(0, 4);
|
||||
if (!entries.length) return "-";
|
||||
return entries.map(([key, item]) => `${key}=${typeof item === "string" || typeof item === "number" || typeof item === "boolean" ? String(item) : "[structured]"}`).join(" · ");
|
||||
}
|
||||
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(); }
|
||||
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; }
|
||||
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 formatDate(value: unknown): string { const text = String(value ?? ""); if (!text) return "-"; const date = new Date(text); return Number.isNaN(date.getTime()) ? text : date.toLocaleString("zh-CN"); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack admin-users-page">
|
||||
<PageCommandBar eyebrow="Admin" title="用户与账务" description="Sub2API-style 用户管理工作面;用户、余额、API key、ledger 和 reservation 均来自 PK01 user-billing authority。" />
|
||||
<AsyncBoundary :state="asyncState" :has-content="Boolean(users.length)" label="加载用户" empty-title="暂无用户" empty-description="当前筛选未返回用户。" @retry="refresh">
|
||||
<template #error><EmptyState title="管理员用户接口不可用" :description="error || '请求未完成'" action-label="重试" @action="refresh" /></template>
|
||||
<p v-if="mutationError" class="form-error admin-action-error">{{ mutationError }}</p>
|
||||
|
||||
<section class="data-panel admin-users-toolbar">
|
||||
<header class="panel-header">
|
||||
<h2>用户查询</h2>
|
||||
<div class="admin-users-heading-actions"><small>{{ stateAuthority }} / {{ formatNumber(total) }} users</small><span class="admin-users-view-toggle"><button type="button" :data-active="viewMode === 'cards'" @click="viewMode = 'cards'">卡片</button><button type="button" :data-active="viewMode === 'list'" @click="viewMode = 'list'">列表</button></span><button class="table-action" type="button" @click="createOpen = true">+ 创建用户</button></div>
|
||||
</header>
|
||||
<div class="admin-users-filters">
|
||||
<input v-model="filters.search" class="input" placeholder="搜索用户名、邮箱或 user id" @keyup.enter="refresh" />
|
||||
<select v-model="filters.status" class="input" @change="refresh"><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="refresh"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select>
|
||||
<button class="btn btn-secondary" type="button" @click="refresh">刷新</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-panel admin-audit-panel">
|
||||
<header class="panel-header">
|
||||
<h2>运营审计</h2>
|
||||
<small>{{ auditLoading ? "loading" : `${formatNumber(auditTotal)} rows` }}</small>
|
||||
</header>
|
||||
<div class="admin-users-filters audit-filters">
|
||||
<button class="table-action" type="button" :data-selected="auditTab === 'usage'" @click="setAuditTab('usage')">Usage</button>
|
||||
<button class="table-action" type="button" :data-selected="auditTab === 'ledger'" @click="setAuditTab('ledger')">Ledger</button>
|
||||
<input v-model="auditFilters.search" class="input" placeholder="搜索用户、记录或原因" @keyup.enter="refreshAudit" />
|
||||
<input v-model="auditFilters.userId" class="input" placeholder="user id" @keyup.enter="refreshAudit" />
|
||||
<input v-if="auditTab === 'usage'" v-model="auditFilters.serviceId" class="input" placeholder="service" @keyup.enter="refreshAudit" />
|
||||
<input v-if="auditTab === 'usage'" v-model="auditFilters.resourceType" class="input" placeholder="resource" @keyup.enter="refreshAudit" />
|
||||
<input v-if="auditTab === 'usage'" v-model="auditFilters.apiKeyId" class="input" placeholder="api key id" @keyup.enter="refreshAudit" />
|
||||
<input v-if="auditTab === 'ledger'" v-model="auditFilters.kind" class="input" placeholder="kind" @keyup.enter="refreshAudit" />
|
||||
<input v-if="auditTab === 'ledger'" v-model="auditFilters.source" class="input" placeholder="source" @keyup.enter="refreshAudit" />
|
||||
<input v-model="auditFilters.status" class="input" placeholder="status" @keyup.enter="refreshAudit" />
|
||||
<input v-model="auditFilters.from" class="input" type="datetime-local" @change="refreshAudit" />
|
||||
<input v-model="auditFilters.to" class="input" type="datetime-local" @change="refreshAudit" />
|
||||
<button class="btn btn-secondary" type="button" @click="refreshAudit">过滤</button>
|
||||
<button class="btn btn-secondary" type="button" @click="downloadAudit">CSV</button>
|
||||
</div>
|
||||
<p v-if="auditError" class="form-error admin-action-error">{{ auditError }}</p>
|
||||
<DataGrid v-if="auditTab === 'usage' && auditUsageRows.length" :columns="auditUsageColumns" :rows="auditUsageRows" :row-key="auditUsageKey" :interactive="false" density="compact" aria-label="Usage 运营审计">
|
||||
<template #cell-time="{ row }">{{ formatDate((row as AdminUsageRow).createdAt) }}<small><code>{{ (row as AdminUsageRow).id }}</code></small></template>
|
||||
<template #cell-user="{ row }">{{ (row as AdminUsageRow).username || (row as AdminUsageRow).email || (row as AdminUsageRow).userId }}<small>{{ (row as AdminUsageRow).userId }}</small></template>
|
||||
<template #cell-service="{ row }"><code>{{ (row as AdminUsageRow).serviceId }}</code><small>{{ (row as AdminUsageRow).apiKeyId || "session/internal" }}</small></template>
|
||||
<template #cell-resource="{ row }"><code>{{ (row as AdminUsageRow).resourceType }}</code><small>{{ formatNumber((row as AdminUsageRow).quantity) }} {{ (row as AdminUsageRow).unit || "unit" }}</small></template>
|
||||
<template #cell-credits="{ row }">{{ formatNumber((row as AdminUsageRow).credits) }}</template>
|
||||
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as AdminUsageRow).status">{{ (row as AdminUsageRow).status }}</span></template>
|
||||
<template #cell-metadata="{ row }"><small>{{ metadataPreview((row as AdminUsageRow).metadata) }}</small></template>
|
||||
</DataGrid>
|
||||
<DataGrid v-else-if="auditTab === 'ledger' && auditLedgerRows.length" :columns="auditLedgerColumns" :rows="auditLedgerRows" :row-key="auditLedgerKey" :interactive="false" density="compact" aria-label="Ledger 运营审计">
|
||||
<template #cell-time="{ row }">{{ formatDate((row as UsageLedgerRow).createdAt) }}<small><code>{{ (row as UsageLedgerRow).id }}</code></small></template>
|
||||
<template #cell-user="{ row }">{{ (row as UsageLedgerRow).username || (row as UsageLedgerRow).email || (row as UsageLedgerRow).userId }}<small>{{ (row as UsageLedgerRow).userId }}</small></template>
|
||||
<template #cell-action="{ row }">{{ (row as UsageLedgerRow).kind }}<small>{{ (row as UsageLedgerRow).source }} / {{ (row as UsageLedgerRow).status }} / {{ (row as UsageLedgerRow).reason }}</small></template>
|
||||
<template #cell-delta="{ row }">{{ formatSignedCredits((row as UsageLedgerRow).deltaCredits) }}</template>
|
||||
<template #cell-balance="{ row }">{{ formatNumber((row as UsageLedgerRow).balanceBefore) }} → {{ formatNumber((row as UsageLedgerRow).balanceAfter) }}</template>
|
||||
<template #cell-operator="{ row }">{{ (row as UsageLedgerRow).operatorUsername || (row as UsageLedgerRow).operatorUserId || "-" }}</template>
|
||||
<template #cell-idempotency="{ row }"><code>{{ (row as UsageLedgerRow).idempotencyKey || "-" }}</code></template>
|
||||
</DataGrid>
|
||||
<p v-else class="muted-box">当前筛选下没有 {{ auditTab }} 记录。</p>
|
||||
<Pagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @update:page="setAuditPage" />
|
||||
</section>
|
||||
|
||||
<section class="data-panel admin-users-list-panel">
|
||||
<header class="panel-header">
|
||||
<h2>用户列表</h2>
|
||||
<small>page {{ page }} / {{ totalPages }}</small>
|
||||
</header>
|
||||
<DataGrid v-if="viewMode === 'list' && users.length" :columns="userColumns" :rows="users" :row-key="userKey" :selected-id="selectedUserId" empty-text="没有匹配用户。" @select="(item) => selectUser(item.user?.id)">
|
||||
<template #cell-user="{ row }"><strong>{{ (row as AdminBillingUserSummary).user?.displayName || (row as AdminBillingUserSummary).user?.username || (row as AdminBillingUserSummary).user?.id }}</strong><small>{{ (row as AdminBillingUserSummary).user?.email || (row as AdminBillingUserSummary).user?.username }} · {{ (row as AdminBillingUserSummary).planId ?? "default" }}</small></template>
|
||||
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as AdminBillingUserSummary).user?.status">{{ (row as AdminBillingUserSummary).user?.role }} / {{ (row as AdminBillingUserSummary).user?.status }}</span></template>
|
||||
<template #cell-credits="{ row }"><strong>{{ formatNumber((row as AdminBillingUserSummary).credits?.available) }}</strong><small>{{ formatNumber((row as AdminBillingUserSummary).credits?.balance) }} balance · {{ formatNumber((row as AdminBillingUserSummary).credits?.reserved) }} reserved</small></template>
|
||||
<template #cell-usage="{ row }"><strong>{{ formatNumber((row as AdminBillingUserSummary).usage?.totalCredits) }}</strong><small>{{ formatNumber((row as AdminBillingUserSummary).usage?.recordCount) }} records</small></template>
|
||||
<template #cell-keys="{ row }"><strong>{{ formatNumber((row as AdminBillingUserSummary).apiKeys?.activeCount) }}</strong><small>{{ formatNumber((row as AdminBillingUserSummary).apiKeys?.totalCount) }} total</small></template>
|
||||
<template #cell-actions="{ row: item }"><button class="table-action" type="button" :disabled="pending === `status:${(item as AdminBillingUserSummary).user?.id}`" @click.stop="setUserStatus((item as AdminBillingUserSummary).user?.id, nextStatus((item as AdminBillingUserSummary).user?.status))">{{ statusActionLabel((item as AdminBillingUserSummary).user?.status) }}</button></template>
|
||||
</DataGrid>
|
||||
<ResourceCollection v-else-if="users.length" :items="users" :item-key="userKey" view="cards" :selected-id="selectedUserId" :show-controls="false" aria-label="用户卡片" @select="(item) => selectUser(item.user?.id)">
|
||||
<template #card="{ item }"><article class="admin-user-card" :data-selected="selectedUserId === item.user?.id">
|
||||
<span class="admin-user-glyph" aria-hidden="true"><ConsoleIcon name="users" :size="19" /></span>
|
||||
<div class="admin-user-card-name"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId ?? 'default' }}</small></div>
|
||||
<span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span>
|
||||
<dl><div><dt>Available</dt><dd>{{ formatNumber(item.credits?.available) }}<small>{{ formatNumber(item.credits?.balance) }} balance · {{ formatNumber(item.credits?.reserved) }} reserved</small></dd></div><div><dt>Usage</dt><dd>{{ formatNumber(item.usage?.totalCredits) }}<small>{{ formatNumber(item.usage?.recordCount) }} records</small></dd></div><div><dt>Keys</dt><dd>{{ formatNumber(item.apiKeys?.activeCount) }}<small>{{ formatNumber(item.apiKeys?.totalCount) }} total</small></dd></div></dl>
|
||||
<footer class="admin-user-card-actions"><button class="table-action" type="button" @click.stop="selectUser(item.user?.id)">查看详情 →</button><button class="table-action" type="button" :disabled="pending === `status:${item.user?.id}`" @click.stop="setUserStatus(item.user?.id, nextStatus(item.user?.status))">{{ statusActionLabel(item.user?.status) }}</button></footer>
|
||||
</article></template>
|
||||
</ResourceCollection>
|
||||
<p v-else class="muted-box">没有匹配用户。</p>
|
||||
<footer class="pagination-row">
|
||||
<button class="table-action" type="button" :disabled="page <= 1" @click="nextPage(-1)">上一页</button>
|
||||
<button class="table-action" type="button" :disabled="page >= totalPages" @click="nextPage(1)">下一页</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<BaseDrawer :open="createOpen" title="创建用户" description="写入 user-billing authority;创建动作不会改变既有 ACL 合同。" :busy="pending === 'create'" @close="createOpen = false">
|
||||
<section class="admin-user-create-panel">
|
||||
<header class="panel-header"><h2>创建用户</h2><small>写入 user-billing</small></header>
|
||||
<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>初始 credit<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 class="span-2">初始密码<input v-model="createForm.password" class="input" type="password" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" :disabled="pending === 'create'" @click="createUser">{{ pending === "create" ? "创建中" : "创建用户" }}</button>
|
||||
</section>
|
||||
<template #footer><button class="table-action" type="button" :disabled="pending === 'create'" @click="createOpen = false">取消</button><button class="table-action" type="button" :disabled="pending === 'create'" @click="createUser">{{ pending === 'create' ? '创建中' : '创建用户' }}</button></template>
|
||||
</BaseDrawer>
|
||||
|
||||
<BaseDrawer :open="Boolean(selectedUser)" :title="selectedUser?.displayName || selectedUser?.username || '用户详情'" :description="selectedUser?.id || ''" :busy="detailLoading" @close="closeDetailDrawer">
|
||||
<section class="data-panel admin-user-detail-panel">
|
||||
<header class="panel-header">
|
||||
<h2>{{ selectedUser?.displayName || selectedUser?.username || "用户详情" }}</h2>
|
||||
<span class="status-pill" :data-status="selectedUser?.status || (detailLoading ? 'pending' : 'disabled')">{{ detailLoading ? "loading" : selectedUser?.role || "未选择" }}</span>
|
||||
</header>
|
||||
<p v-if="!selectedUser" class="muted-box">选择一个用户查看详情。</p>
|
||||
<template v-else>
|
||||
<div class="overview-grid usage-metrics">
|
||||
<article class="metric-card"><span>Available</span><strong>{{ formatNumber(selectedSummary?.credits?.available) }}</strong></article>
|
||||
<article class="metric-card"><span>Plan</span><strong>{{ selectedSummary?.plan?.displayName || selectedSummary?.planId || "default" }}</strong></article>
|
||||
<article class="metric-card"><span>Usage credits</span><strong>{{ formatNumber(selectedSummary?.usage?.totalCredits) }}</strong></article>
|
||||
<article class="metric-card"><span>Active keys</span><strong>{{ formatNumber(selectedSummary?.apiKeys?.activeCount) }}</strong></article>
|
||||
</div>
|
||||
|
||||
<div class="admin-detail-grid">
|
||||
<section class="detail-subpanel">
|
||||
<h3>资料与权限</h3>
|
||||
<div class="form-grid two-col">
|
||||
<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" autocomplete="new-password" placeholder="留空则不变" /></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 class="span-2">计费 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 }} / {{ item.plan?.status }}</option></select></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" :disabled="pending === `save:${selectedUser?.id}`" @click="saveUser">{{ pending === `save:${selectedUser?.id}` ? "保存中" : "保存资料" }}</button>
|
||||
</section>
|
||||
|
||||
<section class="detail-subpanel">
|
||||
<h3>Credit 调整</h3>
|
||||
<div class="form-grid">
|
||||
<label>Delta credits<input v-model.number="creditForm.deltaCredits" class="input" type="number" /></label>
|
||||
<label>原因<input v-model="creditForm.reason" class="input" /></label>
|
||||
<label>Idempotency key<input v-model="creditForm.idempotencyKey" class="input" placeholder="可选" /></label>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" :disabled="pending === `credits:${selectedUser?.id}`" @click="adjustCredits">{{ pending === `credits:${selectedUser?.id}` ? "提交中" : "调整余额" }}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="detail-subpanel">
|
||||
<h3>资源授权</h3>
|
||||
<DataGrid v-if="selectedEntitlements.length" :columns="entitlementColumns" :rows="selectedEntitlements" :row-key="entitlementKey" :interactive="false" density="compact" aria-label="用户资源授权">
|
||||
<template #cell-resource="{ row }"><code>{{ (row as ResourceEntitlementSummary).resourceType }}</code></template>
|
||||
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as ResourceEntitlementSummary).enabled ? 'active' : 'disabled'">{{ (row as ResourceEntitlementSummary).enabled ? "enabled" : "disabled" }}</span></template>
|
||||
<template #cell-monthly="{ row }">{{ entitlementLimit((row as ResourceEntitlementSummary).monthlyUsageCredits, (row as ResourceEntitlementSummary).monthlyQuotaCredits, (row as ResourceEntitlementSummary).monthlyQuotaUnlimited) }}</template>
|
||||
<template #cell-concurrent="{ row }">{{ entitlementLimit((row as ResourceEntitlementSummary).activeReservations, (row as ResourceEntitlementSummary).maxConcurrentReservations, (row as ResourceEntitlementSummary).concurrencyUnlimited) }}</template>
|
||||
<template #cell-rpm="{ row }">{{ entitlementLimit((row as ResourceEntitlementSummary).rpmUsed, (row as ResourceEntitlementSummary).rpmLimit, (row as ResourceEntitlementSummary).rpmUnlimited) }}</template>
|
||||
</DataGrid>
|
||||
<p v-else class="muted-box">当前用户 plan 没有资源授权记录。</p>
|
||||
</section>
|
||||
|
||||
<div class="usage-panels">
|
||||
<section class="detail-subpanel">
|
||||
<h3>API keys</h3>
|
||||
<DataGrid v-if="apiKeys.length" :columns="apiKeyColumns" :rows="apiKeys" :row-key="apiKeyKey" :interactive="false" density="compact" aria-label="用户 API keys">
|
||||
<template #cell-key="{ row }">{{ (row as ApiKeyRecord).name || (row as ApiKeyRecord).id }}<small>{{ (row as ApiKeyRecord).id }}</small></template>
|
||||
<template #cell-prefix="{ row }"><code>{{ (row as ApiKeyRecord).prefix }}</code></template>
|
||||
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as ApiKeyRecord).revokedAt ? 'disabled' : 'active'">{{ (row as ApiKeyRecord).revokedAt ? "revoked" : "active" }}</span></template>
|
||||
</DataGrid>
|
||||
<p v-else class="muted-box">暂无 API key。</p>
|
||||
</section>
|
||||
<section class="detail-subpanel">
|
||||
<h3>Ledger</h3>
|
||||
<DataGrid v-if="ledgerRows.length" :columns="detailLedgerColumns" :rows="ledgerRows" :row-key="ledgerKey" :interactive="false" density="compact" aria-label="用户 Ledger">
|
||||
<template #cell-time="{ row }">{{ formatDate((row as UsageLedgerRow).createdAt) }}<small>{{ (row as UsageLedgerRow).kind }} / {{ (row as UsageLedgerRow).reason }} / {{ (row as UsageLedgerRow).source }}</small></template>
|
||||
<template #cell-delta="{ row }">{{ formatSignedCredits((row as UsageLedgerRow).deltaCredits) }}</template>
|
||||
<template #cell-balance="{ row }">{{ formatNumber((row as UsageLedgerRow).balanceBefore) }} → {{ formatNumber((row as UsageLedgerRow).balanceAfter) }}<small>{{ (row as UsageLedgerRow).operatorUsername || (row as UsageLedgerRow).operatorUserId || "-" }} · {{ (row as UsageLedgerRow).idempotencyKey || "no-key" }}</small></template>
|
||||
</DataGrid>
|
||||
<p v-else class="muted-box">暂无 ledger。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="detail-subpanel">
|
||||
<h3>Reservations</h3>
|
||||
<DataGrid v-if="reservations.length" :columns="reservationColumns" :rows="reservations" :row-key="reservationKey" :interactive="false" density="compact" aria-label="用户 Reservations">
|
||||
<template #cell-reservation="{ row }"><code>{{ (row as UsageReservationRow).reservationId }}</code></template>
|
||||
<template #cell-service="{ row }"><code>{{ (row as UsageReservationRow).serviceId }}</code><small>{{ (row as UsageReservationRow).resourceType || "generic" }}</small></template>
|
||||
<template #cell-credits="{ row }">{{ formatNumber((row as UsageReservationRow).estimatedCredits) }}</template>
|
||||
<template #cell-status="{ row }">{{ (row as UsageReservationRow).status }}</template>
|
||||
<template #cell-expires="{ row }">{{ formatDate((row as UsageReservationRow).expiresAt) }}</template>
|
||||
</DataGrid>
|
||||
<p v-else class="muted-box">没有 active reservation。</p>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</BaseDrawer>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</AsyncBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-users-page { min-width: 0; }
|
||||
.admin-users-toolbar,
|
||||
.admin-audit-panel,
|
||||
.admin-users-list-panel,
|
||||
.admin-user-detail-panel { min-width: 0; box-shadow: inset 3px 0 #1ca39e; }
|
||||
.admin-users-filters { display: grid; grid-template-columns: minmax(220px, 1fr) 150px 140px auto; gap: 7px; }
|
||||
.admin-users-filters .input { min-width: 0; }
|
||||
.audit-filters { grid-template-columns: auto auto repeat(4, minmax(120px, 1fr)) auto auto; }
|
||||
.admin-users-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); gap: 12px; min-width: 0; }
|
||||
.admin-users-list-panel,
|
||||
.admin-user-create-panel,
|
||||
.admin-user-detail-panel { overflow: auto; }
|
||||
.admin-users-heading-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.admin-users-view-toggle { display: flex; overflow: hidden; border: 1px solid #b7c4cb; border-radius: 5px; }
|
||||
.admin-users-view-toggle button { min-height: 28px; border: 0; background: #fff; padding: 0 9px; color: #53666f; font-size: 10px; font-weight: 800; cursor: pointer; }
|
||||
.admin-users-view-toggle button + button { border-left: 1px solid #cbd5da; }
|
||||
.admin-users-view-toggle button[data-active="true"] { background: #17383f; color: #efffff; }
|
||||
.admin-user-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 8px; min-width: 0; }
|
||||
.admin-user-card { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; gap: 9px; align-items: center; min-width: 0; border: 1px solid #d2dce1; border-radius: 8px; background: linear-gradient(145deg, #fff, #f5f8f9); padding: 11px; color: inherit; text-align: left; cursor: pointer; }
|
||||
.admin-user-card:hover,
|
||||
.admin-user-card[data-selected="true"] { border-color: #27908c; box-shadow: 0 6px 16px rgba(25, 91, 89, .1), inset 3px 0 #1ca39e; }
|
||||
.admin-user-glyph { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid #75bbb7; border-radius: 7px; background: #e0f3f0; color: #155f5c; font: 850 9px/1 ui-monospace, monospace; }
|
||||
.admin-user-card-name { display: grid; min-width: 0; gap: 3px; }
|
||||
.admin-user-card-name strong,
|
||||
.admin-user-card-name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-user-card-name strong { color: #1d323b; font-size: 12px; }
|
||||
.admin-user-card-name small { color: #6a7a82; font-size: 9px; }
|
||||
.admin-user-card dl { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 0; }
|
||||
.admin-user-card-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 6px; }
|
||||
.admin-user-card dl div { border-top: 1px solid #e0e7ea; padding-top: 7px; }
|
||||
.admin-user-card dt { color: #75838a; font-size: 8px; text-transform: uppercase; }
|
||||
.admin-user-card dd { margin: 3px 0 0; color: #29404a; font-size: 11px; font-weight: 800; }
|
||||
.admin-user-card-arrow { color: #217d7a; }
|
||||
.admin-user-create-panel { align-self: start; border-color: #d5c27e; background: #fffdf5; box-shadow: inset 3px 0 #d6a33d; }
|
||||
.admin-billing-table tbody tr { cursor: pointer; }
|
||||
.admin-billing-table tbody tr[data-selected="true"] { background: #e9f6f4; box-shadow: inset 3px 0 #168d88; }
|
||||
.admin-user-detail-panel > .panel-header { position: sticky; top: 0; z-index: 2; background: #fff; }
|
||||
.admin-user-detail-layout { min-width: 0; }
|
||||
.detail-subpanel { min-width: 0; border-color: #d9e2e6; background: #f8fafb; }
|
||||
:deep(.admin-users-page table) { min-width: 700px; }
|
||||
@media (max-width: 1120px) {
|
||||
.admin-users-layout { grid-template-columns: 1fr; }
|
||||
.admin-user-create-panel { order: -1; }
|
||||
.audit-filters { grid-template-columns: repeat(4, minmax(120px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.admin-users-filters,
|
||||
.audit-filters { grid-template-columns: 1fr 1fr; }
|
||||
.admin-users-filters > .input:first-of-type { grid-column: 1 / -1; }
|
||||
:deep(.usage-metrics) { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user