feat:前端计费页面

This commit is contained in:
sylarchen1389 2025-12-07 12:30:16 +08:00
parent 448d3697a4
commit ecbf88ee2a
15 changed files with 2138 additions and 3 deletions

View file

@ -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<BalanceInfo> => {
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<BillingLog[]> => {
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<BillingStats> => {
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<ModelPricing> => {
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<ModelPricing[]> => {
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<ModelPricing> => {
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<RechargeLog[]> => {
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;
};

View file

@ -21,6 +21,9 @@
import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte'; import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte';
import UserChatsModal from '$lib/components/admin/Users/UserList/UserChatsModal.svelte'; import UserChatsModal from '$lib/components/admin/Users/UserList/UserChatsModal.svelte';
import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.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 ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@ -52,6 +55,10 @@
let showUserChatsModal = false; let showUserChatsModal = false;
let showEditUserModal = false; let showEditUserModal = false;
let showBalanceModal = false;
let showRechargeHistoryModal = false;
let showBalanceDetailModal = false;
let balanceOperation = 'recharge'; // 'recharge' or 'deduct'
const deleteUserHandler = async (id) => { const deleteUserHandler = async (id) => {
const res = await deleteUserById(localStorage.token, id).catch((error) => { const res = await deleteUserById(localStorage.token, id).catch((error) => {
@ -134,6 +141,36 @@
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} /> <UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
{/if} {/if}
{#if selectedUser}
<BalanceModal
bind:show={showBalanceModal}
selectedUser={selectedUser}
bind:operation={balanceOperation}
on:save={() => {
getUserList();
}}
/>
{/if}
{#if selectedUser}
<RechargeHistoryModal bind:show={showRechargeHistoryModal} selectedUser={selectedUser} />
{/if}
{#if selectedUser}
<UserBalanceDetailModal
bind:show={showBalanceDetailModal}
selectedUser={selectedUser}
onRecharge={() => {
showBalanceModal = true;
balanceOperation = 'recharge';
}}
onDeduct={() => {
showBalanceModal = true;
balanceOperation = 'deduct';
}}
/>
{/if}
{#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats} {#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats}
<div class=" mt-1 mb-2 text-xs text-red-500"> <div class=" mt-1 mb-2 text-xs text-red-500">
<Banner <Banner
@ -293,6 +330,30 @@
</div> </div>
</th> </th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('balance')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('余额')}
{#if orderBy === 'balance'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
@ -364,8 +425,8 @@
<img <img
class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0" class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) || src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') || user?.profile_image_url?.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:') user?.profile_image_url?.startsWith('data:')
? user.profile_image_url ? user.profile_image_url
: `${WEBUI_BASE_URL}/user.png`} : `${WEBUI_BASE_URL}/user.png`}
alt="user" alt="user"
@ -376,6 +437,29 @@
</td> </td>
<td class=" px-3 py-1"> {user.email} </td> <td class=" px-3 py-1"> {user.email} </td>
<td class="px-3 py-1 min-w-[8rem] w-32">
<div class="flex items-center gap-2">
{#if user.billing_status === 'frozen'}
<span class="text-xs px-1.5 py-0.5 rounded-full w-fit bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
{$i18n.t('冻结')}
</span>
{:else}
<span class="text-xs px-1.5 py-0.5 rounded-full w-fit bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
{$i18n.t('正常')}
</span>
{/if}
<button
class="font-medium hover:text-blue-600 dark:hover:text-blue-400 transition cursor-pointer"
on:click={() => {
selectedUser = user;
showBalanceDetailModal = true;
}}
>
¥{(Number(user.balance || 0) / 10000).toFixed(2)}
</button>
</div>
</td>
<td class=" px-3 py-1"> <td class=" px-3 py-1">
{dayjs(user.last_active_at * 1000).fromNow()} {dayjs(user.last_active_at * 1000).fromNow()}
</td> </td>

View file

@ -0,0 +1,215 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext } from 'svelte';
import { rechargeUser } from '$lib/apis/billing';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let show = false;
export let selectedUser: any;
export let operation: 'recharge' | 'deduct' = 'recharge';
let amount = 0;
let remark = '';
let showConfirmDialog = false;
let loading = false;
$: operationText = operation === 'recharge' ? $i18n.t('充值') : $i18n.t('扣费');
$: finalAmount = operation === 'deduct' ? -amount : amount;
// 余额显示单位后端存储单位1元 = 10000毫
$: currentBalanceYuan = Number(selectedUser?.balance || 0) / 10000;
$: newBalance = currentBalanceYuan + finalAmount;
const submitHandler = () => {
if (amount <= 0) {
toast.error($i18n.t('金额必须大于0'));
return;
}
if (!remark.trim()) {
toast.error($i18n.t('请填写备注说明操作原因'));
return;
}
showConfirmDialog = true;
};
const confirmSubmit = async () => {
loading = true;
showConfirmDialog = false;
try {
// 提交到后端时:元 * 10000 = 毫
const amountInMilli = Math.round(finalAmount * 10000);
const result = await rechargeUser(localStorage.token, {
user_id: selectedUser.id,
amount: amountInMilli,
remark
});
// 后端返回的余额单位显示时除以10000
const balanceYuan = result.balance / 10000;
toast.success(`${operationText}${$i18n.t('成功')}! ${$i18n.t('当前余额')}: ¥${balanceYuan.toFixed(2)}`);
dispatch('save');
show = false;
// 重置表单
amount = 0;
remark = '';
} catch (error: any) {
toast.error(`${operationText}${$i18n.t('失败')}: ${error.detail || error}`);
} finally {
loading = false;
}
};
const cancelConfirm = () => {
showConfirmDialog = false;
};
</script>
<Modal size="sm" bind:show>
<div>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="text-lg font-medium self-center">
{operationText} - {selectedUser?.name}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<form
class="flex flex-col w-full px-5 pb-5"
on:submit|preventDefault={submitHandler}
>
<!-- 当前余额信息 -->
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex justify-between text-sm mb-1">
<span>{$i18n.t('当前余额')}:</span>
<span class="font-medium">¥{(Number(selectedUser?.balance || 0) / 10000).toFixed(2)}</span>
</div>
<div class="flex justify-between text-sm">
<span>{$i18n.t('累计消费')}:</span>
<span>¥{(Number(selectedUser?.total_consumed || 0) / 10000).toFixed(2)}</span>
</div>
</div>
<!-- 金额输入 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2 dark:text-gray-300">
{operationText}{$i18n.t('金额')} ({$i18n.t('元')})
</label>
<input
type="number"
step="0.01"
min="0"
bind:value={amount}
class="w-full px-3 py-2 border dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-gray-100"
placeholder={$i18n.t('请输入金额')}
required
/>
</div>
<!-- 备注输入 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2 dark:text-gray-300">
{$i18n.t('备注')} ({$i18n.t('必填')})
</label>
<textarea
bind:value={remark}
rows="3"
class="w-full px-3 py-2 border dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-gray-100"
placeholder={$i18n.t('请说明操作原因')}
required
/>
</div>
<!-- 预览 -->
{#if amount > 0}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm">
<div class="flex justify-between">
<span>{operationText}{$i18n.t('后余额')}:</span>
<span class="font-medium" class:text-red-600={newBalance < 0}>
¥{newBalance.toFixed(2)}
</span>
</div>
</div>
{/if}
<!-- 按钮 -->
<div class="flex justify-end gap-2">
<button
type="button"
class="px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300"
on:click={() => (show = false)}
>
{$i18n.t('取消')}
</button>
<button
type="submit"
class="px-4 py-2 rounded-lg text-white"
class:bg-green-600={operation === 'recharge'}
class:hover:bg-green-700={operation === 'recharge'}
class:bg-red-600={operation === 'deduct'}
class:hover:bg-red-700={operation === 'deduct'}
disabled={loading}
>
{loading ? $i18n.t('处理中') + '...' : $i18n.t('确认') + operationText}
</button>
</div>
</form>
</div>
</Modal>
<!-- 二次确认对话框 -->
{#if showConfirmDialog}
<Modal size="sm" bind:show={showConfirmDialog}>
<div class="px-5 py-4">
<div class="text-lg font-medium mb-4 dark:text-gray-300">
{$i18n.t('确认')}{operationText}
</div>
<div class="mb-4 dark:text-gray-300">
<p>
{$i18n.t('确认为用户')} <strong>{selectedUser?.name}</strong> {operationText}
<strong>¥{amount.toFixed(2)}</strong> {$i18n.t('元吗')}?
</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{$i18n.t('当前余额')}: ¥{(Number(selectedUser?.balance || 0) / 10000).toFixed(2)}<br />
{operationText}{$i18n.t('后余额')}: ¥{newBalance.toFixed(2)}
</p>
<p class="mt-2 text-sm dark:text-gray-400">
{$i18n.t('备注')}: {remark}
</p>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300"
on:click={cancelConfirm}
>
{$i18n.t('取消')}
</button>
<button
type="button"
class="px-4 py-2 rounded-lg text-white"
class:bg-green-600={operation === 'recharge'}
class:hover:bg-green-700={operation === 'recharge'}
class:bg-red-600={operation === 'deduct'}
class:hover:bg-red-700={operation === 'deduct'}
on:click={confirmSubmit}
disabled={loading}
>
{loading ? $i18n.t('处理中') + '...' : $i18n.t('确认')}
</button>
</div>
</div>
</Modal>
{/if}

View file

@ -0,0 +1,120 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import { getRechargeLogsByUserId, type RechargeLog } from '$lib/apis/billing';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
export let show = false;
export let selectedUser: any;
let logs: RechargeLog[] = [];
let loading = true;
const loadLogs = async () => {
if (!selectedUser?.id) return;
loading = true;
try {
logs = await getRechargeLogsByUserId(localStorage.token, selectedUser.id);
} catch (error: any) {
console.error('加载充值记录失败:', error);
toast.error($i18n.t('加载充值记录失败') + ': ' + (error.detail || error));
} finally {
loading = false;
}
};
$: if (show && selectedUser) {
loadLogs();
}
const formatDate = (timestamp: number) => {
// 纳秒级时间戳转换为毫秒
const date = new Date(timestamp / 1000000);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
</script>
<Modal size="lg" bind:show>
<div>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="text-lg font-medium self-center">
{$i18n.t('充值记录')} - {selectedUser?.name}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="px-5 pb-5">
{#if loading}
<div class="text-center py-8 dark:text-gray-400">
{$i18n.t('加载中')}...
</div>
{:else if logs.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
{$i18n.t('暂无充值记录')}
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b dark:border-gray-700">
<th class="text-left py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('时间')}
</th>
<th class="text-right py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('金额')}
</th>
<th class="text-left py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('操作员')}
</th>
<th class="text-left py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('备注')}
</th>
</tr>
</thead>
<tbody>
{#each logs as log}
<tr class="border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="py-2 px-2 text-sm dark:text-gray-300">
{formatDate(log.created_at)}
</td>
<td
class="text-right py-2 px-2 text-sm font-medium"
class:text-green-600={log.amount > 0}
class:text-red-600={log.amount < 0}
>
{log.amount > 0 ? '+' : ''}{log.amount.toFixed(2)} {$i18n.t('元')}
</td>
<td class="py-2 px-2 text-sm dark:text-gray-300">
{log.operator_name}
</td>
<td class="py-2 px-2 text-sm text-gray-600 dark:text-gray-400">
{log.remark || '-'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</Modal>

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import { getRechargeLogsByUserId, type RechargeLog } from '$lib/apis/billing';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
export let show = false;
export let selectedUser: any;
export let onRecharge: () => void;
export let onDeduct: () => void;
let logs: RechargeLog[] = [];
let loading = true;
const loadLogs = async () => {
if (!selectedUser?.id) return;
loading = true;
try {
logs = await getRechargeLogsByUserId(localStorage.token, selectedUser.id);
} catch (error: any) {
console.error('加载充值记录失败:', error);
toast.error($i18n.t('加载充值记录失败') + ': ' + (error.detail || error));
} finally {
loading = false;
}
};
$: if (show && selectedUser) {
loadLogs();
}
const formatDate = (timestamp: number) => {
// 纳秒级时间戳转换为毫秒
const date = new Date(timestamp / 1000000);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
</script>
<Modal size="lg" bind:show>
<div>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="text-lg font-medium self-center">
{$i18n.t('余额详情')} - {selectedUser?.name}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="px-5 pb-5">
<!-- 余额信息卡片 -->
<div class="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex justify-between items-center mb-3">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">{$i18n.t('当前余额')}</div>
<div class="text-2xl font-bold">
¥{(Number(selectedUser?.balance || 0) / 10000).toFixed(2)}
</div>
</div>
<div class="text-right">
<div class="text-sm text-gray-600 dark:text-gray-400">{$i18n.t('累计消费')}</div>
<div class="text-lg">¥{(Number(selectedUser?.total_consumed || 0) / 10000).toFixed(2)}</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<button
class="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition"
on:click={() => {
show = false;
onRecharge();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 inline-block mr-1"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
{$i18n.t('充值')}
</button>
<button
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition"
on:click={() => {
show = false;
onDeduct();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 inline-block mr-1"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />
</svg>
{$i18n.t('扣费')}
</button>
</div>
</div>
<!-- 充值记录 -->
<div>
<h3 class="text-md font-medium mb-3 dark:text-gray-300">
{$i18n.t('充值记录')} / {$i18n.t('余额修改记录')}
</h3>
{#if loading}
<div class="text-center py-8 dark:text-gray-400">
{$i18n.t('加载中')}...
</div>
{:else if logs.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
{$i18n.t('暂无充值记录')}
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b dark:border-gray-700">
<th class="text-left py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('时间')}
</th>
<th class="text-right py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('金额')}
</th>
<th class="text-left py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('操作员')}
</th>
<th class="text-left py-2 px-2 text-sm font-medium dark:text-gray-300">
{$i18n.t('备注')}
</th>
</tr>
</thead>
<tbody>
{#each logs as log}
<tr
class="border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td class="py-2 px-2 text-sm dark:text-gray-300">
{formatDate(log.created_at)}
</td>
<td
class="text-right py-2 px-2 text-sm font-medium"
class:text-green-600={log.amount > 0}
class:text-red-600={log.amount < 0}
>
{log.amount > 0 ? '+' : ''}{(log.amount / 10000).toFixed(2)}
{$i18n.t('元')}
</td>
<td class="py-2 px-2 text-sm dark:text-gray-300">
{log.operator_name}
</td>
<td class="py-2 px-2 text-sm text-gray-600 dark:text-gray-400">
{log.remark || '-'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>
</Modal>

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { balance, isLowBalance, isFrozen, formatCurrency } from '$lib/stores';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
export let compact = false; // 紧凑模式(用于侧边栏)
</script>
{#if $balance}
<div class="balance-display" class:compact>
<div class="balance-info">
<div class="balance-label text-xs opacity-80">{$i18n.t('当前余额')}</div>
<div
class="balance-amount font-bold mt-1"
class:low={$isLowBalance}
class:frozen={$isFrozen}
>
{formatCurrency($balance.balance)}
</div>
</div>
{#if !compact}
<div class="consumed-info mt-3 pt-3 border-t border-white/20">
<div class="consumed-label text-xs opacity-80">{$i18n.t('累计消费')}</div>
<div class="consumed-amount text-sm font-semibold mt-1">
{formatCurrency($balance.total_consumed)}
</div>
</div>
{/if}
{#if $isFrozen}
<div class="status-badge frozen mt-2">
{$i18n.t('账户已冻结')}
</div>
{:else if $isLowBalance}
<div class="status-badge warning mt-2">
{$i18n.t('余额不足')}
</div>
{/if}
</div>
{/if}
<style>
.balance-display {
padding: 0.75rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.balance-display.compact {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.balance-amount {
font-size: 1.5rem;
}
.balance-display.compact .balance-amount {
font-size: 1.125rem;
}
.balance-amount.low {
color: #fbbf24;
}
.balance-amount.frozen {
color: #ef4444;
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
}
.status-badge.warning {
background: #fbbf24;
color: #78350f;
}
.status-badge.frozen {
background: #ef4444;
color: white;
}
</style>

View file

@ -0,0 +1,264 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { billingLogs } from '$lib/stores';
import { getBillingLogs } from '$lib/apis/billing';
import { formatCurrency, formatDate } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
let loading = false;
let limit = 50;
let offset = 0;
let hasMore = true;
let expandedLogIds = new Set<string>();
const loadLogs = async () => {
loading = true;
try {
const logs = await getBillingLogs(localStorage.token, limit, offset);
if (logs.length < limit) {
hasMore = false;
}
billingLogs.update((current) => [...current, ...logs]);
offset += logs.length;
} catch (error) {
toast.error($i18n.t('加载消费记录失败: ') + error.message);
} finally {
loading = false;
}
};
onMount(() => {
// 清空之前的记录
billingLogs.set([]);
expandedLogIds = new Set(); // 清空展开状态
offset = 0;
hasMore = true;
loadLogs();
});
// 合并流式请求的 precharge + settle 为一条记录
$: mergedLogs = (() => {
const result = [];
const prechargeMap = new Map();
const processedIds = new Set(); // 跟踪已处理的记录ID避免重复
// 第一遍:收集所有 precharge 记录
$billingLogs.forEach((log) => {
if (log.type === 'precharge' && log.precharge_id) {
prechargeMap.set(log.precharge_id, log);
}
});
// 第二遍:合并或直接添加
$billingLogs.forEach((log) => {
// 跳过已处理的记录
if (processedIds.has(log.id)) {
return;
}
if (log.type === 'settle' && log.precharge_id) {
// 流式请求的 settle 记录,合并预扣费信息
const precharge = prechargeMap.get(log.precharge_id);
result.push({
...log,
displayType: 'streaming', // 标记为流式请求
prechargeAmount: precharge?.cost || 0,
refundAmount: precharge ? precharge.cost - log.cost : 0
});
processedIds.add(log.id);
// 同时标记对应的 precharge 已处理
if (precharge) {
processedIds.add(precharge.id);
}
} else if (log.type === 'precharge') {
// 检查是否已被 settle 处理过
if (processedIds.has(log.id)) {
return;
}
// 检查是否有对应的 settle异常情况孤立的 precharge
const hasSettle = $billingLogs.some(
(l) => l.type === 'settle' && l.precharge_id === log.precharge_id
);
if (!hasSettle) {
// 孤立的 precharge可能是请求失败保留显示
result.push({ ...log, displayType: 'precharge-only' });
processedIds.add(log.id);
}
} else {
// deduct, refund 等其他类型,直接显示
result.push({ ...log, displayType: log.type });
processedIds.add(log.id);
}
});
return result;
})();
const getLogTypeLabel = (displayType: string) => {
const labels = {
streaming: '流式请求',
deduct: '直接扣费',
refund: '退款',
'precharge-only': '预扣费(未完成)'
};
return labels[displayType] || displayType;
};
const getLogTypeClass = (displayType: string) => {
const classes = {
streaming: 'text-blue-600 dark:text-blue-400',
deduct: 'text-red-600 dark:text-red-400',
refund: 'text-green-600 dark:text-green-400',
'precharge-only': 'text-yellow-600 dark:text-yellow-400'
};
return classes[displayType] || '';
};
const toggleExpand = (logId: string) => {
if (expandedLogIds.has(logId)) {
expandedLogIds.delete(logId);
} else {
expandedLogIds.add(logId);
}
expandedLogIds = expandedLogIds; // 触发响应式更新
};
</script>
<div class="billing-logs-table bg-white dark:bg-gray-850 rounded-xl shadow-sm">
<div class="table-header px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<h2 class="text-lg font-semibold">{$i18n.t('消费记录')}</h2>
</div>
<div class="table-container overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800">
<th class="px-4 py-3 text-left text-xs font-semibold">{$i18n.t('时间')}</th>
<th class="px-4 py-3 text-left text-xs font-semibold">{$i18n.t('模型')}</th>
<th class="px-4 py-3 text-left text-xs font-semibold">{$i18n.t('类型')}</th>
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('输入Token')}</th>
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('输出Token')}</th>
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('费用')}</th>
<th class="px-4 py-3 text-right text-xs font-semibold">{$i18n.t('余额')}</th>
</tr>
</thead>
<tbody>
{#each mergedLogs as log (log.id)}
<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-sm">{formatDate(log.created_at)}</td>
<td class="px-4 py-3">
<code
class="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs font-mono"
>
{log.model_id}
</code>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold {getLogTypeClass(log.displayType)}">
{getLogTypeLabel(log.displayType)}
</span>
{#if log.displayType === 'streaming'}
<button
class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => toggleExpand(log.id)}
title={expandedLogIds.has(log.id) ? '收起详情' : '展开详情'}
>
{#if expandedLogIds.has(log.id)}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
{/if}
</button>
{/if}
</div>
</td>
<td class="px-4 py-3 text-right text-sm">{log.prompt_tokens.toLocaleString()}</td>
<td class="px-4 py-3 text-right text-sm">
{log.completion_tokens.toLocaleString()}
</td>
<td class="px-4 py-3 text-right text-sm font-semibold" class:text-green-600={log.cost < 0} class:text-red-600={log.cost >= 0}>
{formatCurrency(log.cost, true)}
</td>
<td class="px-4 py-3 text-right text-sm">
{log.balance_after !== null ? formatCurrency(log.balance_after, false) : '-'}
</td>
</tr>
<!-- 流式请求的展开详情 -->
{#if log.displayType === 'streaming' && expandedLogIds.has(log.id)}
<tr class="bg-blue-50 dark:bg-blue-900/10 border-b border-gray-100 dark:border-gray-800">
<td colspan="7" class="px-4 py-3">
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
<div class="flex justify-between">
<span>预扣金额:</span>
<span class="font-mono">{formatCurrency(log.prechargeAmount, true)}</span>
</div>
<div class="flex justify-between">
<span>实际消费:</span>
<span class="font-mono">{formatCurrency(log.cost, true)}</span>
</div>
<div class="flex justify-between text-green-600 dark:text-green-400">
<span>退款金额:</span>
<span class="font-mono">-{formatCurrency(log.refundAmount, true)}</span>
</div>
{#if log.precharge_id}
<div class="flex justify-between pt-1 mt-1 border-t border-gray-200 dark:border-gray-700">
<span>事务ID:</span>
<code class="font-mono text-xs">{log.precharge_id.slice(0, 8)}...</code>
</div>
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{#if loading}
<div class="flex justify-center items-center py-8">
<Spinner />
</div>
{/if}
{#if !loading && hasMore}
<div class="flex justify-center py-4">
<button
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition"
on:click={loadLogs}
>
{$i18n.t('加载更多')}
</button>
</div>
{/if}
{#if !loading && mergedLogs.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
<svg
class="w-16 h-16 mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-sm">{$i18n.t('暂无消费记录')}</p>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,235 @@
<script lang="ts">
import { onMount, onDestroy, getContext, tick } from 'svelte';
import { billingStats } from '$lib/stores';
import { getBillingStats } from '$lib/apis/billing';
import { toast } from 'svelte-sonner';
import * as echarts from 'echarts';
const i18n = getContext('i18n');
let dailyChartContainer: HTMLDivElement;
let modelChartContainer: HTMLDivElement;
let dailyChart: echarts.ECharts | null = null;
let modelChart: echarts.ECharts | null = null;
let days = 7;
let loading = false;
const loadStats = async () => {
loading = true;
try {
const stats = await getBillingStats(localStorage.token, days);
billingStats.set(stats);
// 等待 DOM 更新后初始化图表
await tick();
await initCharts();
// 渲染图表
renderCharts();
} catch (error) {
toast.error($i18n.t('查询统计失败: ') + error.message);
} finally {
loading = false;
}
};
const renderCharts = () => {
if (!$billingStats || !dailyChart || !modelChart) return;
// 按日统计图表
if (dailyChart && $billingStats.daily.length > 0) {
dailyChart.setOption({
title: {
text: $i18n.t('每日消费趋势'),
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const data = params[0];
return `${data.name}<br/>费用: ¥${data.value.toFixed(4)}`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: $billingStats.daily.map((d) => d.date),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: '费用(元)',
axisLabel: {
formatter: '¥{value}'
}
},
series: [
{
data: $billingStats.daily.map((d) => d.cost),
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.5)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0.1)' }
])
},
lineStyle: {
color: '#3b82f6',
width: 2
},
itemStyle: {
color: '#3b82f6'
}
}
]
});
}
// 按模型统计图表
if (modelChart && $billingStats.by_model.length > 0) {
modelChart.setOption({
title: {
text: $i18n.t('模型消费分布'),
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'item',
formatter: (params: any) => {
return `${params.name}<br/>费用: ¥${params.value.toFixed(4)}<br/>占比: ${params.percent}%`;
}
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
data: $billingStats.by_model.map((m) => ({
name: m.model,
value: m.cost
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
label: {
formatter: '{b}: ¥{c}'
}
}
]
});
}
};
const initCharts = async () => {
// 等待 DOM 更新
await tick();
// 确保 DOM 元素存在后再初始化
if (dailyChartContainer && !dailyChart) {
dailyChart = echarts.init(dailyChartContainer);
}
if (modelChartContainer && !modelChart) {
modelChart = echarts.init(modelChartContainer);
}
};
onMount(async () => {
// loadStats 会处理图表初始化和渲染
await loadStats();
// 响应式调整(等待图表初始化完成)
await tick();
let resizeObserver: ResizeObserver | null = null;
if (dailyChartContainer && modelChartContainer && dailyChart && modelChart) {
resizeObserver = new ResizeObserver(() => {
dailyChart?.resize();
modelChart?.resize();
});
resizeObserver.observe(dailyChartContainer);
resizeObserver.observe(modelChartContainer);
}
return () => {
resizeObserver?.disconnect();
};
});
onDestroy(() => {
dailyChart?.dispose();
modelChart?.dispose();
});
</script>
<div class="billing-stats bg-white dark:bg-gray-850 rounded-xl shadow-sm p-4">
<div class="stats-header flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">{$i18n.t('消费统计')}</h2>
<select
bind:value={days}
on:change={loadStats}
class="px-3 py-1.5 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-sm"
>
<option value={7}>{$i18n.t('最近7天')}</option>
<option value={30}>{$i18n.t('最近30天')}</option>
<option value={90}>{$i18n.t('最近90天')}</option>
</select>
</div>
{#if loading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
{:else if $billingStats}
<div class="charts-container grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="chart-wrapper">
<div bind:this={dailyChartContainer} class="chart h-80"></div>
</div>
<div class="chart-wrapper">
<div bind:this={modelChartContainer} class="chart h-80"></div>
</div>
</div>
{#if $billingStats.daily.length === 0 && $billingStats.by_model.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
<svg
class="w-16 h-16 mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<p class="text-sm">暂无统计数据</p>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,109 @@
<script lang="ts">
import { isLowBalance, isFrozen, balance, formatCurrency } from '$lib/stores';
import { getContext } from 'svelte';
import { slide } from 'svelte/transition';
const i18n = getContext('i18n');
let dismissed = false;
const dismiss = () => {
dismissed = true;
};
</script>
{#if ($isLowBalance || $isFrozen) && !dismissed}
<div class="alert" class:frozen={$isFrozen} transition:slide>
<div class="alert-icon">
{#if $isFrozen}
{:else}
⚠️
{/if}
</div>
<div class="alert-content">
<div class="alert-title">
{#if $isFrozen}
{$i18n.t('账户已冻结')}
{:else}
{$i18n.t('余额不足')}
{/if}
</div>
<div class="alert-message">
{#if $isFrozen}
{$i18n.t('您的账户余额不足,已被冻结。请联系管理员充值。')}
{:else}
{$i18n.t('当前余额')}: {formatCurrency($balance?.balance || 0)}{$i18n.t(
'请及时充值以免影响使用。'
)}
{/if}
</div>
</div>
<button class="alert-close" on:click={dismiss}>×</button>
</div>
{/if}
<style>
.alert {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #fef3c7;
border: 1px solid #fbbf24;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert.frozen {
background: #fee2e2;
border-color: #ef4444;
}
.alert-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.alert-content {
flex: 1;
}
.alert-title {
font-weight: 600;
margin-bottom: 0.25rem;
color: #78350f;
}
.alert.frozen .alert-title {
color: #7f1d1d;
}
.alert-message {
font-size: 0.875rem;
color: #78350f;
}
.alert.frozen .alert-message {
color: #7f1d1d;
}
.alert-close {
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
background: none;
border: none;
padding: 0;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.alert-close:hover {
color: #111827;
}
</style>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { formatCurrency } from '$lib/stores';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
export let promptTokens: number = 0;
export let completionTokens: number = 0;
export let cost: number = 0;
export let compact = false;
</script>
<div class="token-cost-badge" class:compact>
{#if !compact}
<div class="token-info">
<span class="token-label">{$i18n.t('输入')}:</span>
<span class="token-value">{promptTokens.toLocaleString()}</span>
</div>
<div class="token-info">
<span class="token-label">{$i18n.t('输出')}:</span>
<span class="token-value">{completionTokens.toLocaleString()}</span>
</div>
{/if}
<div class="cost-info">
<span class="cost-label">{$i18n.t('费用')}:</span>
<span class="cost-value">{formatCurrency(cost, true)}</span>
</div>
</div>
<style>
.token-cost-badge {
display: inline-flex;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: #f3f4f6;
border-radius: 0.375rem;
font-size: 0.875rem;
}
:global(.dark) .token-cost-badge {
background: #374151;
}
.token-cost-badge.compact {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.token-info,
.cost-info {
display: flex;
gap: 0.25rem;
}
.token-label,
.cost-label {
color: #6b7280;
}
:global(.dark) .token-label,
:global(.dark) .cost-label {
color: #9ca3af;
}
.token-value,
.cost-value {
font-weight: 600;
color: #111827;
}
:global(.dark) .token-value,
:global(.dark) .cost-value {
color: #f9fafb;
}
.cost-value {
color: #dc2626;
}
:global(.dark) .cost-value {
color: #ef4444;
}
</style>

View file

@ -8,8 +8,9 @@
import { getUsage } from '$lib/apis'; import { getUsage } from '$lib/apis';
import { userSignOut } from '$lib/apis/auths'; import { userSignOut } from '$lib/apis/auths';
import { getBalance } from '$lib/apis/billing';
import { showSettings, mobile, showSidebar, showShortcuts, user } from '$lib/stores'; import { showSettings, mobile, showSidebar, showShortcuts, user, balance } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte'; import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
@ -21,6 +22,7 @@
import Code from '$lib/components/icons/Code.svelte'; import Code from '$lib/components/icons/Code.svelte';
import UserGroup from '$lib/components/icons/UserGroup.svelte'; import UserGroup from '$lib/components/icons/UserGroup.svelte';
import SignOut from '$lib/components/icons/SignOut.svelte'; import SignOut from '$lib/components/icons/SignOut.svelte';
import BalanceDisplay from '$lib/components/billing/BalanceDisplay.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -44,8 +46,18 @@
} }
}; };
const loadBalance = async () => {
try {
const balanceInfo = await getBalance(localStorage.token);
balance.set(balanceInfo);
} catch (error) {
console.error('Error fetching balance:', error);
}
};
$: if (show) { $: if (show) {
getUsageInfo(); getUsageInfo();
loadBalance();
} }
</script> </script>
@ -70,6 +82,47 @@
align="start" align="start"
transition={(e) => fade(e, { duration: 100 })} transition={(e) => fade(e, { duration: 100 })}
> >
<!-- 余额显示 -->
<div class="px-2 py-2">
<BalanceDisplay compact={true} />
</div>
<hr class="border-gray-50 dark:border-gray-800 my-1 p-0" />
<!-- 计费中心入口 -->
<DropdownMenu.Item
as="a"
href="/billing"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition select-none"
on:click={async () => {
show = false;
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('计费中心')}</div>
</DropdownMenu.Item>
<hr class="border-gray-50 dark:border-gray-800 my-1 p-0" />
<DropdownMenu.Item <DropdownMenu.Item
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer" class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer"
on:click={async () => { on:click={async () => {

53
src/lib/stores/billing.ts Normal file
View file

@ -0,0 +1,53 @@
import { writable, derived, type Writable } from 'svelte/store';
import type { BalanceInfo, BillingLog, BillingStats } from '$lib/apis/billing';
// ========== 余额状态 ==========
export const balance: Writable<BalanceInfo | null> = writable(null);
// ========== 消费记录状态 ==========
export const billingLogs: Writable<BillingLog[]> = writable([]);
// ========== 统计数据状态 ==========
export const billingStats: Writable<BillingStats | null> = writable(null);
// ========== 派生状态:余额是否不足 ==========
export const isLowBalance = derived(balance, ($balance) => {
if (!$balance) return false;
// 余额单位1元 = 10000毫低于1元10000毫时警告
return $balance.balance < 10000;
});
// ========== 派生状态:账户是否冻结 ==========
export const isFrozen = derived(balance, ($balance) => {
if (!$balance) return false;
return $balance.billing_status === 'frozen';
});
// ========== 辅助函数:格式化金额 ==========
// amount 单位1元 = 10000毫
export const formatCurrency = (amount: number, isCost: boolean = false): string => {
// 转换为元:毫 / 10000
const yuan = amount / 10000;
// 费用支持显示 0.0001 精度4位小数
// 余额等显示 2位小数
if (isCost || yuan < 0.01) {
// 显示4位小数但去掉尾部的0
return `¥${yuan.toFixed(4).replace(/\.?0+$/, '')}`;
}
return `¥${yuan.toFixed(2)}`;
};
// ========== 辅助函数:格式化日期 ==========
// timestamp 单位:纳秒
export const formatDate = (timestamp: number): string => {
// 纳秒转换为毫秒:除以 1000000
return new Date(timestamp / 1000000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};

View file

@ -94,6 +94,9 @@ export const currentChatPage = writable(1);
export const isLastActiveTab = writable(true); export const isLastActiveTab = writable(true);
export const playingNotificationSound = writable(false); export const playingNotificationSound = writable(false);
// Billing
export { balance, billingLogs, billingStats, isLowBalance, isFrozen, formatCurrency, formatDate } from './billing';
export type Model = OpenAIModel | OllamaModel; export type Model = OpenAIModel | OllamaModel;
type BaseModel = { type BaseModel = {

View file

@ -0,0 +1,257 @@
<script lang="ts">
import { getContext } from 'svelte';
import { rechargeUser, setModelPricing, listModelPricing } from '$lib/apis/billing';
import { toast } from 'svelte-sonner';
import type { ModelPricing } from '$lib/apis/billing';
const i18n = getContext('i18n');
// 充值表单
let userId = '';
let amount = 0;
let remark = '';
let rechargeLoading = false;
// 定价管理
let modelId = '';
let inputPrice = 0;
let outputPrice = 0;
let pricingLoading = false;
let pricings: ModelPricing[] = [];
const handleRecharge = async () => {
if (!userId || amount <= 0) {
toast.error($i18n.t('请填写正确的用户ID和充值金额'));
return;
}
rechargeLoading = true;
try {
const result = await rechargeUser(localStorage.token, {
user_id: userId,
amount,
remark
});
toast.success($i18n.t('充值成功!余额: ') + result.balance);
// 重置表单
userId = '';
amount = 0;
remark = '';
} catch (error) {
toast.error($i18n.t('充值失败: ') + error.message);
} finally {
rechargeLoading = false;
}
};
const handleSetPricing = async () => {
if (!modelId || inputPrice < 0 || outputPrice < 0) {
toast.error('请填写正确的模型ID和价格');
return;
}
pricingLoading = true;
try {
await setModelPricing(localStorage.token, {
model_id: modelId,
input_price: inputPrice,
output_price: outputPrice
});
toast.success('定价设置成功');
// 重置表单并刷新列表
modelId = '';
inputPrice = 0;
outputPrice = 0;
loadPricings();
} catch (error) {
toast.error('设置定价失败: ' + error.message);
} finally {
pricingLoading = false;
}
};
const loadPricings = async () => {
try {
pricings = await listModelPricing();
} catch (error) {
console.error('加载定价列表失败:', error);
}
};
// 页面加载时获取定价列表
loadPricings();
</script>
<svelte:head>
<title>{$i18n.t('充值管理')} | CyberLover Admin</title>
</svelte:head>
<div class="admin-billing-page max-w-6xl mx-auto px-4 py-6 space-y-6">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{$i18n.t('充值管理')}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">管理用户充值和模型定价</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 用户充值表单 -->
<div class="recharge-form bg-white dark:bg-gray-850 rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold mb-4">{$i18n.t('用户充值')}</h2>
<div class="space-y-4">
<div class="form-group">
<label for="userId" class="block text-sm font-medium mb-2">
{$i18n.t('用户ID')}
</label>
<input
id="userId"
type="text"
bind:value={userId}
placeholder="请输入用户ID"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div class="form-group">
<label for="amount" class="block text-sm font-medium mb-2">
{$i18n.t('充值金额(元)')}
</label>
<input
id="amount"
type="number"
step="0.01"
min="0"
bind:value={amount}
placeholder="请输入充值金额"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div class="form-group">
<label for="remark" class="block text-sm font-medium mb-2">
{$i18n.t('备注')}
</label>
<textarea
id="remark"
bind:value={remark}
placeholder="充值备注(可选)"
rows="3"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
<button
class="w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded-lg font-medium transition"
on:click={handleRecharge}
disabled={rechargeLoading}
>
{rechargeLoading ? $i18n.t('充值中...') : $i18n.t('确认充值')}
</button>
</div>
</div>
<!-- 模型定价设置 -->
<div class="pricing-form bg-white dark:bg-gray-850 rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold mb-4">模型定价设置</h2>
<div class="space-y-4">
<div class="form-group">
<label for="modelId" class="block text-sm font-medium mb-2">模型ID</label>
<input
id="modelId"
type="text"
bind:value={modelId}
placeholder="例如: gpt-4o"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div class="form-group">
<label for="inputPrice" class="block text-sm font-medium mb-2">
输入价格(元/百万token
</label>
<input
id="inputPrice"
type="number"
step="0.01"
min="0"
bind:value={inputPrice}
placeholder="例如: 2.5"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div class="form-group">
<label for="outputPrice" class="block text-sm font-medium mb-2">
输出价格(元/百万token
</label>
<input
id="outputPrice"
type="number"
step="0.01"
min="0"
bind:value={outputPrice}
placeholder="例如: 10.0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
class="w-full px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white rounded-lg font-medium transition"
on:click={handleSetPricing}
disabled={pricingLoading}
>
{pricingLoading ? '设置中...' : '设置定价'}
</button>
</div>
</div>
</div>
<!-- 已配置的定价列表 -->
{#if pricings.length > 0}
<div class="pricing-list bg-white dark:bg-gray-850 rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold mb-4">已配置的模型定价</h2>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="px-4 py-3 text-left text-sm font-semibold">模型ID</th>
<th class="px-4 py-3 text-right text-sm font-semibold">输入价格</th>
<th class="px-4 py-3 text-right text-sm font-semibold">输出价格</th>
<th class="px-4 py-3 text-center text-sm font-semibold">来源</th>
</tr>
</thead>
<tbody>
{#each pricings as pricing}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-4 py-3">
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">
{pricing.model_id}
</code>
</td>
<td class="px-4 py-3 text-right">¥{pricing.input_price.toFixed(2)}</td>
<td class="px-4 py-3 text-right">¥{pricing.output_price.toFixed(2)}</td>
<td class="px-4 py-3 text-center">
<span
class="px-2 py-1 text-xs rounded"
class:bg-blue-100={pricing.source === 'database'}
class:text-blue-800={pricing.source === 'database'}
class:bg-gray-100={pricing.source === 'default'}
class:text-gray-800={pricing.source === 'default'}
>
{pricing.source === 'database' ? '数据库' : '默认'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { balance } from '$lib/stores';
import { getBalance } from '$lib/apis/billing';
import BalanceDisplay from '$lib/components/billing/BalanceDisplay.svelte';
import BillingLogsTable from '$lib/components/billing/BillingLogsTable.svelte';
import BillingStatsChart from '$lib/components/billing/BillingStatsChart.svelte';
import LowBalanceAlert from '$lib/components/billing/LowBalanceAlert.svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
onMount(async () => {
try {
const balanceInfo = await getBalance(localStorage.token);
balance.set(balanceInfo);
} catch (error) {
toast.error($i18n.t('加载余额失败: ') + error.message);
}
});
</script>
<svelte:head>
<title>{$i18n.t('计费中心')} | CyberLover</title>
</svelte:head>
<div class="billing-page max-w-7xl mx-auto px-4 py-6 space-y-6">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{$i18n.t('计费中心')}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
查看您的余额、消费记录和统计信息
</p>
</div>
<!-- 余额不足警告 -->
<LowBalanceAlert />
<!-- 余额卡片 -->
<div class="balance-section">
<BalanceDisplay />
</div>
<!-- 统计图表 -->
<div class="stats-section">
<BillingStatsChart />
</div>
<!-- 消费记录 -->
<div class="logs-section">
<BillingLogsTable />
</div>
</div>