Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
client
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
ad7da22d
authored
Jun 10, 2026
by
renyizhao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
添加按次计费 登录可选获取令牌
parent
60a32cda
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
517 additions
and
41 deletions
+517
-41
src/api/console.js
+19
-0
src/router/index.js
+12
-0
src/views/computingResource/resourceList.vue
+33
-0
src/views/console/components/model-card.vue
+25
-9
src/views/console/tokenLog.vue
+387
-0
src/views/login.vue
+41
-32
No files found.
src/api/console.js
View file @
ad7da22d
...
...
@@ -61,3 +61,22 @@ export function getRecharge(id) {
params
:
{
id
}
})
}
// 获取个人token日志(消费/充值)
// type: 1=充值 2=消费
export
function
getTokenLog
(
params
)
{
return
request
({
url
:
'/app/ai-log/self'
,
method
:
'get'
,
params
})
}
// 获取个人token日志统计(quota / rpm / tpm)
export
function
getTokenLogStat
(
params
)
{
return
request
({
url
:
'/app/ai-log/stat'
,
method
:
'get'
,
params
})
}
src/router/index.js
View file @
ad7da22d
...
...
@@ -260,6 +260,18 @@ export const constantRoutes = [
{
path
:
'/console'
,
component
:
ManageLayout
,
children
:
[
{
path
:
'tokenLog'
,
component
:
()
=>
import
(
'@/views/console/tokenLog.vue'
),
name
:
'TokenLog'
,
meta
:
{
title
:
'token使用记录'
,
icon
:
'instock'
}
}
]
},
{
path
:
'/console'
,
component
:
ManageLayout
,
hidden
:
true
,
children
:
[
{
...
...
src/views/computingResource/resourceList.vue
View file @
ad7da22d
...
...
@@ -279,6 +279,7 @@
<!-- 底部固定:未登录或未获取令牌时显示 -->
<transition
name=
"el-fade-in"
v-if=
"tabActive === 999"
>
<!-- 未获取令牌:引导用户获取 -->
<div
v-if=
"!hasToken"
class=
"bottom-cta"
>
<div
class=
"cta-card"
>
<div
class=
"cta-text"
>
...
...
@@ -294,6 +295,20 @@
>
{{ ctaButtonText }}
</el-button>
</div>
</div>
<!-- 已有令牌:提示可在个人中心查看 -->
<div
v-else
class=
"bottom-cta"
>
<div
class=
"cta-card cta-card--info"
>
<div
class=
"cta-text"
>
<div
class=
"cta-title"
>
已获取 API 令牌
</div>
<div
class=
"cta-subtitle"
>
控制台个人中心可查看令牌
</div>
</div>
<el-button
size=
"large"
class=
"cta-btn"
@
click=
"$router.push('/console/overview')"
>
前往查看
</el-button>
</div>
</div>
</transition>
</div>
...
...
@@ -1064,6 +1079,24 @@ onMounted(() => {
0
0
0
1px
rgba
(
46
,
119
,
227
,
0.05
);
}
//
已有令牌
:
信息提示样式
(
弱化主按钮
,
降低视觉权重
)
.cta-card--info
{
box-shadow
:
0
-2px
12px
rgba
(
0
,
0
,
0
,
0.03
),
0
6px
18px
rgba
(
46
,
119
,
227
,
0.06
),
0
0
0
1px
rgba
(
46
,
119
,
227
,
0.04
);
.cta-btn
{
color
:
var
(
--el-color-primary
);
background
:
#f0f5ff
;
border-color
:
#dbe5ff
;
}
.cta-btn
:hover
{
background
:
#e0ebff
;
}
}
.cta-text
{
flex
:
1
;
min-width
:
0
;
...
...
src/views/console/components/model-card.vue
View file @
ad7da22d
<
template
>
<div
class=
"model-card"
>
<!-- 右上角:按量收费 -->
<div
class=
"tag-top"
>
按量收费
</div>
<!-- 右上角:按量
/按次
收费 -->
<div
class=
"tag-top"
>
{{
billingTagText
}}
</div>
<!-- 头部:图标 + 模型名 -->
<div
class=
"card-header"
>
...
...
@@ -20,15 +20,22 @@
{{
model
.
description
||
'暂无描述'
}}
</div>
<!-- 底部:收费价格 tag +
输入/输出价格
-->
<!-- 底部:收费价格 tag +
价格(按量/按次不同展示)
-->
<div
class=
"card-footer"
>
<div
class=
"tag-price"
>
收费价格
</div>
<div
class=
"tag-price"
>
{{
priceTagText
}}
</div>
<div
class=
"price-text"
>
<span>
输入:
<b>
¥
{{
formatPrice
(
model
.
inputPrice
)
}}
</b></span>
<span
class=
"unit"
>
/ 1M tokens
</span>
<span
class=
"divider"
></span>
<span>
输出:
<b>
¥
{{
formatPrice
(
model
.
outputPrice
)
}}
</b></span>
<span
class=
"unit"
>
/ 1M tokens
</span>
<!-- 按次计费:单次价格 -->
<template
v-if=
"isPerCall"
>
<span>
每次调用
<b>
¥
{{
formatPrice
(
model
.
inputPrice
)
}}
</b></span>
</
template
>
<!-- 按量计费:输入/输出 -->
<
template
v-else
>
<span>
输入:
<b>
¥
{{
formatPrice
(
model
.
inputPrice
)
}}
</b></span>
<span
class=
"unit"
>
/ 1M tokens
</span>
<span
class=
"divider"
></span>
<span>
输出:
<b>
¥
{{
formatPrice
(
model
.
outputPrice
)
}}
</b></span>
<span
class=
"unit"
>
/ 1M tokens
</span>
</
template
>
</div>
</div>
</div>
...
...
@@ -57,6 +64,15 @@ const iconInfo = computed(() => {
return
{
name
:
''
,
variant
:
'Color'
}
})
// 是否按次计费(quotaType === 1)
const
isPerCall
=
computed
(()
=>
Number
(
props
.
model
?.
quotaType
)
===
1
)
// 右上角 tag 文案
const
billingTagText
=
computed
(()
=>
(
isPerCall
.
value
?
'按次收费'
:
'按量收费'
))
// 底部 tag 文案
const
priceTagText
=
computed
(()
=>
(
isPerCall
.
value
?
'单次价格'
:
'收费价格'
))
function
formatPrice
(
price
)
{
if
(
price
===
null
||
price
===
undefined
)
return
'-'
const
num
=
Number
(
price
)
...
...
src/views/console/tokenLog.vue
0 → 100644
View file @
ad7da22d
<
template
>
<div
class=
"app-container token-log-page"
>
<el-card
shadow=
"never"
class=
"filter-card"
>
<el-form
:inline=
"true"
:model=
"queryParams"
label-width=
"68px"
>
<el-form-item
label=
"时间"
>
<el-date-picker
v-model=
"dateRange"
type=
"daterange"
range-separator=
"至"
start-placeholder=
"开始日期"
end-placeholder=
"结束日期"
value-format=
"x"
:shortcuts=
"dateShortcuts"
style=
"width: 360px"
@
change=
"handleDateChange"
/>
</el-form-item>
<el-form-item
label=
"模型名称"
>
<el-input
v-model=
"queryParams.model_name"
placeholder=
"请输入模型名称"
clearable
style=
"width: 200px"
@
keyup
.
enter=
"handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button
type=
"primary"
icon=
"Search"
@
click=
"handleQuery"
>
搜索
</el-button>
<el-button
icon=
"Refresh"
@
click=
"resetQuery"
>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card
shadow=
"never"
class=
"table-card"
>
<!-- 统计卡片 -->
<div
class=
"stat-bar"
>
<div
class=
"stat-item"
>
<div
class=
"stat-label"
>
<el-icon><Coin
/></el-icon>
<span>
消费总额
</span>
</div>
<div
class=
"stat-value"
>
¥
<span
class=
"stat-num"
>
{{
formatQuotaToYuan
(
stat
.
quota
)
}}
</span>
</div>
</div>
</div>
<el-tabs
v-model=
"activeTab"
@
tab-change=
"handleTabChange"
>
<!-- 消费 -->
<el-tab-pane
label=
"消费"
name=
"consume"
>
<el-table
v-loading=
"loading"
:data=
"consumeList"
:max-height=
"620"
border
stripe
>
<el-table-column
label=
"时间"
align=
"center"
prop=
"created_at"
width=
"180"
>
<template
#
default=
"scope"
>
<span>
{{
formatTime
(
scope
.
row
.
created_at
)
}}
</span>
</
template
>
</el-table-column>
<el-table-column
label=
"模型"
align=
"center"
prop=
"model_name"
min-width=
"180"
/>
<el-table-column
label=
"输入"
align=
"center"
prop=
"prompt_tokens"
width=
"120"
>
<
template
#
default=
"scope"
>
<span>
{{
scope
.
row
.
prompt_tokens
||
0
}}
</span>
</
template
>
</el-table-column>
<el-table-column
label=
"输出"
align=
"center"
prop=
"completion_tokens"
width=
"120"
>
<
template
#
default=
"scope"
>
<span>
{{
scope
.
row
.
completion_tokens
||
0
}}
</span>
</
template
>
</el-table-column>
<el-table-column
label=
"花费"
align=
"center"
prop=
"quota"
width=
"120"
>
<
template
#
default=
"scope"
>
<span
class=
"cost"
>
¥
{{
formatQuotaToYuan
(
scope
.
row
.
quota
)
}}
</span>
</
template
>
</el-table-column>
<
template
#
empty
>
<el-empty
description=
"暂无消费记录"
/>
</
template
>
</el-table>
</el-tab-pane>
<!-- 充值 -->
<el-tab-pane
label=
"充值"
name=
"recharge"
>
<el-table
v-loading=
"loading"
:data=
"rechargeList"
:max-height=
"620"
border
stripe
>
<el-table-column
label=
"时间"
align=
"center"
prop=
"created_at"
width=
"220"
>
<
template
#
default=
"scope"
>
<span>
{{
formatTime
(
scope
.
row
.
created_at
)
}}
</span>
</
template
>
</el-table-column>
<el-table-column
label=
"充值金额"
align=
"center"
prop=
"amount"
min-width=
"180"
>
<
template
#
default=
"scope"
>
<span
class=
"recharge-amount"
>
¥
{{
extractRechargeAmount
(
scope
.
row
.
content
)
}}
</span>
</
template
>
</el-table-column>
<
template
#
empty
>
<el-empty
description=
"暂无充值记录"
/>
</
template
>
</el-table>
</el-tab-pane>
</el-tabs>
<pagination
v-show=
"total > 0"
:total=
"total"
v-model:page=
"queryParams.p"
v-model:limit=
"queryParams.page_size"
@
pagination=
"refresh"
/>
</el-card>
</div>
</template>
<
script
setup
name=
"TokenLog"
>
import
{
ref
,
reactive
,
onMounted
,
computed
}
from
'vue'
import
{
getTokenLog
,
getTokenLogStat
}
from
'@/api/console'
import
{
parseTime
}
from
'@/utils/ruoyi'
import
{
Coin
,
Wallet
}
from
'@element-plus/icons-vue'
const
{
proxy
}
=
getCurrentInstance
()
const
activeTab
=
ref
(
'consume'
)
const
loading
=
ref
(
false
)
const
consumeList
=
ref
([])
const
rechargeList
=
ref
([])
const
total
=
ref
(
0
)
const
stat
=
reactive
({
quota
:
0
})
// 近30天快捷选项
const
dateShortcuts
=
[
{
text
:
'近7天'
,
value
:
()
=>
{
const
end
=
new
Date
()
const
start
=
new
Date
()
start
.
setTime
(
start
.
getTime
()
-
3600
*
1000
*
24
*
7
)
return
[
start
,
end
]
}
},
{
text
:
'近30天'
,
value
:
()
=>
{
const
end
=
new
Date
()
const
start
=
new
Date
()
start
.
setTime
(
start
.
getTime
()
-
3600
*
1000
*
24
*
30
)
return
[
start
,
end
]
}
},
{
text
:
'近90天'
,
value
:
()
=>
{
const
end
=
new
Date
()
const
start
=
new
Date
()
start
.
setTime
(
start
.
getTime
()
-
3600
*
1000
*
24
*
90
)
return
[
start
,
end
]
}
}
]
// 默认近30天
function
getDefaultDateRange
()
{
const
end
=
Math
.
floor
(
Date
.
now
()
/
1000
)
const
start
=
end
-
3600
*
24
*
30
return
[
start
*
1000
,
end
*
1000
]
// 毫秒,给 el-date-picker 用
}
const
dateRange
=
ref
(
getDefaultDateRange
())
const
queryParams
=
reactive
({
p
:
1
,
page_size
:
10
,
type
:
2
,
// 2=消费
model_name
:
''
,
start_timestamp
:
''
,
end_timestamp
:
''
})
// 当前 tab 对应的 type
const
currentType
=
computed
(()
=>
(
activeTab
.
value
===
'consume'
?
2
:
1
))
/** 构造带时间戳的请求参数 */
function
buildParams
()
{
return
{
...
queryParams
,
type
:
currentType
.
value
,
start_timestamp
:
dateRange
.
value
&&
dateRange
.
value
[
0
]
?
Math
.
floor
(
dateRange
.
value
[
0
]
/
1000
)
:
''
,
end_timestamp
:
dateRange
.
value
&&
dateRange
.
value
[
1
]
?
Math
.
floor
(
dateRange
.
value
[
1
]
/
1000
)
:
''
}
}
/** 加载统计(消费/充值总额) */
function
loadStat
()
{
const
params
=
buildParams
()
getTokenLogStat
(
params
).
then
((
res
)
=>
{
const
data
=
res
.
data
||
{}
stat
.
quota
=
Number
(
data
.
quota
||
0
)
stat
.
rpm
=
Number
(
data
.
rpm
||
0
)
stat
.
tpm
=
Number
(
data
.
tpm
||
0
)
}).
catch
(()
=>
{
// 静默失败,保留旧值
})
}
/** 加载列表 */
function
loadList
()
{
loading
.
value
=
true
const
params
=
buildParams
()
queryParams
.
type
=
currentType
.
value
getTokenLog
(
params
).
then
((
res
)
=>
{
const
data
=
res
.
data
||
{}
const
items
=
data
.
items
||
[]
if
(
activeTab
.
value
===
'consume'
)
{
consumeList
.
value
=
items
}
else
{
rechargeList
.
value
=
items
}
total
.
value
=
data
.
total
||
0
loading
.
value
=
false
}).
catch
(()
=>
{
loading
.
value
=
false
})
}
/** 统一刷新:列表 + 统计 */
function
refresh
()
{
loadList
()
loadStat
()
}
/** 切换 tab */
function
handleTabChange
(
name
)
{
queryParams
.
p
=
1
queryParams
.
type
=
name
===
'consume'
?
2
:
1
refresh
()
}
/** 时间变化 */
function
handleDateChange
()
{
queryParams
.
p
=
1
refresh
()
}
/** 搜索 */
function
handleQuery
()
{
queryParams
.
p
=
1
refresh
()
}
/** 重置 */
function
resetQuery
()
{
dateRange
.
value
=
getDefaultDateRange
()
queryParams
.
model_name
=
''
queryParams
.
p
=
1
refresh
()
}
/** 格式化时间戳(秒) */
function
formatTime
(
ts
)
{
if
(
!
ts
)
return
'-'
return
parseTime
(
ts
*
1000
,
'{y}-{m}-{d} {h}:{i}:{s}'
)
}
/** quota 转 元(1元 = 500000 quota) */
function
formatQuotaToYuan
(
quota
)
{
if
(
quota
===
null
||
quota
===
undefined
)
return
'0.000000'
const
num
=
Number
(
quota
)
if
(
isNaN
(
num
))
return
'0.000000'
return
(
num
/
500000
).
toFixed
(
6
)
}
/** 从 content 提取充值金额
* 示例:"通过兑换码充值 ¥0.010000 额度,兑换码ID 8" -> "0.01"
*/
function
extractRechargeAmount
(
content
)
{
if
(
!
content
)
return
'0.00'
const
match
=
content
.
match
(
/¥
\s
*
(\d
+
(?:\.\d
+
)?)
/
)
if
(
!
match
)
return
'0.00'
return
Number
(
match
[
1
]).
toFixed
(
2
)
}
onMounted
(()
=>
{
refresh
()
})
</
script
>
<
style
lang=
"scss"
scoped
>
.token-log-page
{
:deep(.el-form-item)
{
margin-bottom
:
0
;
}
.filter-card
{
border-radius
:
8px
;
margin-bottom
:
16px
;
:deep(.el-card__body)
{
padding-bottom
:
0
;
}
}
.table-card
{
border-radius
:
8px
;
:deep(.el-tabs__header)
{
margin-bottom
:
16px
;
}
:deep
(
.el-tabs__nav-wrap
::after
)
{
height
:
1px
;
}
}
.cost
{
color
:
#f56c6c
;
font-weight
:
500
;
}
.recharge-amount
{
color
:
#67c23a
;
font-weight
:
500
;
}
.pagination-container
{
background-color
:
transparent
;
margin-top
:
16px
;
text-align
:
right
;
}
.stat-bar
{
display
:
flex
;
gap
:
16px
;
margin-bottom
:
20px
;
flex-wrap
:
wrap
;
}
.stat-item
{
flex
:
1
;
min-width
:
240px
;
padding
:
18px
22px
;
background
:
linear-gradient
(
135deg
,
#fff5f5
0%
,
#fff
100%
);
border
:
1px
solid
#fde2e2
;
border-radius
:
10px
;
transition
:
all
0.2s
;
}
.stat-label
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
14px
;
color
:
#606266
;
margin-bottom
:
10px
;
.el-icon
{
font-size
:
18px
;
color
:
#f56c6c
;
}
}
.stat-value
{
font-size
:
16px
;
color
:
#f56c6c
;
line-height
:
1.2
;
.stat-num
{
font-size
:
30px
;
font-weight
:
700
;
margin-left
:
2px
;
}
}
}
</
style
>
src/views/login.vue
View file @
ad7da22d
...
...
@@ -63,6 +63,10 @@
<
/el-tab-pane
>
<
/el-tabs
>
<
div
class
=
"mb20"
style
=
"height: 20px;"
>
<
el
-
checkbox
v
-
model
=
"loginForm.autoCreateNewApi"
>
若无
token
令牌,自动创建
<
/el-checkbox
>
<
/div
>
<
div
class
=
"flex-align-center flex-space-between mb20"
style
=
"height: 32px;"
>
<
el
-
checkbox
v
-
show
=
"activeName === 1"
v
-
model
=
"loginForm.rememberMe"
>
记住密码
<
/el-checkbox
>
<
div
v
-
if
=
"register"
>
...
...
@@ -96,6 +100,7 @@ import {encrypt, decrypt} from '@/utils/jsencrypt'
import
useUserStore
from
'@/store/modules/user'
import
{
useRoute
,
useRouter
}
from
'vue-router'
import
{
getInfo
,
sendCode
}
from
"@/api/login.js"
;
import
{
getToken
}
from
"@/api/console.js"
;
import
{
ElMessage
}
from
"element-plus"
;
const
userStore
=
useUserStore
()
...
...
@@ -109,7 +114,8 @@ const loginForm = ref({
rememberMe
:
false
,
code
:
''
,
uuid
:
''
,
phoneNumber
:
''
phoneNumber
:
''
,
autoCreateNewApi
:
true
}
)
const
loginRules
=
{
...
...
@@ -178,10 +184,19 @@ function handleClick() {
proxy
.
$refs
.
loginRef
.
resetFields
()
}
function
handleLogin
()
{
proxy
.
$refs
.
loginRef
.
validate
(
valid
=>
{
if
(
valid
)
{
loading
.
value
=
true
async
function
handleLogin
()
{
proxy
.
$refs
.
loginRef
.
validate
(
async
valid
=>
{
if
(
!
valid
)
return
loading
.
value
=
true
try
{
const
query
=
route
.
query
const
otherQueryParams
=
Object
.
keys
(
query
).
reduce
((
acc
,
cur
)
=>
{
if
(
cur
!==
'redirect'
)
{
acc
[
cur
]
=
query
[
cur
]
}
return
acc
}
,
{
}
)
if
(
activeName
.
value
===
1
)
{
// 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码
if
(
loginForm
.
value
.
rememberMe
)
{
...
...
@@ -195,37 +210,30 @@ function handleLogin() {
Cookies
.
remove
(
'rememberMe'
)
}
// 调用action的登录方法
userStore
.
login
(
loginForm
.
value
).
then
(()
=>
{
const
query
=
route
.
query
const
otherQueryParams
=
Object
.
keys
(
query
).
reduce
((
acc
,
cur
)
=>
{
if
(
cur
!==
'redirect'
)
{
acc
[
cur
]
=
query
[
cur
]
}
return
acc
}
,
{
}
)
useUserStore
().
getInfo
().
then
(
res
=>
{
localStorage
.
setItem
(
'avatar'
,
res
.
data
.
avatar
);
}
)
router
.
push
({
path
:
redirect
.
value
||
'/'
,
query
:
otherQueryParams
}
)
}
).
catch
(()
=>
{
loading
.
value
=
false
await
userStore
.
login
(
loginForm
.
value
)
useUserStore
().
getInfo
().
then
(
res
=>
{
localStorage
.
setItem
(
'avatar'
,
res
.
data
.
avatar
);
}
)
}
else
{
userStore
.
cellPhoneLogin
({
mobile
:
loginForm
.
value
.
phoneNumber
,
code
:
loginForm
.
value
.
code
}
).
then
(
res
=>
{
const
query
=
route
.
query
const
otherQueryParams
=
Object
.
keys
(
query
).
reduce
((
acc
,
cur
)
=>
{
if
(
cur
!==
'redirect'
)
{
acc
[
cur
]
=
query
[
cur
]
}
return
acc
}
,
{
}
)
useUserStore
().
getInfo
().
then
(
res
=>
{
}
)
router
.
push
({
path
:
redirect
.
value
||
'/'
,
query
:
otherQueryParams
}
)
}
).
catch
(()
=>
{
loading
.
value
=
false
await
userStore
.
cellPhoneLogin
({
mobile
:
loginForm
.
value
.
phoneNumber
,
code
:
loginForm
.
value
.
code
}
)
useUserStore
().
getInfo
().
then
(
res
=>
{
}
)
}
// 关键:等 New API 令牌落库后再跳转,避免首页第一时间出现"无 apiKey"状态
if
(
loginForm
.
value
.
autoCreateNewApi
)
{
try
{
await
getToken
()
}
catch
(
err
)
{
console
.
warn
(
'[login] 自动创建 New API token 失败,不影响登录'
,
err
)
}
}
router
.
push
({
path
:
redirect
.
value
||
'/'
,
query
:
otherQueryParams
}
)
}
catch
{
loading
.
value
=
false
}
}
)
}
...
...
@@ -236,6 +244,7 @@ function getCookie() {
const
password
=
Cookies
.
get
(
'password'
)
const
rememberMe
=
Cookies
.
get
(
'rememberMe'
)
loginForm
.
value
=
{
...
loginForm
.
value
,
mobile
:
mobile
===
undefined
?
loginForm
.
value
.
mobile
:
mobile
,
password
:
password
===
undefined
?
loginForm
.
value
.
password
:
decrypt
(
password
),
rememberMe
:
rememberMe
===
undefined
?
false
:
Boolean
(
rememberMe
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment