From ecbf88ee2ac975dc50f53af180514d049715fbb5 Mon Sep 17 00:00:00 2001 From: sylarchen1389 Date: Sun, 7 Dec 2025 12:30:16 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=89=8D=E7=AB=AF=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/apis/billing/index.ts | 326 ++++++++++++++++++ .../components/admin/Users/UserList.svelte | 88 ++++- .../admin/Users/UserList/BalanceModal.svelte | 215 ++++++++++++ .../UserList/RechargeHistoryModal.svelte | 120 +++++++ .../UserList/UserBalanceDetailModal.svelte | 190 ++++++++++ .../components/billing/BalanceDisplay.svelte | 90 +++++ .../billing/BillingLogsTable.svelte | 264 ++++++++++++++ .../billing/BillingStatsChart.svelte | 235 +++++++++++++ .../components/billing/LowBalanceAlert.svelte | 109 ++++++ .../components/billing/TokenCostBadge.svelte | 83 +++++ .../components/layout/Sidebar/UserMenu.svelte | 55 ++- src/lib/stores/billing.ts | 53 +++ src/lib/stores/index.ts | 3 + src/routes/(app)/admin/billing/+page.svelte | 257 ++++++++++++++ src/routes/(app)/billing/+page.svelte | 53 +++ 15 files changed, 2138 insertions(+), 3 deletions(-) create mode 100644 src/lib/apis/billing/index.ts create mode 100644 src/lib/components/admin/Users/UserList/BalanceModal.svelte create mode 100644 src/lib/components/admin/Users/UserList/RechargeHistoryModal.svelte create mode 100644 src/lib/components/admin/Users/UserList/UserBalanceDetailModal.svelte create mode 100644 src/lib/components/billing/BalanceDisplay.svelte create mode 100644 src/lib/components/billing/BillingLogsTable.svelte create mode 100644 src/lib/components/billing/BillingStatsChart.svelte create mode 100644 src/lib/components/billing/LowBalanceAlert.svelte create mode 100644 src/lib/components/billing/TokenCostBadge.svelte create mode 100644 src/lib/stores/billing.ts create mode 100644 src/routes/(app)/admin/billing/+page.svelte create mode 100644 src/routes/(app)/billing/+page.svelte diff --git a/src/lib/apis/billing/index.ts b/src/lib/apis/billing/index.ts new file mode 100644 index 0000000000..08e17d0487 --- /dev/null +++ b/src/lib/apis/billing/index.ts @@ -0,0 +1,326 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +// ========== 类型定义 ========== + +export interface BalanceInfo { + balance: number; + total_consumed: number; + billing_status: 'active' | 'frozen'; +} + +export interface BillingLog { + id: string; + model_id: string; + cost: number; + balance_after: number | null; + type: 'deduct' | 'refund' | 'precharge' | 'settle'; + prompt_tokens: number; + completion_tokens: number; + created_at: number; + precharge_id?: string | null; // 预扣费事务ID,用于关联 precharge 和 settle +} + +export interface DailyStats { + date: string; + cost: number; +} + +export interface ModelStats { + model: string; + cost: number; + count: number; +} + +export interface BillingStats { + daily: DailyStats[]; + by_model: ModelStats[]; +} + +export interface ModelPricing { + model_id: string; + input_price: number; + output_price: number; + source: 'database' | 'default'; +} + +export interface RechargeRequest { + user_id: string; + amount: number; + remark?: string; +} + +export interface RechargeLog { + id: string; + user_id: string; + amount: number; + operator_id: string; + operator_name: string; + remark: string | null; + created_at: number; +} + +// ========== API函数 ========== + +/** + * 查询当前用户余额 + */ +export const getBalance = async (token: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/balance`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 查询消费记录 + */ +export const getBillingLogs = async ( + token: string, + limit: number = 50, + offset: number = 0 +): Promise => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/billing/logs?limit=${limit}&offset=${offset}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 查询统计报表 + */ +export const getBillingStats = async ( + token: string, + days: number = 7 +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/stats?days=${days}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 查询模型定价 + */ +export const getModelPricing = async (modelId: string): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/pricing/${modelId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 列出所有模型定价 + */ +export const listModelPricing = async (): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/pricing`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 管理员充值(仅管理员) + */ +export const rechargeUser = async ( + token: string, + data: RechargeRequest +): Promise<{ balance: number; status: string }> => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/recharge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(data) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 设置模型定价(仅管理员) + */ +export const setModelPricing = async ( + token: string, + data: { model_id: string; input_price: number; output_price: number } +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/billing/pricing`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(data) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +/** + * 查询用户充值记录 (仅管理员) + */ +export const getRechargeLogsByUserId = async ( + token: string, + userId: string, + limit: number = 50, + offset: number = 0 +): Promise => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/billing/recharge/logs/${userId}?limit=${limit}&offset=${offset}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail || err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index f7862bf51b..554c116f3e 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -21,6 +21,9 @@ import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte'; import UserChatsModal from '$lib/components/admin/Users/UserList/UserChatsModal.svelte'; import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte'; + import BalanceModal from '$lib/components/admin/Users/UserList/BalanceModal.svelte'; + import RechargeHistoryModal from '$lib/components/admin/Users/UserList/RechargeHistoryModal.svelte'; + import UserBalanceDetailModal from '$lib/components/admin/Users/UserList/UserBalanceDetailModal.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; @@ -52,6 +55,10 @@ let showUserChatsModal = false; let showEditUserModal = false; + let showBalanceModal = false; + let showRechargeHistoryModal = false; + let showBalanceDetailModal = false; + let balanceOperation = 'recharge'; // 'recharge' or 'deduct' const deleteUserHandler = async (id) => { const res = await deleteUserById(localStorage.token, id).catch((error) => { @@ -134,6 +141,36 @@ {/if} +{#if selectedUser} + { + getUserList(); + }} + /> +{/if} + +{#if selectedUser} + +{/if} + +{#if selectedUser} + { + showBalanceModal = true; + balanceOperation = 'recharge'; + }} + onDeduct={() => { + showBalanceModal = true; + balanceOperation = 'deduct'; + }} + /> +{/if} + {#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats}
+ setSortKey('balance')} + > +
+ {$i18n.t('余额')} + + {#if orderBy === 'balance'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + {/if} +
+ + {user.email} + +
+ {#if user.billing_status === 'frozen'} + + {$i18n.t('冻结')} + + {:else} + + {$i18n.t('正常')} + + {/if} + +
+ + {dayjs(user.last_active_at * 1000).fromNow()} diff --git a/src/lib/components/admin/Users/UserList/BalanceModal.svelte b/src/lib/components/admin/Users/UserList/BalanceModal.svelte new file mode 100644 index 0000000000..3bdd2cf7cf --- /dev/null +++ b/src/lib/components/admin/Users/UserList/BalanceModal.svelte @@ -0,0 +1,215 @@ + + + +
+
+
+ {operationText} - {selectedUser?.name} +
+ +
+ +
+ +
+
+ {$i18n.t('当前余额')}: + ¥{(Number(selectedUser?.balance || 0) / 10000).toFixed(2)} +
+
+ {$i18n.t('累计消费')}: + ¥{(Number(selectedUser?.total_consumed || 0) / 10000).toFixed(2)} +
+
+ + +
+ + +
+ + +
+ +