Commit ad7da22d by renyizhao

添加按次计费 登录可选获取令牌

parent 60a32cda
...@@ -61,3 +61,22 @@ export function getRecharge(id) { ...@@ -61,3 +61,22 @@ export function getRecharge(id) {
params: { 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
})
}
...@@ -260,6 +260,18 @@ export const constantRoutes = [ ...@@ -260,6 +260,18 @@ export const constantRoutes = [
{ {
path: '/console', path: '/console',
component: ManageLayout, component: ManageLayout,
children: [
{
path: 'tokenLog',
component: () => import('@/views/console/tokenLog.vue'),
name: 'TokenLog',
meta: {title: 'token使用记录', icon: 'instock'}
}
]
},
{
path: '/console',
component: ManageLayout,
hidden: true, hidden: true,
children: [ children: [
{ {
......
...@@ -279,6 +279,7 @@ ...@@ -279,6 +279,7 @@
<!-- 底部固定:未登录或未获取令牌时显示 --> <!-- 底部固定:未登录或未获取令牌时显示 -->
<transition name="el-fade-in" v-if="tabActive === 999"> <transition name="el-fade-in" v-if="tabActive === 999">
<!-- 未获取令牌:引导用户获取 -->
<div v-if="!hasToken" class="bottom-cta"> <div v-if="!hasToken" class="bottom-cta">
<div class="cta-card"> <div class="cta-card">
<div class="cta-text"> <div class="cta-text">
...@@ -294,6 +295,20 @@ ...@@ -294,6 +295,20 @@
>{{ ctaButtonText }}</el-button> >{{ ctaButtonText }}</el-button>
</div> </div>
</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> </transition>
</div> </div>
...@@ -1064,6 +1079,24 @@ onMounted(() => { ...@@ -1064,6 +1079,24 @@ onMounted(() => {
0 0 0 1px rgba(46, 119, 227, 0.05); 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 { .cta-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
......
<template> <template>
<div class="model-card"> <div class="model-card">
<!-- 右上角:按量收费 --> <!-- 右上角:按量/按次收费 -->
<div class="tag-top">按量收费</div> <div class="tag-top">{{ billingTagText }}</div>
<!-- 头部:图标 + 模型名 --> <!-- 头部:图标 + 模型名 -->
<div class="card-header"> <div class="card-header">
...@@ -20,15 +20,22 @@ ...@@ -20,15 +20,22 @@
{{ model.description || '暂无描述' }} {{ model.description || '暂无描述' }}
</div> </div>
<!-- 底部:收费价格 tag + 输入/输出价格 --> <!-- 底部:收费价格 tag + 价格(按量/按次不同展示) -->
<div class="card-footer"> <div class="card-footer">
<div class="tag-price">收费价格</div> <div class="tag-price">{{ priceTagText }}</div>
<div class="price-text"> <div class="price-text">
<span>输入:<b>¥{{ formatPrice(model.inputPrice) }}</b></span> <!-- 按次计费:单次价格 -->
<span class="unit"> / 1M tokens</span> <template v-if="isPerCall">
<span class="divider"></span> <span>每次调用<b>¥{{ formatPrice(model.inputPrice) }}</b></span>
<span>输出:<b>¥{{ formatPrice(model.outputPrice) }}</b></span> </template>
<span class="unit"> / 1M tokens</span> <!-- 按量计费:输入/输出 -->
<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> </div>
</div> </div>
...@@ -57,6 +64,15 @@ const iconInfo = computed(() => { ...@@ -57,6 +64,15 @@ const iconInfo = computed(() => {
return { name: '', variant: 'Color' } 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) { function formatPrice(price) {
if (price === null || price === undefined) return '-' if (price === null || price === undefined) return '-'
const num = Number(price) const num = Number(price)
......
<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>
...@@ -63,6 +63,10 @@ ...@@ -63,6 +63,10 @@
</el-tab-pane> </el-tab-pane>
</el-tabs> </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;"> <div class="flex-align-center flex-space-between mb20" style="height: 32px;">
<el-checkbox v-show="activeName === 1" v-model="loginForm.rememberMe">记住密码</el-checkbox> <el-checkbox v-show="activeName === 1" v-model="loginForm.rememberMe">记住密码</el-checkbox>
<div v-if="register"> <div v-if="register">
...@@ -96,6 +100,7 @@ import {encrypt, decrypt} from '@/utils/jsencrypt' ...@@ -96,6 +100,7 @@ import {encrypt, decrypt} from '@/utils/jsencrypt'
import useUserStore from '@/store/modules/user' import useUserStore from '@/store/modules/user'
import {useRoute, useRouter} from 'vue-router' import {useRoute, useRouter} from 'vue-router'
import {getInfo, sendCode} from "@/api/login.js"; import {getInfo, sendCode} from "@/api/login.js";
import {getToken} from "@/api/console.js";
import {ElMessage} from "element-plus"; import {ElMessage} from "element-plus";
const userStore = useUserStore() const userStore = useUserStore()
...@@ -109,7 +114,8 @@ const loginForm = ref({ ...@@ -109,7 +114,8 @@ const loginForm = ref({
rememberMe: false, rememberMe: false,
code: '', code: '',
uuid: '', uuid: '',
phoneNumber: '' phoneNumber: '',
autoCreateNewApi: true
}) })
const loginRules = { const loginRules = {
...@@ -178,10 +184,19 @@ function handleClick() { ...@@ -178,10 +184,19 @@ function handleClick() {
proxy.$refs.loginRef.resetFields() proxy.$refs.loginRef.resetFields()
} }
function handleLogin() { async function handleLogin() {
proxy.$refs.loginRef.validate(valid => { proxy.$refs.loginRef.validate(async valid => {
if (valid) { if (!valid) return
loading.value = true 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) { if (activeName.value === 1) {
// 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码 // 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码
if (loginForm.value.rememberMe) { if (loginForm.value.rememberMe) {
...@@ -195,37 +210,30 @@ function handleLogin() { ...@@ -195,37 +210,30 @@ function handleLogin() {
Cookies.remove('rememberMe') Cookies.remove('rememberMe')
} }
// 调用action的登录方法 // 调用action的登录方法
userStore.login(loginForm.value).then(() => { await userStore.login(loginForm.value)
const query = route.query useUserStore().getInfo().then(res => {
const otherQueryParams = Object.keys(query).reduce((acc, cur) => { localStorage.setItem('avatar', res.data.avatar);
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
}) })
} else { } else {
userStore.cellPhoneLogin({mobile: loginForm.value.phoneNumber, code: loginForm.value.code}).then(res => { await userStore.cellPhoneLogin({
const query = route.query mobile: loginForm.value.phoneNumber,
const otherQueryParams = Object.keys(query).reduce((acc, cur) => { code: loginForm.value.code
if (cur !== 'redirect') {
acc[cur] = query[cur]
}
return acc
}, {})
useUserStore().getInfo().then(res => {
})
router.push({path: redirect.value || '/', query: otherQueryParams})
}).catch(() => {
loading.value = false
}) })
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() { ...@@ -236,6 +244,7 @@ function getCookie() {
const password = Cookies.get('password') const password = Cookies.get('password')
const rememberMe = Cookies.get('rememberMe') const rememberMe = Cookies.get('rememberMe')
loginForm.value = { loginForm.value = {
...loginForm.value,
mobile: mobile === undefined ? loginForm.value.mobile : mobile, mobile: mobile === undefined ? loginForm.value.mobile : mobile,
password: password === undefined ? loginForm.value.password : decrypt(password), password: password === undefined ? loginForm.value.password : decrypt(password),
rememberMe: rememberMe === undefined ? false : Boolean(rememberMe) rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment