mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
feat:前端计费页面
This commit is contained in:
parent
448d3697a4
commit
ecbf88ee2a
15 changed files with 2138 additions and 3 deletions
326
src/lib/apis/billing/index.ts
Normal file
326
src/lib/apis/billing/index.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 @@
|
|||
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
|
||||
{/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}
|
||||
<div class=" mt-1 mb-2 text-xs text-red-500">
|
||||
<Banner
|
||||
|
|
@ -293,6 +330,30 @@
|
|||
</div>
|
||||
</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
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
|
|
@ -364,8 +425,8 @@
|
|||
<img
|
||||
class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
|
||||
src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) ||
|
||||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
|
||||
user.profile_image_url.startsWith('data:')
|
||||
user?.profile_image_url?.startsWith('https://www.gravatar.com/avatar/') ||
|
||||
user?.profile_image_url?.startsWith('data:')
|
||||
? user.profile_image_url
|
||||
: `${WEBUI_BASE_URL}/user.png`}
|
||||
alt="user"
|
||||
|
|
@ -376,6 +437,29 @@
|
|||
</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">
|
||||
{dayjs(user.last_active_at * 1000).fromNow()}
|
||||
</td>
|
||||
|
|
|
|||
215
src/lib/components/admin/Users/UserList/BalanceModal.svelte
Normal file
215
src/lib/components/admin/Users/UserList/BalanceModal.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
90
src/lib/components/billing/BalanceDisplay.svelte
Normal file
90
src/lib/components/billing/BalanceDisplay.svelte
Normal 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>
|
||||
264
src/lib/components/billing/BillingLogsTable.svelte
Normal file
264
src/lib/components/billing/BillingLogsTable.svelte
Normal 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>
|
||||
235
src/lib/components/billing/BillingStatsChart.svelte
Normal file
235
src/lib/components/billing/BillingStatsChart.svelte
Normal 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>
|
||||
109
src/lib/components/billing/LowBalanceAlert.svelte
Normal file
109
src/lib/components/billing/LowBalanceAlert.svelte
Normal 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>
|
||||
83
src/lib/components/billing/TokenCostBadge.svelte
Normal file
83
src/lib/components/billing/TokenCostBadge.svelte
Normal 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>
|
||||
|
|
@ -8,8 +8,9 @@
|
|||
|
||||
import { getUsage } from '$lib/apis';
|
||||
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 ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
import Code from '$lib/components/icons/Code.svelte';
|
||||
import UserGroup from '$lib/components/icons/UserGroup.svelte';
|
||||
import SignOut from '$lib/components/icons/SignOut.svelte';
|
||||
import BalanceDisplay from '$lib/components/billing/BalanceDisplay.svelte';
|
||||
|
||||
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) {
|
||||
getUsageInfo();
|
||||
loadBalance();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -70,6 +82,47 @@
|
|||
align="start"
|
||||
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
|
||||
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 () => {
|
||||
|
|
|
|||
53
src/lib/stores/billing.ts
Normal file
53
src/lib/stores/billing.ts
Normal 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'
|
||||
});
|
||||
};
|
||||
|
|
@ -94,6 +94,9 @@ export const currentChatPage = writable(1);
|
|||
export const isLastActiveTab = writable(true);
|
||||
export const playingNotificationSound = writable(false);
|
||||
|
||||
// Billing
|
||||
export { balance, billingLogs, billingStats, isLowBalance, isFrozen, formatCurrency, formatDate } from './billing';
|
||||
|
||||
export type Model = OpenAIModel | OllamaModel;
|
||||
|
||||
type BaseModel = {
|
||||
|
|
|
|||
257
src/routes/(app)/admin/billing/+page.svelte
Normal file
257
src/routes/(app)/admin/billing/+page.svelte
Normal 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>
|
||||
53
src/routes/(app)/billing/+page.svelte
Normal file
53
src/routes/(app)/billing/+page.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue