feat(avatar): add user token accounting
This commit is contained in:
@@ -131,9 +131,24 @@ export const deleteAvatar = (id: string) =>
|
||||
|
||||
// ==================== Token 管理 API ====================
|
||||
|
||||
export interface TokenBalance {
|
||||
balance: number
|
||||
totalGranted: number
|
||||
totalConsumed: number
|
||||
}
|
||||
|
||||
export interface TokenUsageSummary {
|
||||
avatarId: string
|
||||
source: string
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
// 获取 Token 余额
|
||||
export const getTokenBalance = () =>
|
||||
request.get<{ balance: number }>('/token/balance')
|
||||
request.get<TokenBalance>('/token/balance')
|
||||
|
||||
// 获取充值套餐
|
||||
export const getRechargePlans = () =>
|
||||
@@ -143,6 +158,10 @@ export const getRechargePlans = () =>
|
||||
export const chargeToken = (planId: string) =>
|
||||
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
||||
|
||||
// 按分身和使用场景汇总 Token 消耗
|
||||
export const getTokenUsage = () =>
|
||||
request.get<TokenUsageSummary[]>('/token/usage')
|
||||
|
||||
// ==================== 授权管理 API ====================
|
||||
|
||||
export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover'
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getUserProfile } from '@/api'
|
||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getTokenUsage, getUserProfile } from '@/api'
|
||||
import { unwrapListData } from '@/utils/avatar-page-data'
|
||||
|
||||
export const useAvatarStore = defineStore('avatar', () => {
|
||||
// 已创建的分身列表(来自后端)
|
||||
const avatars = ref<any[]>([])
|
||||
// 全局 Token 余额(来自后端)
|
||||
// 当前用户所有分身共享的 Token 账户
|
||||
const tokenBalance = ref<number>(0)
|
||||
const tokenConsumed = ref<number>(0)
|
||||
const tokenUsageByAvatar = ref<Record<string, number>>({})
|
||||
// 当前选中分身 id
|
||||
const currentAvatarId = ref<string | null>(null)
|
||||
// 会会用户资料(头像/昵称,来自会会接口)
|
||||
@@ -29,11 +31,24 @@ export const useAvatarStore = defineStore('avatar', () => {
|
||||
try {
|
||||
const res = await getTokenBalance()
|
||||
tokenBalance.value = (res as any)?.balance ?? 0
|
||||
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTokenUsage = async () => {
|
||||
try {
|
||||
const rows = await getTokenUsage()
|
||||
tokenUsageByAvatar.value = rows.reduce<Record<string, number>>((result, row) => {
|
||||
result[row.avatarId] = (result[row.avatarId] || 0) + row.totalTokens
|
||||
return result
|
||||
}, {})
|
||||
} catch (e) {
|
||||
console.error('加载 Token 用量失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取会会用户资料(头像/昵称)
|
||||
const loadUserProfile = async () => {
|
||||
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
|
||||
@@ -82,10 +97,13 @@ export const useAvatarStore = defineStore('avatar', () => {
|
||||
return {
|
||||
avatars,
|
||||
tokenBalance,
|
||||
tokenConsumed,
|
||||
tokenUsageByAvatar,
|
||||
currentAvatarId,
|
||||
userProfile,
|
||||
loadAvatars,
|
||||
loadTokenBalance,
|
||||
loadTokenUsage,
|
||||
loadUserProfile,
|
||||
setNativeProfile,
|
||||
addAvatar,
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<div class="token-info">
|
||||
<span class="token-label">Token 余额</span>
|
||||
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
||||
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
||||
</div>
|
||||
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
||||
</div>
|
||||
@@ -55,6 +56,7 @@
|
||||
<div class="avatar-details">
|
||||
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
|
||||
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
||||
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
@@ -95,7 +97,9 @@ const me = computed(() => userStore.user)
|
||||
|
||||
// 状态(来自 store / 后端)
|
||||
const tokenBalance = computed(() => avatarStore.tokenBalance)
|
||||
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
|
||||
const avatars = computed(() => avatarStore.avatars)
|
||||
const avatarTokenUsage = (id: string) => avatarStore.tokenUsageByAvatar[id] || 0
|
||||
|
||||
const shareToast = ref('')
|
||||
|
||||
@@ -174,6 +178,7 @@ onMounted(() => {
|
||||
userStore.loadFromStorage()
|
||||
avatarStore.loadAvatars()
|
||||
avatarStore.loadTokenBalance()
|
||||
avatarStore.loadTokenUsage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -316,6 +321,12 @@ onMounted(() => {
|
||||
color: #F97316;
|
||||
}
|
||||
|
||||
.token-used {
|
||||
margin-top: 3px;
|
||||
color: #A0A5B4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recharge-btn {
|
||||
padding: 8px 16px;
|
||||
background: #F97316;
|
||||
@@ -440,6 +451,13 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar-token-usage {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
color: #A0A5B4;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.avatar-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<span class="balance-label">当前余额</span>
|
||||
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
||||
<span class="balance-unit">Token</span>
|
||||
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -28,7 +29,7 @@
|
||||
@click="selectedPlan = plan"
|
||||
>
|
||||
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
||||
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div>
|
||||
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
|
||||
<div class="plan-unit">Token</div>
|
||||
<div class="plan-price">¥{{ plan.price }}</div>
|
||||
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
|
||||
@@ -83,7 +84,8 @@ import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
||||
const router = useRouter()
|
||||
|
||||
// 当前余额
|
||||
const currentBalance = ref<number>(1250)
|
||||
const currentBalance = ref<number>(0)
|
||||
const totalConsumed = ref<number>(0)
|
||||
|
||||
// 充值套餐
|
||||
const plans = ref<Array<{
|
||||
@@ -105,6 +107,7 @@ const loadData = async () => {
|
||||
try {
|
||||
const b: any = await getTokenBalance()
|
||||
currentBalance.value = b?.balance ?? 0
|
||||
totalConsumed.value = b?.totalConsumed ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
@@ -118,6 +121,10 @@ const loadData = async () => {
|
||||
|
||||
// 执行充值(写入后端)
|
||||
const charging = ref(false)
|
||||
const formatTokenAmount = (amount: number) => {
|
||||
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
|
||||
return amount.toLocaleString()
|
||||
}
|
||||
const doCharge = async () => {
|
||||
if (!selectedPlan.value || charging.value) return
|
||||
charging.value = true
|
||||
@@ -187,6 +194,7 @@ onMounted(() => {
|
||||
|
||||
.balance-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
@@ -211,6 +219,12 @@ onMounted(() => {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.balance-used {
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
/* 充值套餐 */
|
||||
.plans-section {
|
||||
padding: 0 20px 20px;
|
||||
|
||||
Reference in New Issue
Block a user