From 0269cf33863efb56ca38ab6a2fea7dcd324f5cfb Mon Sep 17 00:00:00 2001 From: skycurtain Date: Fri, 9 Oct 2026 17:04:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=88=90=E5=91=98=E7=AB=AF?= =?UTF-8?q?=E5=90=8C=E6=9E=84=E4=B8=89=E9=A1=B5=E4=B8=8E=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=AF=A6=E6=83=85=EF=BC=8C=E7=AE=A1=E7=90=86=E7=AB=AF=E8=BD=BB?= =?UTF-8?q?=E9=87=8F=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成员视图改管理端壳(总览/调用日志/支付参数),行内详情弹窗含原始数据; 成员日志含错误行;共享时间范围。管理端成员/支付筛选;支付仅留类型筛选。 --- apps/server/src/routes/admin-usage.ts | 27 + apps/server/src/routes/member-view.ts | 45 +- apps/web/src/api/member/me.ts | 17 +- apps/web/src/api/query-keys.ts | 15 +- apps/web/src/composables/use-member-range.ts | 64 +++ apps/web/src/layouts/member-layout.vue | 290 ++++++++-- apps/web/src/queries/member.ts | 6 +- apps/web/src/router/member.ts | 16 +- apps/web/src/stores/member-range.ts | 33 ++ apps/web/src/views/admin/logs-view.vue | 155 +++++- apps/web/src/views/admin/members-view.vue | 79 ++- apps/web/src/views/admin/payments-view.vue | 62 ++- apps/web/src/views/member/home-view.vue | 524 ------------------ apps/web/src/views/member/login-view.vue | 2 +- apps/web/src/views/member/logs-view.vue | 547 +++++++++++++++++++ apps/web/src/views/member/overview-view.vue | 234 ++++++++ apps/web/src/views/member/payments-view.vue | 144 +++++ packages/core/src/schemas.ts | 1 + packages/core/src/types.ts | 2 + packages/db/src/repos.ts | 34 +- 20 files changed, 1695 insertions(+), 602 deletions(-) create mode 100644 apps/web/src/composables/use-member-range.ts create mode 100644 apps/web/src/stores/member-range.ts delete mode 100644 apps/web/src/views/member/home-view.vue create mode 100644 apps/web/src/views/member/logs-view.vue create mode 100644 apps/web/src/views/member/overview-view.vue create mode 100644 apps/web/src/views/member/payments-view.vue diff --git a/apps/server/src/routes/admin-usage.ts b/apps/server/src/routes/admin-usage.ts index 0c08aef..5cf8f5b 100644 --- a/apps/server/src/routes/admin-usage.ts +++ b/apps/server/src/routes/admin-usage.ts @@ -359,6 +359,33 @@ export function adminUsageRoutes() { is_stream: row.is_stream, billing_source: row.billing_source as LogItem["billing_source"], subscription_id: row.subscription_id, + raw: { + id: row.id, + api_key_id: row.api_key_id, + dedupe_key: row.dedupe_key, + billing_source: row.billing_source, + subscription_id: row.subscription_id, + user_id: row.user_id, + created_at: toUnixSec(row.created_at), + type: row.type, + content: row.content, + username: row.username, + token_name: row.token_name, + model_name: row.model_name, + quota: row.quota, + prompt_tokens: row.prompt_tokens, + completion_tokens: row.completion_tokens, + use_time: row.use_time, + is_stream: row.is_stream, + channel: row.channel, + channel_name: row.channel_name, + token_id: row.token_id, + group_name: row.group_name, + ip: row.ip, + request_id: row.request_id, + upstream_request_id: row.upstream_request_id, + other: row.other, + }, }; }); diff --git a/apps/server/src/routes/member-view.ts b/apps/server/src/routes/member-view.ts index 07ea586..bed1c93 100644 --- a/apps/server/src/routes/member-view.ts +++ b/apps/server/src/routes/member-view.ts @@ -225,7 +225,7 @@ export function memberViewRoutes() { method: "get", path: "/logs", summary: "日志明细(名下 key)", - description: "分页;恒 type=2;DTO 与管理面同构", + description: "分页;默认全类型(可 type 筛);DTO 与管理面同构", tags: ["成员视图"], security: [{ memberToken: [] }], request: { @@ -234,6 +234,10 @@ export function memberViewRoutes() { to: z.string(), page: z.string().optional(), page_size: z.string().optional(), + api_key_id: z.string().optional(), + billing_source: z.string().optional(), + subscription_id: z.string().optional(), + type: z.string().optional(), }), }, responses: { @@ -264,6 +268,18 @@ export function memberViewRoutes() { to: range.to, page, page_size, + api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, + billing_source: + q.billing_source === "none" ? "none" : q.billing_source || undefined, + subscription_id: q.subscription_id + ? Number(q.subscription_id) + : undefined, + type: + q.type === "error" + ? "error" + : q.type != null && q.type !== "" + ? Number(q.type) + : undefined, }); const items: LogItem[] = rows.map((row) => { const { quota_usd } = usageRowPrecise( @@ -287,6 +303,33 @@ export function memberViewRoutes() { is_stream: row.is_stream, billing_source: row.billing_source as LogItem["billing_source"], subscription_id: row.subscription_id, + raw: { + id: row.id, + api_key_id: row.api_key_id, + dedupe_key: row.dedupe_key, + billing_source: row.billing_source, + subscription_id: row.subscription_id, + user_id: row.user_id, + created_at: toUnixSec(row.created_at), + type: row.type, + content: row.content, + username: row.username, + token_name: row.token_name, + model_name: row.model_name, + quota: row.quota, + prompt_tokens: row.prompt_tokens, + completion_tokens: row.completion_tokens, + use_time: row.use_time, + is_stream: row.is_stream, + channel: row.channel, + channel_name: row.channel_name, + token_id: row.token_id, + group_name: row.group_name, + ip: row.ip, + request_id: row.request_id, + upstream_request_id: row.upstream_request_id, + other: row.other, + }, }; }); return c.json({ items, total }, 200); diff --git a/apps/web/src/api/member/me.ts b/apps/web/src/api/member/me.ts index 7622647..045c8ea 100644 --- a/apps/web/src/api/member/me.ts +++ b/apps/web/src/api/member/me.ts @@ -45,11 +45,20 @@ export async function fetchOverview(range: { return data; } -export async function fetchLogs( - range: { from: string; to: string; page: number; page_size: number }, -): Promise> { +export type MemberLogQuery = { + from: string; + to: string; + page: number; + page_size: number; + api_key_id?: number; + billing_source?: string; + subscription_id?: number; + type?: number | "error"; +}; + +export async function fetchLogs(query: MemberLogQuery): Promise> { const { data } = await memberHttp.get>("/logs", { - params: range, + params: query, }); return data; } diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index 7c701dd..405e83f 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -50,7 +50,18 @@ export const qk = { me: () => ["member", "me"] as const, overview: (range: TimeRange) => ["member", "overview", range.from, range.to] as const, - logs: (range: TimeRange & PageQuery) => - ["member", "logs", range.from, range.to, range.page, range.page_size] as const, + logs: (range: TimeRange & PageQuery & Record) => + [ + "member", + "logs", + range.from, + range.to, + range.page, + range.page_size, + range.api_key_id ?? "", + range.billing_source ?? "", + range.subscription_id ?? "", + range.type ?? "", + ] as const, }, }; diff --git a/apps/web/src/composables/use-member-range.ts b/apps/web/src/composables/use-member-range.ts new file mode 100644 index 0000000..5d4b160 --- /dev/null +++ b/apps/web/src/composables/use-member-range.ts @@ -0,0 +1,64 @@ +import { computed, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { + useMemberRangeStore, + type RangeKind, + type RangeValue, +} from "@/stores/member-range.ts"; + +/** + * 成员端共享时间范围 + 当前页 URL(from/to)双向同步。 + * 切页时布局菜单带上 from/to,状态从 store 读——对账窗口不打断。 + */ +export function useMemberRange() { + const store = useMemberRangeStore(); + const route = useRoute(); + const router = useRouter(); + + const range = computed(() => store.range); + + function syncFromRoute() { + const from = route.query.from; + const to = route.query.to; + if (typeof from === "string" && from && typeof to === "string" && to) { + store.setRange({ from, to }, "fixed"); + } + } + + /** 在当前路径上写入 from/to(可并入页内其它 query) */ + function pushRangeQuery(extra: Record = {}) { + void router.replace({ + path: route.path, + query: { + ...pickNonRangeQuery(route.query), + ...extra, + from: store.range.from, + to: store.range.to, + }, + }); + } + + function onRangeChange(v: RangeValue, k: RangeKind) { + store.setRange(v, k); + pushRangeQuery(); + } + + syncFromRoute(); + watch(() => route.query, () => syncFromRoute()); + + return { + range, + setRange: store.setRange, + onRangeChange, + pushRangeQuery, + }; +} + +function pickNonRangeQuery(q: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(q)) { + if (k === "from" || k === "to") continue; + if (typeof v === "string" && v) out[k] = v; + } + return out; +} diff --git a/apps/web/src/layouts/member-layout.vue b/apps/web/src/layouts/member-layout.vue index fbff4f9..65dc8f3 100644 --- a/apps/web/src/layouts/member-layout.vue +++ b/apps/web/src/layouts/member-layout.vue @@ -1,71 +1,273 @@ diff --git a/apps/web/src/queries/member.ts b/apps/web/src/queries/member.ts index b593d62..8228555 100644 --- a/apps/web/src/queries/member.ts +++ b/apps/web/src/queries/member.ts @@ -3,8 +3,9 @@ import { fetchLogs, fetchMe, fetchOverview, + type MemberLogQuery, } from "@/api/member/me.ts"; -import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts"; +import { qk, type TimeRange } from "@/api/query-keys.ts"; export function useMemberMe() { return useQuery({ @@ -21,9 +22,10 @@ export function useMemberOverview(range: () => TimeRange) { }); } -export function useMemberLogs(params: () => TimeRange & PageQuery) { +export function useMemberLogs(params: () => MemberLogQuery) { return useQuery({ queryKey: () => qk.member.logs(params()), queryFn: () => fetchLogs(params()), + staleTime: 0, }); } diff --git a/apps/web/src/router/member.ts b/apps/web/src/router/member.ts index ea04fda..174e57e 100644 --- a/apps/web/src/router/member.ts +++ b/apps/web/src/router/member.ts @@ -1,7 +1,7 @@ import type { RouteRecordRaw } from "vue-router"; import MemberLayout from "@/layouts/member-layout.vue"; -/** 成员视图 `/member/*`:登录壳 → 单页(交互共性 §1) */ +/** 成员视图 `/member/*`:登录壳 → 侧边栏三页(与管理端同构壳) */ export const memberRoutes: RouteRecordRaw[] = [ { path: "/member/login", @@ -16,8 +16,18 @@ export const memberRoutes: RouteRecordRaw[] = [ children: [ { path: "", - name: "member-home", - component: () => import("@/views/member/home-view.vue"), + name: "member-overview", + component: () => import("@/views/member/overview-view.vue"), + }, + { + path: "logs", + name: "member-logs", + component: () => import("@/views/member/logs-view.vue"), + }, + { + path: "payments", + name: "member-payments", + component: () => import("@/views/member/payments-view.vue"), }, ], }, diff --git a/apps/web/src/stores/member-range.ts b/apps/web/src/stores/member-range.ts new file mode 100644 index 0000000..3429f58 --- /dev/null +++ b/apps/web/src/stores/member-range.ts @@ -0,0 +1,33 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { recentDaysRange } from "@/utils/time.ts"; + +export type RangeValue = { from: string; to: string }; +export type RangeKind = "recent-7" | "recent-30" | "fixed"; + +/** + * 成员端全局时间范围——总览 / 调用日志 / 支付参数共用(对账窗口不因切页打断)。 + */ +export const useMemberRangeStore = defineStore("member-range", () => { + const kind = ref("recent-30"); + const fixed = ref(recentDaysRange(30)); + const nowTick = ref(Date.now()); + + setInterval(() => { + nowTick.value = Date.now(); + }, 60_000); + + const range = computed(() => { + void nowTick.value; + if (kind.value === "recent-7") return recentDaysRange(7); + if (kind.value === "recent-30") return recentDaysRange(30); + return fixed.value; + }); + + function setRange(value: RangeValue, nextKind: RangeKind) { + kind.value = nextKind; + if (nextKind === "fixed") fixed.value = value; + } + + return { kind, fixed, range, setRange }; +}); diff --git a/apps/web/src/views/admin/logs-view.vue b/apps/web/src/views/admin/logs-view.vue index 3d6df6e..9744d22 100644 --- a/apps/web/src/views/admin/logs-view.vue +++ b/apps/web/src/views/admin/logs-view.vue @@ -78,8 +78,22 @@ const columns = [ { title: "来源", key: "source", width: 110 }, { title: "用量", key: "usage", width: 140 }, { title: "Tokens", key: "tokens", width: 110 }, + { title: "操作", key: "actions", width: 72 }, ]; +const detailOpen = ref(false); +const detailRecord = ref(null); + +function openDetail(record: LogItem) { + detailRecord.value = record; + detailOpen.value = true; +} + +function formatRaw(record: LogItem | null): string { + if (!record) return ""; + return JSON.stringify(record.raw ?? record, null, 2); +} + function syncFromRoute() { const q = route.query; const m = q.member_id; @@ -159,6 +173,21 @@ function goMember(memberId: number | null) { } let ro: ResizeObserver | null = null; + +function onRangeChange( + v: { from: string; to: string }, + k: "recent-7" | "recent-30" | "fixed", +) { + setRange(v, k); + page.value = 1; + pushQuery(); +} + +function onPageChange(p: number, ps: number) { + page.value = p; + pageSize.value = ps; +} + const boxRef = ref(null); const bodyY = ref(480); const CHROME = 96; @@ -190,13 +219,7 @@ onUnmounted(() => { 时间范围 @@ -266,10 +289,7 @@ onUnmounted(() => { showSizeChanger: true, pageSizeOptions: ['20', '50', '100', '200'], showTotal: (t: number) => `共 ${t} 条`, - onChange: (p: number, ps: number) => { - page = p; - pageSize = ps; - }, + onChange: onPageChange, }" size="middle" :scroll="{ y: bodyY }" @@ -306,9 +326,74 @@ onUnmounted(() => { + + + +
+
+ 时间 + {{ formatDateTime(detailRecord.created_at) }} +
+
+ Key / 归属 + + {{ detailRecord.token_name }} + · + {{ keyById.get(detailRecord.api_key_id)?.member_name ?? "未归属" }} + +
+
+ 模型 / 类型 + + {{ detailRecord.model_name }} + · + {{ detailRecord.type === 2 ? "消费" : "错误" }} + +
+
+ 用量 + +
+
+ Tokens + + 提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }} + +
+
+ 耗时 + + {{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }} + +
+
+ 来源 + + {{ sourceBadge(detailRecord).text }} + +
+
+ 内容 + {{ detailRecord.content }} +
+
+
原始数据
+
{{ formatRaw(detailRecord) }}
+
+
+
@@ -337,4 +422,52 @@ onUnmounted(() => { flex: 1; min-height: 0; } + +.call-logs__detail { + display: flex; + flex-direction: column; + gap: 12px; +} + +.call-logs__kv { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 12px; +} + +.call-logs__note { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + min-width: 72px; + flex-shrink: 0; +} + +.call-logs__num { + font-variant-numeric: tabular-nums; +} + +.call-logs__content { + white-space: pre-wrap; + word-break: break-all; + flex: 1; + min-width: 0; +} + +.call-logs__raw { + margin-top: 4px; +} + +.call-logs__json { + margin: 4px 0 0; + padding: 12px; + max-height: 360px; + overflow: auto; + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 6px; + font-size: 12px; + line-height: 20px; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; +} diff --git a/apps/web/src/views/admin/members-view.vue b/apps/web/src/views/admin/members-view.vue index 1b956d2..01e4ff0 100644 --- a/apps/web/src/views/admin/members-view.vue +++ b/apps/web/src/views/admin/members-view.vue @@ -2,7 +2,7 @@ /** * 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停 */ -import { computed, h, reactive, ref } from "vue"; +import { computed, h, reactive, ref, watch } from "vue"; import { Tooltip, message } from "antdv-next"; import { QuestionCircleOutlined } from "@antdv-next/icons"; import { useRoute, useRouter } from "vue-router"; @@ -34,6 +34,29 @@ const tokenMemberName = ref(""); const createForm = reactive({ name: "", remark: "" }); const createError = ref(""); +const filterName = ref(""); +const filterStatus = ref<"active" | "disabled" | null>(null); + +function syncFiltersFromRoute() { + const q = route.query; + filterName.value = typeof q.name === "string" ? q.name : ""; + filterStatus.value = + q.status === "active" || q.status === "disabled" ? q.status : null; +} + +function pushFilterQuery() { + const query: Record = {}; + const mid = route.query.member_id; + if (typeof mid === "string" && mid) query.member_id = mid; + if (filterName.value.trim()) query.name = filterName.value.trim(); + if (filterStatus.value) query.status = filterStatus.value; + void router.replace({ path: "/admin/members", query }); +} + +syncFiltersFromRoute(); +watch(() => route.query, () => syncFiltersFromRoute()); +watch([filterName, filterStatus], () => pushFilterQuery()); + function tipTitle(label: string, tip: string) { return h("span", { class: "overview-col-title" }, [ label, @@ -63,15 +86,34 @@ const columns = [ ]; const rows = computed(() => { - const list = membersQuery.data.value ?? []; + let list = membersQuery.data.value ?? []; const mid = route.query.member_id; if (typeof mid === "string" && mid) { const n = Number(mid); - return list.filter((m) => m.id === n); + list = list.filter((m) => m.id === n); + } + const name = filterName.value.trim(); + if (name) { + const lower = name.toLowerCase(); + list = list.filter( + (m) => + m.name.toLowerCase().includes(lower) || + (m.remark ?? "").toLowerCase().includes(lower), + ); + } + if (filterStatus.value) { + list = list.filter((m) => m.status === filterStatus.value); } return list; }); +const hasFilter = computed( + () => + !!filterName.value.trim() || + filterStatus.value != null || + (typeof route.query.member_id === "string" && !!route.query.member_id), +); + async function onCreate() { createError.value = ""; const name = createForm.name.trim(); @@ -133,11 +175,31 @@ async function onToggle(record: MemberItem) { +
+ + +
+ - 新建成员 + 新建成员 diff --git a/apps/web/src/views/member/login-view.vue b/apps/web/src/views/member/login-view.vue index 779a32f..1ca9e9c 100644 --- a/apps/web/src/views/member/login-view.vue +++ b/apps/web/src/views/member/login-view.vue @@ -20,7 +20,7 @@ async function onFinish() { tokenStore.setToken(model.token.trim()); const me = await fetchMe(); tokenStore.setMemberName(me.name); - void router.replace({ name: "member-home" }); + void router.replace({ name: "member-overview" }); } catch { tokenStore.clearToken(); error.value = "令牌无效或已停用"; diff --git a/apps/web/src/views/member/logs-view.vue b/apps/web/src/views/member/logs-view.vue new file mode 100644 index 0000000..9de17b4 --- /dev/null +++ b/apps/web/src/views/member/logs-view.vue @@ -0,0 +1,547 @@ + + + + + diff --git a/apps/web/src/views/member/overview-view.vue b/apps/web/src/views/member/overview-view.vue new file mode 100644 index 0000000..faab91c --- /dev/null +++ b/apps/web/src/views/member/overview-view.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/apps/web/src/views/member/payments-view.vue b/apps/web/src/views/member/payments-view.vue new file mode 100644 index 0000000..de850ab --- /dev/null +++ b/apps/web/src/views/member/payments-view.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 865c41c..a675cf7 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -65,6 +65,7 @@ export const logItem = z.object({ is_stream: z.boolean(), billing_source: z.string().nullable(), subscription_id: z.number().int().nullable(), + raw: z.record(z.string(), z.unknown()).optional(), }); export const logPage = z.object({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 39d6350..75c1dc4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -37,6 +37,8 @@ export interface LogItem { is_stream: boolean; billing_source: BillingSource | null; subscription_id: number | null; + /** 完整落库行(上游字段原貌)——「原始数据」视图 */ + raw?: Record; } export interface KeyItem { diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 1b86ffc..492d9c6 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -597,20 +597,39 @@ export function createRepos(db: Db) { ); }, - /** 成员视图明细分页(恒 type=2) */ + /** 成员视图明细分页(可 type 筛;默认全类型) */ async listMemberPage(params: { member_id: number; from: Date; to: Date; page: number; page_size: number; + api_key_id?: number; + billing_source?: string | "none"; + subscription_id?: number; + type?: number | "error"; }) { const conds = [ eq(apiKeys.member_id, params.member_id), gte(usageLogs.created_at, params.from), lt(usageLogs.created_at, params.to), - eq(usageLogs.type, 2), ]; + if (params.type === "error") { + conds.push(ne(usageLogs.type, 2)); + } else if (typeof params.type === "number") { + conds.push(eq(usageLogs.type, params.type)); + } + if (params.api_key_id != null) { + conds.push(eq(usageLogs.api_key_id, params.api_key_id)); + } + if (params.billing_source === "none") { + conds.push(isNull(usageLogs.billing_source)); + } else if (params.billing_source) { + conds.push(eq(usageLogs.billing_source, params.billing_source)); + } + if (params.subscription_id != null) { + conds.push(eq(usageLogs.subscription_id, params.subscription_id)); + } const totalRows = await db .select({ n: count() }) .from(usageLogs) @@ -621,6 +640,7 @@ export function createRepos(db: Db) { .select({ id: usageLogs.id, api_key_id: usageLogs.api_key_id, + dedupe_key: usageLogs.dedupe_key, created_at: usageLogs.created_at, token_name: apiKeys.token_name, model_name: usageLogs.model_name, @@ -633,6 +653,16 @@ export function createRepos(db: Db) { is_stream: usageLogs.is_stream, billing_source: usageLogs.billing_source, subscription_id: usageLogs.subscription_id, + user_id: usageLogs.user_id, + username: usageLogs.username, + channel: usageLogs.channel, + channel_name: usageLogs.channel_name, + token_id: usageLogs.token_id, + group_name: usageLogs.group_name, + ip: usageLogs.ip, + request_id: usageLogs.request_id, + upstream_request_id: usageLogs.upstream_request_id, + other: usageLogs.other, log_synced_at: apiKeys.log_synced_at, }) .from(usageLogs)