Compare commits

..

No commits in common. "32e7d2482cc7b24c22e6d3a9acf362c199d8b806" and "85c332828c536eaee2d0b0b2de47c000621505be" have entirely different histories.

20 changed files with 371 additions and 7164 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1069,10 +1069,10 @@ async def finance_billing_overview(ns=None):
include_sub = _parse_bool(ns.get('include_sub_reseller_customers'), True)
only_accounted = _parse_bool(ns.get('only_accounted'), False)
try:
max_bills = int(ns.get('max_bills', 50000) or 50000)
max_bills = int(ns.get('max_bills', 5000) or 5000)
except (TypeError, ValueError):
max_bills = 50000
max_bills = max(100, min(max_bills, 200000))
max_bills = 5000
max_bills = max(100, min(max_bills, 20000))
db = DBPools()
async with db.sqlorContext(DBNAME) as sor:
@ -1228,13 +1228,11 @@ async def finance_billing_overview(ns=None):
return {'status': True, 'msg': 'ok', 'data': data}
# _report = params_kw.get('report') or params_kw.get('api') or 'order_list'
_report = None
_report = params_kw.get('report') or params_kw.get('api') or 'order_list'
if _report in ('overview', 'billing_overview', 'finance_billing_overview'):
ret = await finance_billing_overview(params_kw)
elif _report in ('detail', 'order_detail'):
ret = await finance_order_report_detail(params_kw)
else:
ret = await finance_order_report(params_kw)
return ret
return ret

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
# model_management 可写入字段(不含 id、created_at、updated_at
_MODEL_FIELDS = (
'llmid', 'provider', 'model_name', 'display_name', 'model_logo', 'model_type',
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
'context_length', 'input_token_price', 'output_token_price',
'cache_hit_input_price', 'billing_method', 'billing_unit',
'capabilities', 'limitations', 'highlights', 'is_active',

View File

@ -5,7 +5,7 @@ def _escape(value):
# 客户侧可见字段(不含 listing_status、is_active 等运营字段)
_CUSTOMER_MODEL_COLUMNS = """
id, llmid, provider, model_name, display_name, model_logo, model_type,
id, llmid, provider, model_name, display_name, model_type,
context_length, input_token_price, output_token_price,
cache_hit_input_price, billing_method, billing_unit,
capabilities, limitations, highlights, description, sort_order,is_active, experience

View File

@ -1,6 +1,6 @@
# 可写入/更新的字段(不含 id、created_at、updated_at
_MODEL_FIELDS = (
'llmid', 'provider', 'model_name', 'display_name', 'model_logo', 'model_type',
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
'context_length', 'input_token_price', 'output_token_price',
'cache_hit_input_price', 'billing_method', 'billing_unit',
'capabilities', 'limitations', 'highlights', 'is_active',

View File

@ -1,6 +1,6 @@
# model_management 可写入字段(不含 id、created_at、updated_at
_MODEL_FIELDS = (
'llmid', 'provider', 'model_name', 'display_name', 'model_logo', 'model_type',
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
'context_length', 'input_token_price', 'output_token_price',
'cache_hit_input_price', 'billing_method', 'billing_unit',
'capabilities', 'limitations', 'highlights', 'is_active',

View File

@ -1,26 +1,3 @@
async def get_user_role(ns={}):
sor = ns['sor']
ns['del_flg'] = '0'
res_role = await sor.R('userrole', ns)
if res_role:
user_role = res_role[0]
else:
return {
'status': False,
'msg': 'userrole table, user id can not find...',
}
roleid = user_role.get('roleid')
role_name = await sor.R('role', {'id': roleid})
if role_name:
role = role_name[0].get('role')
else:
return {
'status': False,
'msg': 'role table, can not get role name',
}
return role
def _escape(value):
if value is None:
return None
@ -205,74 +182,18 @@ async def _enrich_usage_rows(sor, rows):
return items
async def _resolve_scope_orgid(sor, user_orgid, user_role):
"""
解析报表统计范围的机构 id。
运营/运营管理员users.orgid 即所在机构 id。
其他管理员users.orgid 为用户机构 id所在机构为 organization.parentid。
"""
if user_role in ('运营', '运营管理员'):
return user_orgid
org_rows = await sor.R('organization', {'id': user_orgid, 'del_flg': '0'})
if org_rows and org_rows[0].get('parentid'):
return org_rows[0]['parentid']
return user_orgid
async def _fetch_customer_users(sor, institution_orgid, customerid=None):
"""
获取机构下辖用户。
普通用户users.orgid = organization.idorganization.parentid = 机构 id。
管理/运营users.orgid 直接等于机构 id。
邀请注册可能存在二级 organizationparentid 指向上级客户机构)。
"""
inst_esc = _escape(institution_orgid)
async def _fetch_customer_users(sor, orgid, customerid=None):
"""获取机构下客户及其用户映射。"""
org_rows = await sor.R('organization', {'parentid': orgid, 'del_flg': '0'})
if customerid:
cid_esc = _escape(customerid)
if customerid == institution_orgid:
user_scope = """(
o.parentid = '%s'
OR o.parentid IN (
SELECT id FROM organization WHERE parentid = '%s' AND del_flg = '0'
)
OR u.orgid = '%s'
)""" % (inst_esc, inst_esc, inst_esc)
else:
user_scope = "(u.orgid = '%s' OR o.parentid = '%s')" % (cid_esc, cid_esc)
else:
user_scope = """(
o.parentid = '%s'
OR o.parentid IN (
SELECT id FROM organization WHERE parentid = '%s' AND del_flg = '0'
)
OR u.orgid = '%s'
)""" % (inst_esc, inst_esc, inst_esc)
org_rows = [row for row in org_rows if row.get('id') == customerid]
org_map = {row['id']: row for row in org_rows}
sql = """
SELECT u.id, u.username, u.name, u.orgid, o.orgname, o.parentid AS org_parentid
FROM users u
INNER JOIN organization o ON u.orgid = o.id AND o.del_flg = '0'
WHERE u.del_flg = '0' AND %s
""" % user_scope
rows = await sor.sqlExe(sql, {})
org_map = {}
user_map = {}
for row in rows:
uid = row['id']
oid = row.get('orgid')
user_map[uid] = {
'id': uid,
'username': row.get('username'),
'name': row.get('name'),
'orgid': oid,
}
if oid and oid not in org_map:
org_map[oid] = {
'id': oid,
'orgname': row.get('orgname'),
'parentid': row.get('org_parentid'),
}
for oid in org_map:
user_rows = await sor.R('users', {'orgid': oid, 'del_flg': '0'})
for user in user_rows:
user_map[user['id']] = user
return org_map, user_map
@ -455,6 +376,7 @@ async def model_usage_user_report(ns={}):
except Exception as e:
return {'status': False, 'msg': '查询失败, %s' % str(e)}
async def model_usage_admin_report(ns={}):
"""
管理员查看当前机构下所有客户的模型使用汇总。
@ -491,8 +413,8 @@ async def model_usage_admin_report(ns={}):
if group_by and group_by not in ('hour', 'day', 'week'):
return {'status': False, 'msg': 'group_by 仅支持 hour / day / week'}
page_size = int(ns.get('page_size')) if ns.get('page_size') else 20
current_page = int(ns.get('current_page')) if ns.get('current_page') else 1
page_size = int(ns.get('page_size', 20))
current_page = int(ns.get('current_page', 1))
offset = (current_page - 1) * page_size
db = DBPools()
@ -502,12 +424,11 @@ async def model_usage_admin_report(ns={}):
if not user_rows:
return {'status': False, 'msg': '用户不存在'}
user_orgid = user_rows[0].get('orgid')
orgid = user_rows[0].get('orgid')
user_role = await get_user_role({'userid': userid, 'sor': sor})
if user_role not in ('管理员', '运营', '运营管理员'):
return {'status': False, 'msg': '无权限,仅机构管理员可查看'}
orgid = await _resolve_scope_orgid(sor, user_orgid, user_role)
org_map, user_map = await _fetch_customer_users(sor, orgid, customerid)
user_ids = list(user_map.keys())
if not user_ids:

View File

@ -316,8 +316,8 @@ async def model_usage_user_report(ns={}):
if group_by and group_by not in ('hour', 'day', 'week'):
return {'status': False, 'msg': 'group_by 仅支持 hour / day / week'}
page_size = int(ns.get('page_size')) if ns.get('page_size') else 20
current_page = int(ns.get('current_page')) if ns.get('current_page') else 1
page_size = int(ns.get('page_size', 20))
current_page = int(ns.get('current_page', 1))
offset = (current_page - 1) * page_size
db = DBPools()

View File

@ -1,17 +0,0 @@
import request from "@/utils/request";
// 获取财务概览
export const reqFinancialOverview = (params = {}) => {
return request({
url: '/bill/finance_order_report_overview.dspy',
method: 'get',
params
})
}
// 计费统计
export const reqBillingStatistics = (params = {}) => {
return request({
url: '/bill/finance_order_report.dspy',
method: 'get',
params
})
}

View File

@ -126,25 +126,19 @@ export const reqTokenUsage = (params = {}) => {
// 模型信息配置添加
export const reqModelInfoConfig = (params = {}) => {
const isFormData = params instanceof FormData
return request({
url: '/cntoai/model_management_add.dspy',
method: isFormData ? 'post' : 'get',
params: isFormData ? undefined : params,
data: isFormData ? params : undefined,
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
method: 'get',
params
})
}
// 模型信息配置编辑(编辑时需要额外传 id
export const reqModelInfoConfigEdit = (params = {}) => {
const isFormData = params instanceof FormData
return request({
url: '/cntoai/model_management_update.dspy',
method: isFormData ? 'post' : 'get',
params: isFormData ? undefined : params,
data: isFormData ? params : undefined,
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
method: 'get',
params
})
}
@ -155,13 +149,4 @@ export const reqModelInfoConfigList = (params = {}) => {
method: 'get',
params
})
}
// 运营报表
export const reqOperationReport = (params = {}) => {
return request({
url: '/cntoai/model_usage_admin_report.dspy',
method: 'post',
params
})
}

View File

@ -436,44 +436,6 @@ export const constantRoutes = [
* 需要根据用户角色动态加载的路由
*/
export const asyncRoutes = [
// 财务——财务概览
{
path: "/financialOverview",
component: Layout,
meta: {
title: "财务概览",
fullPath: "/financialOverview",
noCache: true,
icon: "el-icon-data-analysis",
roles: ["财务"]
},
children: [
{
path: "",
component: () => import('@/views/finance/financialOverview/index.vue'),
name: 'FinancialOverview',
meta: {
title: "财务概览",
fullPath: "/financialOverview",
noCache: true,
roles: ["财务"]
}
},
// 计费统计
{
path: "billingStatistics",
component: () => import('@/views/finance/billingStatistics/index.vue'),
name: 'BillingStatistics',
meta: {
title: "计费统计",
fullPath: "/financialOverview/billingStatistics",
noCache: false,
roles: ["财务"]
}
}
]
},
// 运营——模型管理
{
path: "/modelManagement",
@ -577,29 +539,29 @@ export const asyncRoutes = [
]
},
// Token用量 - 一级菜单(所有登录用户都能看到)
{
path: "/tokenUsage",
component: Layout,
meta: {
title: "Token用量",
fullPath: "/tokenUsage",
noCache: true,
icon: "el-icon-data-line"
},
children: [
{
path: "",
component: () => import('@/views/tokenUsage/index.vue'),
name: 'TokenUsage',
meta: {
title: "Token用量",
fullPath: "/tokenUsage",
noCache: true,
icon: "el-icon-data-line"
}
},
]
},
// {
// path: "/tokenUsage",
// component: Layout,
// meta: {
// title: "Token用量",
// fullPath: "/tokenUsage",
// noCache: true,
// icon: "el-icon-data-line"
// },
// children: [
// {
// path: "",
// component: () => import('@/views/tokenUsage/index.vue'),
// name: 'TokenUsage',
// meta: {
// title: "Token用量",
// fullPath: "/tokenUsage",
// noCache: true,
// icon: "el-icon-data-line"
// }
// },
// ]
// },
// 模型体验
{
path: "/modelExperience",
@ -2087,19 +2049,9 @@ export const asyncRoutes = [
},
{
path: "/finance", component: Layout, redirect: "/finance/financialOverview", meta: {
path: "/finance", component: Layout, redirect: "/finance", meta: {
title: "财务", icon: "el-icon-s-data", noCache: true, fullPath: "/finance",
}, children: [{
path: "financialOverview",
component: () => import("@/views/finance/financialOverview"),
name: "FinancialOverview",
meta: {
title: "财务概览",
fullPath: "/finance/financialOverview",
icon: "el-icon-data-analysis",
roles: ["财务"]
},
}, {
path: "supplierSettlement",
hidden: true,
component: () => import(

View File

@ -6,7 +6,6 @@ const MOBILE_UA_REGEXP = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Ope
// 项目里用到的固定角色名,集中放这里,避免代码里到处写字符串。
const CUSTOMER_ROLE = '客户';
const OPERATION_ROLE = '运营';
const FINANCE_ROLE = '财务';
// 这个用户能看到订单管理里的特殊子菜单,比如历史订单和订单详情。
const SPECIAL_ORDER_USER = 'ZhipuHZ';
@ -20,9 +19,6 @@ const COMMON_ROUTE_PATHS = ['/product', '/tokenManagement', '/tokenUsage', '/mod
// 运营角色需要额外补出来的菜单。
const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/modelInfoConfig', '/operationReport'];
// 财务角色需要额外补出来的菜单。
const FINANCE_EXTRA_ROUTE_PATHS = ['/financialOverview'];
// 普通客户账号默认要补出来的基础菜单。
const BASE_USER_ROUTE_PATHS = ['/orderManagement', '/resourceManagement'];
@ -340,15 +336,6 @@ function addOperationRoutes(accessedRoutes, routes, userRoles = [], deviceType =
return appendMissingRoutes(accessedRoutes, getRoutesByPath(routes, OPERATION_EXTRA_ROUTE_PATHS));
}
// 财务角色额外补财务菜单,目前只在 PC 端展示。
function addFinanceRoutes(accessedRoutes, routes, userRoles = [], deviceType = 'pc') {
if (!userRoles.includes(FINANCE_ROLE) || deviceType !== 'pc') {
return accessedRoutes;
}
return appendMissingRoutes(accessedRoutes, getRoutesByPath(routes, FINANCE_EXTRA_ROUTE_PATHS));
}
// token市集是公共菜单所有登录用户都要能看到。
function addCommonRoutes(accessedRoutes, routes, deviceType = 'pc') {
const commonRoutes = getRoutesByPath(routes, COMMON_ROUTE_PATHS)
@ -512,9 +499,6 @@ const actions = {
// 6. 运营角色额外补模型管理。
accessedRoutes = addOperationRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType);
// 6.1 财务角色额外补财务菜单。
accessedRoutes = addFinanceRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType);
// 7. 最后处理订单管理里的特殊子菜单权限。
accessedRoutes = filterOrderChildrenByUser(accessedRoutes, username);

View File

@ -1,669 +0,0 @@
<template>
<div class="billing-statistics-page">
<div class="page-wrap">
<div class="page-head">
<div>
<h2>
财务概览
<span>{{ billingData.is_business_owner ? '业主机构' : '分销机构' }}</span>
</h2>
<p>核算机构<b>{{ displayValue(billingData.accounting_orgname) }}</b></p>
<p>机构编号{{ displayValue(billingData.accounting_orgid) }}</p>
</div>
<div class="view-info">
<strong>管理员视图</strong>
<small>生成于 2026/5/29 17:00:06</small>
</div>
</div>
<div class="summary-grid">
<div class="summary-card blue">
<i></i>
<span>客户支付总额</span>
<strong>{{ money(summary.customer_pay_total) }}<em></em></strong>
<p>customer_pay_total</p>
</div>
<div class="summary-card green">
<i></i>
<span>利润总额</span>
<strong>{{ money(summary.profit_total) }}<em></em></strong>
<p>profit_total</p>
</div>
<div class="summary-card amber">
<i></i>
<span>上游结算总额</span>
<strong>{{ money(summary.settle_upstream_total) }}<em></em></strong>
<p>settle_upstream_total</p>
</div>
</div>
<section class="bill-panel">
<div class="panel-head">
<h3>账单明细</h3>
<span> {{ billingData.current_page || 1 }} · 每页 {{ billingData.page_size || query.page_size }} · {{ billingData.total_count || 0 }} </span>
</div>
<table class="bill-table">
<thead>
<tr>
<th>账单日期</th>
<th>客户</th>
<th>产品</th>
<th>供应商</th>
<th>操作</th>
<th class="num">数量</th>
<th class="num">客户支付</th>
<th class="num">利润</th>
<th class="num">上游结算</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="item in billItems" :key="item.bill_id" class="clickable-row" @click="openBillDetail(item)">
<td>{{ displayValue(item.bill_date) }}</td>
<td>{{ displayValue(item.customer && item.customer.name) }}</td>
<td>{{ displayValue(item.product && (item.product.name || item.product.servicename)) }}</td>
<td>{{ displayValue(item.provider && item.provider.name) }}</td>
<td><span class="op">{{ displayValue(item.business_op) }}</span></td>
<td class="num">{{ formatNumber(item.quantity) }}</td>
<td class="num" :class="amountClass(item.pricing && item.pricing.customer_pay_amount)">
{{ money(item.pricing && item.pricing.customer_pay_amount) }}
</td>
<td class="num" :class="amountClass(item.finance && item.finance.profit_amount)">
{{ money(item.finance && item.finance.profit_amount) }}
</td>
<td class="num" :class="amountClass(item.finance && item.finance.settle_upstream_amount)">
{{ money(item.finance && item.finance.settle_upstream_amount) }}
</td>
<td class="link" @click.stop="openBillDetail(item)">查看 </td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="6">合计财务概览</td>
<td class="num">{{ money(summary.customer_pay_total) }}</td>
<td class="num">{{ money(summary.profit_total) }}</td>
<td class="num">{{ money(summary.settle_upstream_total) }}</td>
<td></td>
</tr>
</tfoot>
</table>
<div class="pagination-wrap">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="billingData.total_count || 0"
:current-page="query.current_page"
:page-size="query.page_size"
:page-sizes="[3, 10, 20, 50]"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</section>
<p class="foot-note">
当前展示 {{ billItems.length }} 条明细点击任意明细行查看详情
</p>
</div>
<el-dialog
:visible.sync="detailVisible"
width="920px"
custom-class="billing-detail-dialog"
append-to-body
:show-close="false"
>
<div v-if="currentBill" class="detail-content">
<button class="detail-close" @click="detailVisible = false">×</button>
<div class="detail-head">
<h3>{{ displayValue(currentBill.product && currentBill.product.name) }} · {{ displayValue(currentBill.bill_date) }}</h3>
<p>
账单号 {{ displayValue(currentBill.bill_id) }}
<span>订单号 {{ displayValue(currentBill.order_id) }}</span>
</p>
</div>
<div class="section-title">基本信息</div>
<div class="info-grid">
<div><span>账单日期</span><b>{{ displayValue(currentBill.bill_date) }}</b></div>
<div><span>下单时间</span><b>{{ displayValue(currentBill.order_date) }}</b></div>
<div><span>业务操作</span><b>{{ displayValue(currentBill.business_op) }}</b></div>
<div><span>账单状态</span><b>{{ billStateText(currentBill) }}</b></div>
<div><span>客户</span><b>{{ displayValue(currentBill.customer && currentBill.customer.name) }}{{ currentBill.customer && currentBill.customer.is_direct_customer ? '(直客)' : '' }}</b></div>
<div><span>产品</span><b>{{ displayValue(currentBill.product && currentBill.product.name) }}</b></div>
<div><span>供应商</span><b>{{ displayValue(currentBill.provider && currentBill.provider.name) }}</b></div>
<div><span>数量</span><b>{{ formatNumber(currentBill.quantity) }}</b></div>
</div>
<div class="section-title">价格信息</div>
<div class="info-grid">
<div><span>目录金额</span><b>{{ money(currentBill.pricing && currentBill.pricing.catalog_amount) }}</b></div>
<div><span>标价单价</span><b>{{ money(currentBill.pricing && currentBill.pricing.list_price_unit) }}</b></div>
<div><span>订单折扣</span><b>{{ discountText(currentBill.pricing && currentBill.pricing.order_discount) }}</b></div>
<div><span>成交单价</span><b>{{ money(currentBill.pricing && currentBill.pricing.order_unit_price) }}</b></div>
<div><span>客户支付金额</span><b>{{ money(currentBill.pricing && currentBill.pricing.customer_pay_amount) }}</b></div>
</div>
<div class="section-title">协议 / 折扣</div>
<div class="info-grid">
<div><span>上游销售模式</span><b>{{ displayValue(currentBill.protocol && currentBill.protocol.parent_salemode) }}</b></div>
<div><span>上游给我方折扣</span><b>{{ discountText(currentBill.protocol && currentBill.protocol.parent_discount_to_us) }}</b></div>
<div><span>我方对客户模式</span><b>{{ displayValue(currentBill.protocol && currentBill.protocol.our_salemode_to_customer) }}</b></div>
<div><span>我方对客户折扣</span><b>{{ discountText(currentBill.protocol && currentBill.protocol.our_discount_to_customer) }}</b></div>
<div><span>我方作为分销给上游折扣</span><b>{{ discountText(currentBill.protocol && currentBill.protocol.our_discount_as_reseller_to_parent) }}</b></div>
</div>
<div class="section-title">财务结算</div>
<div class="info-grid">
<div><span>利润金额</span><b>{{ money(currentBill.finance && currentBill.finance.profit_amount) }}</b></div>
<div><span>上游结算金额</span><b>{{ money(currentBill.finance && currentBill.finance.settle_upstream_amount) }}</b></div>
<div><span>结算类型</span><b>{{ displayValue(currentBill.finance && currentBill.finance.settle_upstream_type) }}</b></div>
<div><span>结算对象</span><b>{{ displayValue(currentBill.finance && currentBill.finance.settle_upstream_target && currentBill.finance.settle_upstream_target.name) }}</b></div>
<div><span>金额来源</span><b>{{ displayValue(currentBill.finance && currentBill.finance.amount_source) }}</b></div>
</div>
<div class="section-title">记账分录{{ billLegs.length }} </div>
<table class="legs-table">
<thead>
<tr>
<th>科目</th>
<th>方向</th>
<th>参与方类型</th>
<th>核算机构</th>
<th class="num">金额</th>
</tr>
</thead>
<tbody>
<tr v-for="(leg, index) in billLegs" :key="`${leg.subjectname}-${index}`">
<td>{{ displayValue(leg.subjectname) }}</td>
<td><span class="dir-tag" :class="leg.accounting_dir === '贷' ? 'credit' : 'debit'">{{ displayValue(leg.accounting_dir) }}</span></td>
<td>{{ displayValue(leg.participanttype) }}</td>
<td>{{ displayValue(leg.accounting_orgid) }}</td>
<td class="num">{{ money(leg.amount) }}</td>
</tr>
</tbody>
</table>
</div>
</el-dialog>
</div>
</template>
<script>
import { reqBillingStatistics } from '@/api/FinancialOverview/FinancialOverview'
export default {
name: 'BillingStatistics',
data() {
return {
orgid: '',
billingData: {},
query: {
current_page: 1,
page_size: 10,
only_accounted: true
},
detailVisible: false,
currentBill: null
}
},
computed: {
summary() {
return this.billingData.summary || {}
},
billItems() {
return this.billingData.items || []
},
billLegs() {
return (this.currentBill && this.currentBill.bill_detail_legs) || []
}
},
created() {
this.getData()
},
methods: {
getOrgId() {
const orgid = this.$route.query.orgid || sessionStorage.getItem('orgid') || localStorage.getItem('orgid') || ''
this.orgid = orgid
return orgid
},
async getData() {
const orgid = this.getOrgId()
const query = {
accounting_orgid:orgid,
current_page: this.query.current_page,
page_size: this.query.page_size,
only_accounted: this.query.only_accounted
}
const res = await reqBillingStatistics(query)
if (res && res.status === true) {
this.billingData = res.data || {}
this.query.current_page = Number(this.billingData.current_page || this.query.current_page)
this.query.page_size = Number(this.billingData.page_size || this.query.page_size)
}
},
handleSizeChange(size) {
this.query.page_size = size
this.query.current_page = 1
this.getData()
},
handleCurrentChange(page) {
this.query.current_page = page
this.getData()
},
openBillDetail(item) {
this.currentBill = item
this.detailVisible = true
},
billStateText(item) {
const state = item.bill_state === '1' ? '已生效' : this.displayValue(item.bill_state)
return `${state} · ${item.accounted ? '已入账' : '未入账'}`
},
discountText(value) {
if (value === undefined || value === null || value === '') return '-'
return `${Number(value) * 100}%`
},
money(value) {
return `¥${Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`
},
formatNumber(value) {
return Number(value || 0).toLocaleString('zh-CN')
},
displayValue(value) {
if (value === undefined || value === null || value === '') return '-'
return String(value)
},
amountClass(value) {
return Number(value || 0) > 0 ? 'green-text' : ''
}
}
}
</script>
<style scoped lang="less">
.billing-statistics-page {
// min-height: 100vh;
height: 100%;
padding: 18px 20px 36px;
// padding: 28px 24px 60px;
color: #1f2733;
background: #f4f6fb;
}
.page-wrap {
// max-width: 1180px;
margin: 0 auto;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
font-size: 22px;
font-weight: 700;
span {
padding: 2px 10px;
color: #3a6df0;
font-size: 12px;
font-weight: 600;
background: #eef3ff;
border-radius: 999px;
}
}
p {
margin: 0 0 4px;
color: #6b7686;
font-size: 13px;
}
}
.view-info {
color: #6b7686;
font-size: 13px;
text-align: right;
strong {
display: block;
font-weight: 400;
}
small {
display: block;
margin-top: 4px;
font-size: 12px;
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-bottom: 28px;
}
.summary-card {
position: relative;
min-height: 116px;
padding: 20px 22px;
overflow: hidden;
background: #fff;
border: 1px solid #e6eaf1;
border-radius: 14px;
box-shadow: 0 1px 3px rgba(20, 30, 55, 0.06), 0 8px 24px rgba(20, 30, 55, 0.05);
i {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
}
span {
display: block;
margin-bottom: 10px;
color: #6b7686;
font-size: 13px;
}
strong {
display: block;
font-size: 30px;
line-height: 1.15;
}
em {
margin-left: 4px;
color: #6b7686;
font-size: 14px;
font-style: normal;
font-weight: 500;
}
p {
margin: 8px 0 0;
color: #98a2b3;
font-size: 12px;
}
&.blue {
i { background: #3a6df0; }
strong { color: #3a6df0; }
}
&.green {
i { background: #18a058; }
strong { color: #18a058; }
}
&.amber {
i { background: #d08e18; }
strong { color: #d08e18; }
}
}
.bill-panel {
overflow: hidden;
background: #fff;
border: 1px solid #e6eaf1;
border-radius: 14px;
box-shadow: 0 1px 3px rgba(20, 30, 55, 0.06), 0 8px 24px rgba(20, 30, 55, 0.05);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e6eaf1;
h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
span {
color: #6b7686;
font-size: 12px;
}
}
.bill-table {
width: 100%;
min-width: 880px;
border-collapse: collapse;
font-size: 13px;
th {
padding: 12px 16px;
color: #6b7686;
font-weight: 600;
text-align: left;
white-space: nowrap;
background: #fafbfe;
border-bottom: 1px solid #e6eaf1;
}
td {
padding: 12px 16px;
color: #1f2733;
white-space: nowrap;
border-bottom: 1px solid #e6eaf1;
}
tfoot td {
font-weight: 700;
background: #fafbfe;
border-top: 2px solid #e6eaf1;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding: 14px 16px 16px;
border-top: 1px solid #eef2f7;
}
.clickable-row {
cursor: pointer;
&:hover td {
background: #f0f5ff;
}
}
.op {
display: inline-block;
padding: 2px 8px;
color: #18a058;
font-size: 11px;
font-weight: 600;
background: #e9f6ee;
border-radius: 6px;
}
.green-text {
color: #18a058 !important;
font-weight: 600;
}
.link {
color: #6b7686;
text-align: right;
}
/deep/ .billing-detail-dialog {
overflow: hidden;
border-radius: 14px;
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
.detail-content {
position: relative;
padding: 22px 28px 24px;
overflow: visible;
background: #fff;
}
.detail-close {
position: absolute;
top: 18px;
right: 20px;
width: 28px;
height: 28px;
color: #98a2b3;
font-size: 16px;
line-height: 26px;
background: #f4f6fb;
border: 0;
border-radius: 7px;
cursor: pointer;
}
.detail-head {
padding-right: 38px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid #eef2f7;
h3 {
margin: 0 0 6px;
color: #1f2733;
font-size: 16px;
font-weight: 700;
}
p {
margin: 0;
color: #98a2b3;
font-size: 12px;
span {
margin-left: 10px;
}
}
}
.section-title {
margin: 14px 0 8px;
color: #344054;
font-size: 13px;
font-weight: 700;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 28px;
div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 27px;
border-bottom: 1px solid #eef2f7;
}
span {
color: #667085;
font-size: 12px;
}
b {
color: #1f2733;
font-size: 12px;
font-weight: 700;
text-align: right;
}
}
.legs-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th,
td {
padding: 7px 10px;
text-align: left;
border-bottom: 1px solid #eef2f7;
}
th {
color: #667085;
font-weight: 600;
background: #fafbfe;
}
.num {
text-align: right;
}
}
.dir-tag {
display: inline-block;
padding: 1px 7px;
font-size: 11px;
font-weight: 700;
border-radius: 5px;
&.credit {
color: #18a058;
background: #e9f6ee;
}
&.debit {
color: #d03050;
background: #fdeef0;
}
}
.foot-note {
margin: 16px 0 0;
color: #6b7686;
font-size: 12px;
text-align: center;
}
@media (max-width: 760px) {
.page-head,
.panel-head {
align-items: flex-start;
flex-direction: column;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,736 +0,0 @@
<template>
<div class="finance-layout-page">
<div class="page-top">
<div>
<h2>
利润概览
<em>{{ financialOverview.is_business_owner ? '业主机构' : '分销机构' }}</em>
</h2>
<p>
核算机构<b>{{ displayValue(financialOverview.accounting_orgname) }}</b>
<span>编号 {{ displayValue(financialOverview.accounting_orgid) }}</span>
</p>
<div class="chips">
<span>含下级分销客户<b>{{ customerScope.include_sub_reseller_customers ? '是' : '否' }}</b></span>
<span>下级分销机构<b>{{ formatNumber(customerScope.descendant_reseller_count) }}</b> </span>
<span>账单总数<b>{{ formatNumber(financialOverview.bill_count) }}</b></span>
<span>仅已入账<b>{{ filters.only_accounted ? '是' : '否' }}</b></span>
<span>数据截断<b>{{ financialOverview.truncated ? '是' : '否' }}</b></span>
</div>
</div>
<div class="view-meta">
查看计费统计
</div>
</div>
<div class="summary-grid">
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.type">
<i></i>
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
<p>{{ card.desc }}</p>
</div>
</div>
<el-card shadow="never" class="panel">
<div class="panel-head">
<h3>收益构成按客户来源</h3>
</div>
<el-table
v-loading="loading"
:data="sourceRows"
size="small"
class="finance-table"
show-summary
:summary-method="getSourceSummary"
>
<el-table-column label="客户来源" min-width="180">
<template slot-scope="{ row }">
<span class="source-cell">
<i class="dot" :class="row.type"></i>{{ row.name }}
</span>
</template>
</el-table-column>
<el-table-column label="销售额" align="right" min-width="140">
<template slot-scope="{ row }">{{ money(row.sales_total) }}</template>
</el-table-column>
<el-table-column label="利润" align="right" min-width="140">
<template slot-scope="{ row }">
<span :class="amountClass(row.profit_total)">{{ money(row.profit_total) }}</span>
</template>
</el-table-column>
<el-table-column label="利润率" align="right" width="120">
<template slot-scope="{ row }">
<span :class="amountClass(row.margin)">{{ percent(row.margin) }}</span>
</template>
</el-table-column>
<el-table-column :label="settleLabel" align="right" min-width="140">
<template slot-scope="{ row }">{{ money(row.settle_upstream_total) }}</template>
</el-table-column>
<el-table-column label="账单数" align="right" width="110">
<template slot-scope="{ row }">{{ formatNumber(row.bill_count) }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="panel">
<div class="panel-head">
<h3>供应商 / 产品明细</h3>
</div>
<el-table
v-loading="loading"
:data="filteredProductRows"
size="small"
class="finance-table product-table"
show-summary
:summary-method="getProductSummary"
@row-click="openProductDetail"
>
<el-table-column label="供应商" min-width="170" show-overflow-tooltip>
<template slot-scope="{ row }">
{{ row.providerName }}
<el-tag v-if="row.hasSubReseller" size="mini" type="primary" effect="plain">含下级</el-tag>
</template>
</el-table-column>
<el-table-column prop="productName" label="产品" min-width="190" show-overflow-tooltip />
<el-table-column prop="sales_total" label="销售额" align="right" min-width="140" sortable>
<template slot-scope="{ row }">{{ money(row.sales_total) }}</template>
</el-table-column>
<el-table-column prop="profit_total" label="利润" align="right" min-width="140" sortable>
<template slot-scope="{ row }">
<span :class="amountClass(row.profit_total)">{{ money(row.profit_total) }}</span>
</template>
</el-table-column>
<el-table-column prop="margin" label="利润率" align="right" width="120" sortable>
<template slot-scope="{ row }">
<span :class="amountClass(row.margin)">{{ percent(row.margin) }}</span>
</template>
</el-table-column>
<el-table-column prop="settle_upstream_total" :label="settleLabel" align="right" min-width="140">
<template slot-scope="{ row }">{{ money(row.settle_upstream_total) }}</template>
</el-table-column>
<el-table-column prop="bill_count" label="账单数" align="right" width="110" sortable>
<template slot-scope="{ row }">{{ formatNumber(row.bill_count) }}</template>
</el-table-column>
</el-table>
<p class="note"> {{ productRows.length }} 个供应商/产品组合</p>
</el-card>
<el-dialog
:visible.sync="detailVisible"
width="680px"
custom-class="finance-detail-dialog"
append-to-body
:show-close="false"
>
<div v-if="currentProduct" class="detail-card">
<button class="detail-close" @click="detailVisible = false">×</button>
<div class="detail-head">
<h3>{{ currentProduct.providerName }} · {{ currentProduct.productName }}</h3>
<p>
销售额 {{ money(currentProduct.sales_total) }} ·
利润率 {{ percent(currentProduct.margin) }}
</p>
</div>
<el-table :data="detailRows" size="small" class="detail-table">
<el-table-column label="客户来源" min-width="160">
<template slot-scope="{ row }">
<span class="source-cell">
<i class="dot" :class="row.type"></i>{{ row.name }}
</span>
</template>
</el-table-column>
<el-table-column label="销售额" align="right" min-width="150">
<template slot-scope="{ row }">{{ money(row.sales_total) }}</template>
</el-table-column>
<el-table-column label="利润" align="right" min-width="150">
<template slot-scope="{ row }">
<span :class="amountClass(row.profit_total)">{{ money(row.profit_total) }}</span>
</template>
</el-table-column>
<el-table-column :label="settleLabel" align="right" min-width="150">
<template slot-scope="{ row }">{{ money(row.settle_upstream_total) }}</template>
</el-table-column>
<el-table-column label="账单数" align="right" width="110">
<template slot-scope="{ row }">{{ formatNumber(row.bill_count) }}</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
</div>
</template>
<script>
import { reqFinancialOverview } from '@/api/FinancialOverview/FinancialOverview'
export default {
name: 'FinancialOverview',
data() {
return {
loading: false,
keyword: '',
orgid: '',
financialOverview: {},
detailVisible: false,
currentProduct: null
}
},
computed: {
//
customerScope() {
return this.financialOverview.customer_scope || {}
},
//
filters() {
return this.financialOverview.filters || {}
},
//
period() {
return this.financialOverview.period || {}
},
//
totals() {
return this.financialOverview.totals || {}
},
//
grandTotal() {
return this.totals.grand_total || {}
},
//
settleLabel() {
return this.financialOverview.settle_upstream_label || '应付供应商'
},
//
periodText() {
if (this.period.start_date || this.period.end_date) {
return `${this.period.start_date || '-'}${this.period.end_date || '-'}`
}
return '全部周期'
},
//
summaryCards() {
return [
{
label: '销售总额',
value: this.money(this.grandTotal.sales_total),
desc: 'grand_total · sales_total',
type: 'blue'
},
{
label: '利润总额',
value: this.money(this.grandTotal.profit_total),
desc: `利润率 ${this.percent(this.margin(this.grandTotal.profit_total, this.grandTotal.sales_total))}`,
type: 'green'
},
{
label: this.settleLabel,
value: this.money(this.grandTotal.settle_upstream_total),
desc: 'settle_upstream_total',
type: 'amber'
},
{
label: '账单总数',
value: this.formatNumber(this.grandTotal.bill_count),
desc: `${this.formatNumber(this.totals.from_sub_resellers && this.totals.from_sub_resellers.bill_count)} 笔下级分销`,
type: 'purple'
}
]
},
//
sourceRows() {
return [
this.buildSourceRow('直接客户', this.totals.direct_customers, 'blue-dot'),
this.buildSourceRow('下级分销客户', this.totals.from_sub_resellers, 'purple-dot')
]
},
// / by_provider_product 便 el-table
productRows() {
return (this.financialOverview.by_provider_product || []).map((item, index) => {
const total = item.total || {}
const direct = item.direct_customers || {}
const sub = item.from_sub_resellers || {}
return {
index,
providerName: this.displayValue(item.provider && item.provider.name),
productName: this.displayValue(item.product && item.product.name),
direct_customers: direct,
from_sub_resellers: sub,
sales_total: Number(total.sales_total || 0),
profit_total: Number(total.profit_total || 0),
settle_upstream_total: Number(total.settle_upstream_total || 0),
bill_count: Number(total.bill_count || 0),
margin: this.margin(total.profit_total, total.sales_total),
hasSubReseller: Number(sub.bill_count || 0) > 0 || Number(sub.sales_total || 0) !== 0 || Number(sub.profit_total || 0) !== 0
}
})
},
//
filteredProductRows() {
const keyword = this.keyword.trim().toLowerCase()
if (!keyword) return this.productRows
return this.productRows.filter(item => {
return `${item.providerName} ${item.productName}`.toLowerCase().includes(keyword)
})
},
//
detailRows() {
if (!this.currentProduct) return []
return [
this.buildSourceRow('直接客户', this.currentProduct.direct_customers, 'blue-dot'),
this.buildSourceRow('下级分销客户', this.currentProduct.from_sub_resellers, 'purple-dot'),
this.buildSourceRow('合计', this.currentProduct, 'total-dot')
]
}
},
mounted() {
this.getFinancialOverview()
},
methods: {
// orgid使使
getOrgId() {
const orgid = this.$route.query.orgid || sessionStorage.getItem('orgid') || localStorage.getItem('orgid') || ''
this.orgid = orgid
return orgid
},
// computed
async getFinancialOverview() {
this.loading = true
try {
const orgid = this.getOrgId()
const res = await reqFinancialOverview({ accounting_orgid:orgid })
if (res && res.status === true) {
this.financialOverview = res.data || {}
}
} finally {
this.loading = false
}
},
//
buildSourceRow(name, data = {}, type) {
return {
name,
type,
sales_total: Number(data.sales_total || 0),
profit_total: Number(data.profit_total || 0),
settle_upstream_total: Number(data.settle_upstream_total || 0),
bill_count: Number(data.bill_count || 0),
margin: this.margin(data.profit_total, data.sales_total)
}
},
// totals.grand_total
getSourceSummary() {
return [
'合计',
this.money(this.grandTotal.sales_total),
this.money(this.grandTotal.profit_total),
this.percent(this.margin(this.grandTotal.profit_total, this.grandTotal.sales_total)),
this.money(this.grandTotal.settle_upstream_total),
this.formatNumber(this.grandTotal.bill_count)
]
},
// / grand_total
getProductSummary() {
return [
'合计',
'',
this.money(this.grandTotal.sales_total),
this.money(this.grandTotal.profit_total),
this.percent(this.margin(this.grandTotal.profit_total, this.grandTotal.sales_total)),
this.money(this.grandTotal.settle_upstream_total),
this.formatNumber(this.grandTotal.bill_count)
]
},
// /
openProductDetail(row) {
this.currentProduct = row
this.detailVisible = true
},
// /
margin(profit, sales) {
const salesValue = Number(sales || 0)
return salesValue ? (Number(profit || 0) / salesValue) * 100 : 0
},
//
money(value) {
return `¥${Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}`
},
//
percent(value) {
return `${Number(value || 0).toFixed(1)}%`
},
//
formatNumber(value) {
return Number(value || 0).toLocaleString('zh-CN')
},
//
displayValue(value) {
if (value === undefined || value === null || value === '') return '未命名'
return String(value)
},
// 绿
amountClass(value) {
return Number(value || 0) < 0 ? 'red-text' : 'green-text'
}
}
}
</script>
<style scoped lang="less">
.finance-layout-page {
min-height: 100vh;
padding: 18px 20px 36px;
color: #1f2733;
background: #f4f6fb;
}
.page-top {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
.crumb {
margin-bottom: 8px;
color: #667085;
font-size: 12px;
span {
margin: 0 6px;
color: #c4cad5;
}
}
h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 6px;
font-size: 22px;
line-height: 1;
em {
padding: 2px 9px;
color: #6d5df6;
font-size: 12px;
font-style: normal;
background: #f0edff;
border-radius: 999px;
}
}
p {
margin: 0;
color: #667085;
font-size: 13px;
span {
margin-left: 8px;
}
}
}
.view-meta {
color: #667085;
font-size: 13px;
text-align: right;
small {
display: block;
margin-top: 4px;
font-size: 12px;
}
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
span {
padding: 4px 10px;
color: #667085;
font-size: 12px;
background: #fff;
border: 1px solid #e6eaf1;
border-radius: 8px;
}
b {
color: #1f2733;
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.summary-card {
position: relative;
min-height: 102px;
padding: 16px 18px 14px;
overflow: hidden;
background: #fff;
border: 1px solid #e6eaf1;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(20, 30, 55, 0.06), 0 8px 24px rgba(20, 30, 55, 0.05);
i {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
}
span {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 13px;
}
strong {
display: block;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 7px 0 0;
color: #98a2b3;
font-size: 12px;
}
&.blue {
i { background: #3a6df0; }
strong { color: #3a6df0; }
}
&.green {
i { background: #18a058; }
strong { color: #18a058; }
}
&.amber {
i { background: #d08e18; }
strong { color: #d08e18; }
}
&.purple {
i { background: #7a5af0; }
strong { color: #7a5af0; }
}
}
.panel {
margin-bottom: 14px;
border: 1px solid #e6eaf1;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(20, 30, 55, 0.05);
.el-card__body {
padding: 14px 18px 10px;
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h3 {
margin: 0;
color: #1f2733;
font-size: 15px;
}
}
.panel-tools {
display: flex;
align-items: center;
gap: 10px;
color: #98a2b3;
font-size: 12px;
.el-input {
width: 220px;
}
}
.finance-table {
th {
padding: 8px 0;
color: #667085;
font-size: 12px;
font-weight: 600;
background: #fafbfe;
}
td {
padding: 7px 0;
color: #273142;
font-size: 12px;
}
.el-table__footer td {
color: #1f2733;
font-weight: 700;
background: #fafbfe;
}
}
.product-table {
.el-table__row {
cursor: pointer;
}
.el-table__row:hover > td {
background: #f0f5ff;
}
}
/deep/ .finance-detail-dialog {
overflow: hidden;
background: #fff;
border-radius: 18px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
.detail-card {
position: relative;
padding: 22px 24px 22px;
}
.detail-close {
position: absolute;
top: 18px;
right: 22px;
width: 34px;
height: 34px;
color: #667085;
font-size: 18px;
font-weight: 700;
line-height: 32px;
text-align: center;
background: #f5f7fb;
border: 0;
border-radius: 10px;
cursor: pointer;
}
.detail-head {
padding-right: 48px;
margin-bottom: 18px;
h3 {
margin: 0 0 6px;
color: #1f2733;
font-size: 20px;
font-weight: 700;
}
p {
margin: 0;
color: #667085;
font-size: 13px;
}
}
.detail-table {
/deep/ th {
padding: 12px 0;
color: #667085;
font-size: 14px;
font-weight: 700;
background: #f7f8fb;
}
/deep/ td {
padding: 12px 0;
color: #273142;
font-size: 13px;
}
/deep/ .el-table__row:last-child td {
font-weight: 700;
background: #fafbfe;
}
}
.source-cell {
display: inline-flex;
align-items: center;
gap: 7px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.blue-dot {
background: #3a6df0;
}
.purple-dot {
background: #7a5af0;
}
.total-dot {
background: #1f2733;
}
.green-text {
color: #18a058 !important;
font-weight: 600;
}
.red-text {
color: #d03050 !important;
font-weight: 600;
}
.note {
margin: 10px 0 2px;
color: #98a2b3;
font-size: 12px;
text-align: center;
}
@media (max-width: 1100px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.page-top,
.panel-head,
.panel-tools {
flex-direction: column;
align-items: flex-start;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -44,23 +44,10 @@
<el-option label="多模态" value="多模态"></el-option>
</el-select>
</el-form-item>
<el-form-item label="模型LOGO">
<el-form-item label="供应商LOGO">
<div class="logo-upload">
<input
ref="modelLogoInput"
type="file"
accept="image/*"
class="logo-file-input"
@change="handleLogoFileChange"
>
<div v-if="editForm.modelLogoPreview" class="logo-preview">
<img :src="editForm.modelLogoPreview" alt="model logo">
</div>
<div class="logo-actions">
<el-button size="small" icon="el-icon-upload2" @click="triggerLogoUpload">选择文件</el-button>
<el-button v-if="editForm.modelLogoPreview" size="small" type="text" @click="clearModelLogo">移除</el-button>
<span>{{ editForm.logo || (editForm.modelLogoPreview ? '已上传图片' : '未选择任何文件') }}</span>
</div>
<el-button size="small" icon="el-icon-upload2">选择文件</el-button>
<span>{{ editForm.logo || '未选择任何文件' }}</span>
</div>
</el-form-item>
<el-form-item label="接口地址" class="span-2">
@ -273,9 +260,6 @@ const DEFAULT_EDIT_FORM = {
type: '',
provider: '',
logo: '',
modelLogo: '',
modelLogoFile: null,
modelLogoPreview: '',
apiUrl: '',
description: '',
contextLength: '',
@ -337,7 +321,6 @@ export default {
const apiUrl = this.toFormValue(apiDoc.api_url || row.api_url || '')
const curlCode = this.toTextareaValue(apiDoc.curl_code || row.curl_code || '')
const pythonCode = this.toTextareaValue(apiDoc.python_code || row.python_code || '')
const modelLogo = row.model_logo || row.modelLogo || row.provider_logo || ''
this.editForm = {
...defaultForm,
@ -347,10 +330,7 @@ export default {
displayName: row.display_name || row.displayName || row.model_name || row.name || '',
type: row.model_type || row.type || '',
provider: row.provider || '',
logo: row.logo || '',
modelLogo,
modelLogoFile: null,
modelLogoPreview: modelLogo,
logo: row.provider_logo || row.logo || '',
apiUrl,
requestUrl: apiUrl,
description: row.description || '',
@ -396,7 +376,7 @@ export default {
this.submitLoading = true
try {
const res = isEdit
? await reqModelInfoConfigEdit(this.appendEditId(params))
? await reqModelInfoConfigEdit({ ...params, id: this.editForm.id })
: await reqModelInfoConfig(params)
if (res && res.status) {
this.$message.success(res.msg || (isEdit ? '模型信息编辑成功' : '模型信息添加成功'))
@ -412,7 +392,7 @@ export default {
}
},
buildSubmitParams() {
const params = {
return {
provider: this.editForm.provider,
model_name: this.editForm.modelName,
display_name: this.editForm.displayName,
@ -428,68 +408,10 @@ export default {
highlights: JSON.stringify(this.filterNameValueList(this.editForm.highlights)),
description: this.editForm.description,
experience: this.editForm.experience,
model_logo: this.editForm.modelLogo,
api_url: this.editForm.apiUrl || this.editForm.requestUrl,
curl_code: this.editForm.curlCode,
python_code: this.editForm.pythonCode
}
return this.editForm.modelLogoFile ? this.buildFormData(params) : params
},
buildFormData(params) {
const formData = new FormData()
Object.keys(params).forEach(key => {
if (params[key] !== undefined && params[key] !== null) {
formData.append(key, params[key])
}
})
formData.set('model_logo', this.editForm.modelLogoFile)
return formData
},
appendEditId(params) {
if (params instanceof FormData) {
params.append('id', this.editForm.id)
return params
}
return { ...params, id: this.editForm.id }
},
triggerLogoUpload() {
if (this.$refs.modelLogoInput) {
this.$refs.modelLogoInput.click()
}
},
handleLogoFileChange(event) {
const file = event.target.files && event.target.files[0]
if (!file) return
if (file.size > 5 * 1024 * 1024) {
this.$message.error('图片大小不能超过5MB')
event.target.value = ''
return
}
if (!file.type || !file.type.startsWith('image/')) {
this.$message.error('请选择图片文件')
event.target.value = ''
return
}
if (this.editForm.modelLogoPreview && this.editForm.modelLogoPreview.startsWith('blob:')) {
URL.revokeObjectURL(this.editForm.modelLogoPreview)
}
this.editForm.modelLogoFile = file
this.editForm.modelLogo = ''
this.editForm.modelLogoPreview = URL.createObjectURL(file)
this.editForm.logo = file.name
event.target.value = ''
},
clearModelLogo() {
if (this.editForm.modelLogoPreview && this.editForm.modelLogoPreview.startsWith('blob:')) {
URL.revokeObjectURL(this.editForm.modelLogoPreview)
}
this.editForm.modelLogo = ''
this.editForm.modelLogoFile = null
this.editForm.modelLogoPreview = ''
this.editForm.logo = ''
if (this.$refs.modelLogoInput) {
this.$refs.modelLogoInput.value = ''
}
},
buildCapabilities() {
return [
@ -853,36 +775,6 @@ export default {
align-items: center;
gap: 10px;
min-height: 32px;
}
.logo-file-input {
display: none;
}
.logo-preview {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 42px;
width: 42px;
height: 42px;
overflow: hidden;
background: #f6f8fb;
border: 1px solid #edf1f7;
border-radius: 10px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.logo-actions {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
span {
color: #98a2b3;

View File

@ -100,7 +100,63 @@ export default {
modelType: '',
provider: ''
},
tableData: []
tableData: [
{
id: 'M006',
name: 'GPT-3.5-Turbo',
type: '文本生成',
provider: 'OpenAI',
logo: '-',
apiUrl: 'https://api.openai.com/v1/chat/completions',
description: 'GPT-3.5-Turbo高效对话模型适合文本生成和问答。',
status: '待上架',
updatedAt: '2026-04-15'
},
{
id: 'M007',
name: 'ViT-B/16',
type: '图像生成',
provider: 'Google',
logo: '-',
apiUrl: 'https://api.kaiyuan.cloud/v1/images',
description: 'ViT-B/16视觉Transformer模型适合图像理解。',
status: '待上架',
updatedAt: '2026-04-14'
},
{
id: 'M008',
name: 'Bark',
type: '语音合成',
provider: '开元云',
logo: '-',
apiUrl: 'https://api.kaiyuan.cloud/v1/audio',
description: 'Bark多语言语音合成模型支持自然语音生成。',
status: '待上架',
updatedAt: '2026-04-13'
},
{
id: 'M009',
name: 'CLIP',
type: '多模态',
provider: 'OpenAI',
logo: '-',
apiUrl: 'https://api.kaiyuan.cloud/v1/clip',
description: 'CLIP多模态模型支持图文检索和理解。',
status: '待上架',
updatedAt: '2026-04-12'
},
{
id: 'M011',
name: 'Falcon-180B',
type: '文本生成',
provider: 'Meta',
logo: '-',
apiUrl: 'https://api.kaiyuan.cloud/v1/chat',
description: 'Falcon-180B开源大语言模型适合复杂文本任务。',
status: '待上架',
updatedAt: '2026-04-27'
}
]
}
},
computed: {

View File

@ -2,529 +2,196 @@
<div class="operation-report-page">
<div class="report-header">
<div>
<div class="title-line">
<span class="title-icon">
<i class="el-icon-s-data"></i>
</span>
<h2>运营报表</h2>
</div>
<p>查看机构下模型调用次数Token 消耗和费用统计</p>
</div>
<div class="header-actions">
<span class="range-badge">{{ filterTimeText }}</span>
<el-button size="small" icon="el-icon-refresh" class="refresh-btn" :loading="loading" @click="getReportList">
刷新
</el-button>
<h2>运营报表</h2>
<p>模型使用与计费数据概览</p>
</div>
<el-button size="small" icon="el-icon-document" @click="exportReport">导出报表</el-button>
</div>
<div class="stat-grid">
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
<div class="stat-card-head">
<div class="stat-title">{{ item.label }}</div>
<i :class="item.icon"></i>
</div>
<div class="stat-value">{{ item.value }}</div>
<div class="stat-desc">{{ item.desc }}</div>
<div class="stat-card purple">
<div class="stat-title">活跃用户</div>
<div class="stat-value">{{ statCards.activeUsers }}</div>
</div>
<div class="stat-card green">
<div class="stat-title">Token消耗</div>
<div class="stat-value">{{ statCards.tokenUsage }}</div>
</div>
<div class="stat-card orange">
<div class="stat-title">Tokens总费用</div>
<div class="stat-value">¥{{ statCards.totalFee }}</div>
</div>
</div>
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<div>
<h3>筛选条件</h3>
<p>按模型和时间范围查询运营用量</p>
</div>
<el-form :inline="true" :model="query" class="filter-form">
<el-select v-model="query.range" size="small" placeholder="快捷范围" @change="handleRangeChange">
<el-option label="最近1小时" value="hour"></el-option>
<el-option label="今天" value="day"></el-option>
<el-option label="最近一周" value="week"></el-option>
<el-form :inline="true" :model="filterForm">
<el-form-item label="用户">
<el-input v-model="filterForm.userName" placeholder="搜索用户名" clearable />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="filterForm.modelName" placeholder="全部模型" clearable>
<el-option v-for="item in modelOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-input
v-model="query.model"
size="small"
clearable
placeholder="搜索模型"
@keyup.enter.native="handleSearch"
@clear="handleSearch"
></el-input>
<el-input
v-model="query.customerid"
size="small"
clearable
placeholder="搜索用户ID"
@keyup.enter.native="handleSearch"
@clear="handleSearch"
></el-input>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
></el-date-picker>
<el-button size="small" type="primary" :loading="loading" @click="handleSearch">查询</el-button>
<el-button size="small" @click="handleReset">重置</el-button>
</el-form>
</div>
</el-form-item>
<el-form-item label="支付方式">
<el-select v-model="filterForm.paymentMethod" placeholder="全部" clearable>
<el-option label="支付宝" value="支付宝" />
<el-option label="微信支付" value="微信支付" />
</el-select>
</el-form-item>
<el-form-item label="使用时间">
<el-date-picker v-model="filterForm.date" type="date" placeholder="年/月/日" value-format="yyyy-MM-dd" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="table-card">
<div class="table-title">
<div>
<h3>用量明细</h3>
<p> {{ total }} 条记录</p>
</div>
<span>{{ timeText }}</span>
</div>
<el-table v-loading="loading" :data="reportList" class="report-table" style="width: 100%">
<el-table-column prop="customer_name" label="客户名称" min-width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="customerid" label="客户ID" min-width="240" show-overflow-tooltip></el-table-column>
<el-table-column prop="model" label="模型" min-width="140" show-overflow-tooltip>
<el-table :data="pagedList" style="width: 100%">
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="userId" label="用户ID" min-width="110" />
<el-table-column prop="userName" label="用户名" min-width="100" />
<el-table-column prop="modelName" label="使用模型" min-width="140">
<template slot-scope="scope">
<el-tag size="mini" type="info">{{ scope.row.model || '-' }}</el-tag>
<el-tag type="info" size="mini">{{ scope.row.modelName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="request_count" label="请求次数" width="100"></el-table-column>
<el-table-column prop="prompt_tokens" label="输入Token" min-width="110">
<template slot-scope="scope">{{ formatNumber(scope.row.prompt_tokens) }}</template>
<el-table-column prop="useTime" label="使用时间" min-width="160" />
<el-table-column prop="inputToken" label="输入TOKEN" min-width="110" />
<el-table-column prop="outputToken" label="输出TOKEN" min-width="110" />
<el-table-column prop="tokenCost" label="TOKEN费用(元)" min-width="120" />
<el-table-column prop="paymentMethod" label="支付方式" min-width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.paymentMethod === '支付宝' ? 'primary' : 'success'">
{{ scope.row.paymentMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="completion_tokens" label="输出Token" min-width="110">
<template slot-scope="scope">{{ formatNumber(scope.row.completion_tokens) }}</template>
</el-table-column>
<el-table-column prop="total_tokens" label="总Token" min-width="110">
<template slot-scope="scope">{{ formatNumber(scope.row.total_tokens) }}</template>
</el-table-column>
<el-table-column prop="amount" label="费用(元)" min-width="110">
<template slot-scope="scope">¥{{ formatAmount(scope.row.amount) }}</template>
</el-table-column>
<el-table-column prop="last_usage_time" label="最近使用时间" min-width="160" show-overflow-tooltip></el-table-column>
<el-table-column prop="balance" label="账户余额(元)" min-width="120" />
</el-table>
<div class="pager-wrap">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:page-size="query.page_size"
:current-page="query.current_page"
:page-sizes="[10, 20, 50, 100]"
@size-change="handleSizeChange"
layout="prev, pager, next, total"
:total="filteredList.length"
:page-size="pageSize"
:current-page.sync="currentPage"
@current-change="handlePageChange"
></el-pagination>
/>
</div>
</el-card>
</div>
</template>
<script>
import { reqOperationReport } from '@/api/model/model'
export default {
name: 'OperationReport',
name: "OperationReport",
data() {
return {
loading: false,
dateRange: [],
summary: {
request_count: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
amount: 0
pageSize: 10,
currentPage: 1,
filterForm: {
userName: "",
modelName: "",
paymentMethod: "",
date: ""
},
reportList: [],
total: 0,
timeText: '',
query: {
range: 'week',
start_time: '',
end_time: '',
username: '',
model: '',
customerid: '',
current_page: 1,
page_size: 20
}
}
reportList: [
{ userId: "U100001", userName: "张明远", modelName: "MiniMax-M2.7", useTime: "2026-04-20 14:32:18", inputToken: "1,520", outputToken: "2,380", tokenCost: "¥0.047", paymentMethod: "支付宝", balance: "¥87.53" },
{ userId: "U100002", userName: "李恩涵", modelName: "DeepSeek-V3.2", useTime: "2026-04-20 14:28:45", inputToken: "3,200", outputToken: "4,500", tokenCost: "¥0.012", paymentMethod: "微信支付", balance: "¥32.18" },
{ userId: "U100003", userName: "王建国", modelName: "GLM-5.1", useTime: "2026-04-20 14:15:33", inputToken: "890", outputToken: "1,200", tokenCost: "¥0.017", paymentMethod: "支付宝", balance: "¥185.42" },
{ userId: "U100004", userName: "陈晓鸽", modelName: "Qwen3.5-72B", useTime: "2026-04-20 13:58:21", inputToken: "2,100", outputToken: "3,500", tokenCost: "¥0.063", paymentMethod: "支付宝", balance: "¥56.91" },
{ userId: "U100005", userName: "赵伟", modelName: "GPT-4", useTime: "2026-04-20 13:45:10", inputToken: "4,500", outputToken: "6,800", tokenCost: "¥0.576", paymentMethod: "微信支付", balance: "¥312.45" },
{ userId: "U100006", userName: "刘芳", modelName: "ERNIE-4.5-Turbo", useTime: "2026-04-20 13:30:05", inputToken: "1,800", outputToken: "2,200", tokenCost: "¥0.042", paymentMethod: "支付宝", balance: "¥68.77" }
]
};
},
computed: {
statCards() {
return [
{ label: '请求次数', value: this.formatNumber(this.summary.request_count), desc: '当前筛选范围', type: 'purple', icon: 'el-icon-s-promotion' },
{ label: 'Token消耗', value: this.formatNumber(this.summary.total_tokens), desc: `输入 ${this.formatNumber(this.summary.prompt_tokens)} / 输出 ${this.formatNumber(this.summary.completion_tokens)}`, type: 'green', icon: 'el-icon-coin' },
{ label: 'Token总费用', value: `¥${this.formatAmount(this.summary.amount)}`, desc: '按调用记录汇总', type: 'orange', icon: 'el-icon-wallet' }
]
return {
activeUsers: "1,286",
tokenUsage: "3.2M",
totalFee: "38,642"
};
},
filterTimeText() {
if (this.query.start_time && this.query.end_time) {
return `${this.query.start_time}${this.query.end_time}`
}
const labelMap = {
hour: '最近1小时',
day: '今天',
week: '最近一周'
}
return labelMap[this.query.range] || '当前范围'
modelOptions() {
return [...new Set(this.reportList.map(item => item.modelName))];
},
filteredList() {
return this.reportList.filter(item => {
const matchUser = !this.filterForm.userName || item.userName.includes(this.filterForm.userName);
const matchModel = !this.filterForm.modelName || item.modelName === this.filterForm.modelName;
const matchPay = !this.filterForm.paymentMethod || item.paymentMethod === this.filterForm.paymentMethod;
const matchDate = !this.filterForm.date || item.useTime.startsWith(this.filterForm.date);
return matchUser && matchModel && matchPay && matchDate;
});
},
pagedList() {
const start = (this.currentPage - 1) * this.pageSize;
return this.filteredList.slice(start, start + this.pageSize);
}
},
created() {
this.getReportList()
},
methods: {
async getReportList() {
this.loading = true
try {
const res = await reqOperationReport(this.query)
if (res && res.status === true) {
const data = res.data || {}
this.summary = {
...this.summary,
...(data.summary || {})
}
this.reportList = Array.isArray(data.items) ? data.items : []
this.total = Number(data.total_count || 0)
this.query.current_page = Number(data.current_page || this.query.current_page)
this.query.page_size = Number(data.page_size || this.query.page_size)
this.timeText = data.start_time && data.end_time ? `${data.start_time}${data.end_time}` : ''
}
} finally {
this.loading = false
}
},
handleDateChange(value) {
if (value && value.length === 2) {
this.query.start_time = value[0]
this.query.end_time = value[1]
} else {
this.query.start_time = ''
this.query.end_time = ''
}
this.query.current_page = 1
this.getReportList()
},
handleRangeChange() {
this.query.current_page = 1
this.getReportList()
},
handleSearch() {
this.query.current_page = 1
this.getReportList()
this.currentPage = 1;
},
handleReset() {
this.dateRange = []
this.query = {
range: 'week',
start_time: '',
end_time: '',
username: '',
model: '',
customerid: '',
current_page: 1,
page_size: 20
}
this.getReportList()
},
handleSizeChange(size) {
this.query.page_size = size
this.query.current_page = 1
this.getReportList()
resetSearch() {
this.filterForm = { userName: "", modelName: "", paymentMethod: "", date: "" };
this.currentPage = 1;
},
handlePageChange(page) {
this.query.current_page = page
this.getReportList()
this.currentPage = page;
},
formatNumber(value) {
return Number(value || 0).toLocaleString()
},
formatAmount(value) {
return Number(value || 0).toFixed(6).replace(/0+$/, '').replace(/\.$/, '.00')
exportReport() {
this.$message.success("报表导出任务已提交");
}
}
}
};
</script>
<style scoped lang="scss">
.operation-report-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(64, 158, 255, 0.16), transparent 34%),
linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 48%, #ffffff 100%);
padding: 20px;
background: #f5f7fb;
}
.report-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 22px;
margin-bottom: 20px;
overflow: hidden;
color: #ffffff;
background: linear-gradient(135deg, #1e6fff 0%, #409eff 48%, #7c3aed 100%);
border-radius: 18px;
box-shadow: 0 14px 34px rgba(64, 158, 255, 0.22);
h2 {
margin: 0;
color: #ffffff;
font-size: 24px;
}
p {
margin: 6px 0 0;
color: rgba(255, 255, 255, 0.82);
}
align-items: flex-start;
margin-bottom: 16px;
h2 { margin: 0; font-size: 28px; }
p { margin: 6px 0 0; color: #8b95a7; }
}
.title-line {
display: flex;
align-items: center;
gap: 10px;
}
.title-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: #ffffff;
font-size: 18px;
background: rgba(255, 255, 255, 0.18);
border-radius: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.range-badge {
padding: 7px 12px;
color: #ffffff;
font-size: 12px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.refresh-btn {
color: #1e6fff;
border: none;
border-radius: 10px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
gap: 14px;
margin-bottom: 14px;
}
.stat-card {
position: relative;
overflow: hidden;
padding: 20px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(31, 45, 61, 0.08);
}
&:before {
position: absolute;
top: 0;
right: 0;
width: 86px;
height: 86px;
content: '';
background: rgba(255, 255, 255, 0.52);
border-radius: 0 0 0 86px;
}
&.purple {
background: linear-gradient(135deg, #f5f3ff 0%, #ffffff 100%);
}
&.green {
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
}
&.orange {
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
}
background: #fff;
border: 1px solid #eceff5;
border-radius: 12px;
padding: 18px 20px;
}
.stat-card-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
i {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
color: #409eff;
font-size: 18px;
background: rgba(64, 158, 255, 0.12);
border-radius: 12px;
}
}
.stat-title {
color: #5f6b7d;
font-size: 13px;
}
.stat-value {
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.stat-desc {
margin-top: 10px;
color: #98a2b3;
font-size: 12px;
}
.purple .stat-value {
color: #7f56d9;
}
.green .stat-value {
color: #16a34a;
}
.orange .stat-value {
color: #ea580c;
}
.stat-title { color: #5f6b7d; margin-bottom: 10px; }
.stat-value { font-size: 38px; font-weight: 700; line-height: 1; }
.purple .stat-value { color: #7f56d9; }
.green .stat-value { color: #16a34a; }
.orange .stat-value { color: #ea580c; }
.filter-card,
.table-card {
margin-bottom: 16px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 16px;
box-shadow: 0 10px 26px rgba(31, 45, 61, 0.05);
}
.filter-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
h3 {
margin: 0 0 8px;
color: #1f2d3d;
font-size: 16px;
}
p {
margin: 0;
color: #909399;
font-size: 13px;
}
}
.filter-form {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: #f6f8fb;
border: 1px solid #edf1f7;
border: 1px solid #eceff5;
border-radius: 12px;
.el-select {
width: 110px;
}
.el-input {
width: 160px;
}
}
.table-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 14px;
h3 {
margin: 0 0 6px;
color: #1f2d3d;
font-size: 16px;
}
p,
span {
margin: 0;
color: #98a2b3;
font-size: 12px;
}
}
.pager-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.report-table {
border-radius: 12px;
overflow: hidden;
.el-table__header th {
color: #475467;
font-weight: 700;
background: #f8fafc;
}
.el-table__row:hover > td {
background: #f6faff;
}
}
@media (max-width: 900px) {
.stat-grid {
grid-template-columns: 1fr;
}
.report-header,
.table-title,
.filter-bar {
flex-direction: column;
gap: 10px;
}
.filter-form {
align-items: flex-start;
flex-direction: column;
width: 100%;
.el-date-editor {
width: 100%;
}
}
}
</style>

View File

@ -116,65 +116,37 @@
@click="goModelDetail(product)"
>
<div class="token-card-top">
<span class="token-provider-avatar">
<img v-if="product.model_logo" :src="getModelLogoUrl(product.model_logo)" alt="">
<span v-else>{{ getProviderInitial(product.provider || product.display_name || product.model_name) }}</span>
</span>
<div class="token-title-group">
<h3>{{ product.display_name || product.model_name }}</h3>
<p>{{ product.provider || product.model_name || '-' }}</p>
</div>
<h3>{{ product.display_name || product.model_name }}</h3>
<!-- <span v-if="product.sort_order <= 10" class="token-new-badge">NEW</span> -->
<!-- <i class="el-icon-more token-more"></i> -->
</div>
<div class="token-price-grid">
<div class="token-price-item">
<span>输入</span>
<strong>¥{{ formatTokenPrice(product.input_token_price) }}</strong>
<em>/百万 tokens</em>
</div>
<div class="token-price-item">
<span>输出</span>
<strong>¥{{ formatTokenPrice(product.output_token_price) }}</strong>
<em>/百万 tokens</em>
</div>
<div class="token-price-item">
<span>缓存读</span>
<strong>¥{{ formatTokenPrice(product.cache_hit_input_price) }}</strong>
<em>/百万 tokens</em>
</div>
<div class="token-price-item">
<span>缓存创建</span>
<strong>¥{{ formatTokenPrice(product.input_token_price) }}</strong>
<em>/百万 tokens</em>
</div>
<div class="token-tags">
<span>{{ product.model_type || '-' }}</span>
<span>{{ product.billing_method || '-' }}</span>
<span>{{ product.provider || '-' }}</span>
<span v-if="product.llmid">{{ product.llmid }}</span>
</div>
<!-- <div class="token-description">
{{ product.description || '该模型适用于文本生成、对话问答、复杂推理等多种业务场景。' }}
</div> -->
<div class="token-card-footer">
<div class="token-feature-tags">
<span v-for="(tag, tagIndex) in getTokenFeatureTags(product)" :key="`${tag.type}-${tag.text}-${tagIndex}`" :class="tag.type">
<i :class="tag.icon"></i>
{{ tag.text }}
</span>
</div>
<div class="token-price-line">
<span>输入 ¥{{ formatTokenPrice(product.input_token_price) }}/千Token</span>
<span>输出 ¥{{ formatTokenPrice(product.output_token_price) }}/千Token</span>
</div>
<div class="token-hover-actions">
<div class="token-meta">
<span class="token-provider-avatar">{{ getProviderInitial(product.provider) }}</span>
<span>{{ product.provider || '-' }}</span>
</div>
<div class="token-actions">
<button @click.stop="goModelApiDocument(product)">
<i class="el-icon-document"></i>
API文档
</button>
<button
class="primary-action"
class="experience"
:class="{ disabled: !isModelExperienceEnabled(product) }"
:disabled="!isModelExperienceEnabled(product)"
@click.stop="goModelExperience(product)"
>
<i class="el-icon-video-play"></i>
快速体验
</button>
<button @click.stop="goModelApiDocument(product)">
<i class="el-icon-document"></i>
API参考
体验
</button>
</div>
</div>
@ -230,20 +202,6 @@ import { reqNavList, reqNewHomeSync, reqNewHomeFestival } from "@/api/newHome";
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
import TopBox from '@/views/homePage/components/topBox/index.vue'
const getImageUrlPrefix = () => {
const origin = window.location.origin
if (origin.includes('localhost') || origin.includes('dev.opencomputing.cn')) {
return 'https://dev.opencomputing.cn/idfile?path='
}
if (origin.includes('www.opencomputing.cn')) {
return 'https://www.opencomputing.cn/idfile?path='
}
if (origin.includes('www.ncmatch.cn')) {
return 'https://www.ncmatch.cn/idfile?path='
}
return `${origin}/idfile?path=`
}
export default {
name: "ProductServicePage",
components: {
@ -261,8 +219,7 @@ export default {
tokenModelTypeList: [],
tokenProviderList: [],
tokenActiveModelType: '',
tokenActiveProvider: "",
IMG_URL: getImageUrlPrefix(),
tokenActiveProvider: ''
};
},
computed: {
@ -402,61 +359,6 @@ export default {
return provider ? provider.slice(0, 1) : 'M';
},
getModelLogoUrl(modelLogo) {
if (!modelLogo) return '';
if (/^https?:\/\//.test(modelLogo) || modelLogo.startsWith('data:') || modelLogo.startsWith('blob:')) {
return modelLogo;
}
return `${this.IMG_URL}${modelLogo}`;
},
normalizeTokenConfigList(value) {
if (!value) return [];
let parsedValue = value;
if (typeof value === 'string') {
try {
parsedValue = JSON.parse(value);
} catch (error) {
return [];
}
}
if (Array.isArray(parsedValue)) {
return parsedValue.map(item => ({
name: item.name || item.label || item.key || '',
value: item.value || item.text || item.content || ''
})).filter(item => item.name || item.value);
}
if (parsedValue && typeof parsedValue === 'object') {
return Object.keys(parsedValue).map(key => ({ name: key, value: parsedValue[key] }));
}
return [];
},
getTokenFeatureTags(model) {
const capabilities = this.normalizeTokenConfigList(model.capabilities);
const highlights = this.normalizeTokenConfigList(model.highlights);
const inputType = capabilities.find(item => item.name === '输入类型');
const outputType = capabilities.find(item => item.name === '输出类型');
const highlight = highlights[0];
return [
{
text: inputType && inputType.value ? inputType.value : (model.model_type || '模型能力'),
icon: 'el-icon-view',
type: 'green'
},
{
text: outputType && outputType.value ? outputType.value : (highlight && (highlight.value || highlight.name)) || '智能生成',
icon: 'el-icon-magic-stick',
type: 'purple'
},
{
text: model.billing_method || '按量计费',
icon: 'el-icon-coin',
type: 'blue'
}
].filter(item => item.text);
},
// experience 1 0
isModelExperienceEnabled(model) {
return Number(model && model.experience) === 1;
@ -1391,86 +1293,42 @@ export default {
}
.token-market-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
align-items: stretch;
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.token-market-card {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
flex: 0 0 calc((100% - 28px) / 3);
min-width: 0;
min-height: 224px;
padding: 12px;
padding: 14px;
background: #ffffff;
border: 1px solid #dfe7f3;
border-radius: 10px;
box-shadow: 0 8px 20px rgba(31, 45, 61, 0.08);
border: 1px solid #edf1f7;
border-radius: 12px;
box-shadow: 0 10px 24px rgba(31, 45, 61, 0.05);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #8bbcff;
border-color: #d7e4f5;
transform: translateY(-2px);
box-shadow: 0 0 0 3px rgba(30, 111, 255, 0.1), 0 18px 38px rgba(30, 111, 255, 0.18);
&:before {
opacity: 1;
}
.token-hover-actions {
opacity: 1;
transform: translateY(0);
}
}
&:before {
position: absolute;
inset: 0;
content: '';
pointer-events: none;
opacity: 0;
background: radial-gradient(420px circle at 45% 0%, rgba(51, 133, 255, 0.16), transparent 42%);
transition: opacity 0.35s ease;
box-shadow: 0 16px 32px rgba(31, 45, 61, 0.08);
}
}
.token-card-top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 10px;
min-height: 38px;
margin-bottom: 10px;
.token-title-group {
flex: 1;
min-width: 0;
}
gap: 6px;
margin-bottom: 8px;
h3 {
flex: 1;
margin: 0;
color: #111827;
font-size: 15px;
color: #1f2d3d;
font-size: 14px;
font-weight: 700;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 3px 0 0;
color: #6b7890;
font-size: 12px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.35;
}
}
@ -1488,164 +1346,70 @@ export default {
transform: rotate(90deg);
}
.token-price-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
.token-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 10px;
margin-bottom: 10px;
}
.token-price-item {
span,
em {
display: block;
color: #6b7890;
font-style: normal;
}
border-bottom: 1px dashed #d8e0ee;
span {
margin-bottom: 2px;
padding: 2px 6px;
color: #667085;
font-size: 11px;
}
strong {
display: block;
color: #111827;
font-size: 15px;
font-weight: 800;
line-height: 1.1;
}
em {
margin-top: 0;
font-size: 10px;
background: #f8fafc;
border: 1px solid #edf1f7;
border-radius: 6px;
}
}
.token-description {
position: relative;
z-index: 1;
min-height: 58px;
margin-bottom: 12px;
color: #728096;
font-size: 13px;
line-height: 1.5;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.token-card-footer {
position: relative;
z-index: 1;
.token-price-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
margin-top: auto;
padding-top: 0;
}
.token-feature-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
flex: 1;
min-width: 0;
gap: 5px;
gap: 10px;
margin-bottom: 8px;
color: #1f2937;
font-size: 12px;
font-weight: 600;
}
.token-meta {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
color: #667085;
font-size: 12px;
}
.token-provider-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 36px;
width: 34px;
height: 34px;
width: 16px;
height: 16px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
// background: #7c3aed;
// border-radius: 8px;
overflow: hidden;
// box-shadow: 0 6px 16px rgba(124, 58, 237, 0.18);
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
font-size: 10px;
background: #1e6fff;
border-radius: 4px;
}
.token-feature-tags span {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 104px;
height: 20px;
padding: 0 7px;
font-size: 11px;
border-radius: 999px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.green {
color: #047857;
background: #dcfce7;
border: 1px solid #86efac;
}
&.purple {
color: #4338ca;
background: #eef2ff;
border: 1px solid #c7d2fe;
}
&.blue {
color: #1d4ed8;
background: #eff6ff;
border: 1px solid #bfdbfe;
}
}
.token-hover-actions {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
.token-actions {
display: flex;
gap: 6px;
padding: 6px;
opacity: 0;
background: rgba(255, 255, 255, 0.94);
border-top: 1px solid #edf1f7;
backdrop-filter: blur(10px);
transform: translateY(100%);
transition: all 0.25s ease;
button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 4px;
height: 28px;
padding: 0 8px;
padding: 0 10px;
color: #475467;
font-size: 12px;
font-weight: 600;
background: #ffffff;
border: 1px solid #d8e0ee;
border-radius: 8px;
border-radius: 7px;
cursor: pointer;
transition: all 0.2s ease;
@ -1653,32 +1417,30 @@ export default {
color: #1e6fff;
border-color: #9ec5ff;
background: #f4f8ff;
box-shadow: 0 0 0 3px rgba(30, 111, 255, 0.08);
}
}
.primary-action {
color: #ffffff;
background: #1e6fff;
border-color: #1e6fff;
.experience {
color: #4f46e5;
border-color: #c7d2fe;
&:hover {
color: #ffffff;
background: #155eef;
border-color: #155eef;
color: #4338ca;
border-color: #a5b4fc;
background: #eef2ff;
}
&.disabled,
&:disabled {
color: #ffffff;
color: #98a2b3;
cursor: not-allowed;
background: #cbd5e1;
border-color: #cbd5e1;
background: #f3f4f6;
border-color: #e5e7eb;
&:hover {
color: #ffffff;
background: #cbd5e1;
border-color: #cbd5e1;
color: #98a2b3;
background: #f3f4f6;
border-color: #e5e7eb;
}
}
}
@ -1856,14 +1618,14 @@ export default {
}
}
.product-content .main-content .token-market-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
.product-content .main-content .token-market-card {
flex-basis: calc((100% - 14px) / 2);
}
}
@media (max-width: 768px) {
.product-content .main-content .token-market-grid {
grid-template-columns: 1fr;
.product-content .main-content .token-market-card {
flex-basis: 100%;
}
}
}

View File

@ -1,7 +1,7 @@
<template>
<div class="token-usage-page">
<div class="usage-shell">
<!-- <div class="page-header">
<div class="page-header">
<div>
<div class="title-line">
<span class="title-icon">
@ -11,41 +11,14 @@
</div>
<p>查看模型调用 Token 消耗调用次数和费用趋势</p>
</div>
<div class="header-actions">
<span class="range-badge">{{ filterTimeText }}</span>
<el-button size="small" icon="el-icon-refresh" class="refresh-btn" :loading="loading" @click="getTokenList">刷新</el-button>
</div>
</div> -->
<div class="stat-row">
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
<div class="stat-card-head">
<span>{{ item.label }}</span>
<i :class="item.icon"></i>
</div>
<strong>{{ item.value }}</strong>
<em>{{ item.desc }}</em>
</div>
<el-button size="small" icon="el-icon-refresh" class="refresh-btn">刷新</el-button>
</div>
<div class="usage-dashboard">
<div class="dashboard-card token-ratio-card">
<div class="dashboard-title">
<h3>Token 使用概览</h3>
<span>{{ rangeLabel }}</span>
</div>
<div class="token-total">{{ formatNumber(summary.total_tokens) }}</div>
<p> Token 消耗</p>
<div ref="tokenRatioChart" class="chart-box token-ratio-chart"></div>
</div>
<div class="dashboard-card model-rank-card">
<div class="dashboard-title">
<h3>模型消耗排行</h3>
<span>当前页数据</span>
</div>
<div ref="modelRankChart" class="chart-box model-rank-chart"></div>
<div class="stat-row">
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<em>{{ item.desc }}</em>
</div>
</div>
@ -55,352 +28,62 @@
<h3>用量明细</h3>
<p>按模型维度统计 Token 输入输出与费用</p>
</div>
<div class="filter-actions">
<el-select v-model="query.range" size="small" placeholder="快捷范围" @change="handleRangeChange">
<el-option label="最近1小时" value="hour"></el-option>
<el-option label="今天" value="day"></el-option>
<el-option label="最近一周" value="week"></el-option>
</el-select>
<el-input
v-model="query.model"
size="small"
clearable
placeholder="搜索模型"
@keyup.enter.native="handleSearch"
@clear="handleSearch"
></el-input>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
></el-date-picker>
</div>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
></el-date-picker>
</div>
<el-table v-loading="loading" :data="usageList" class="usage-table" style="width: 100%">
<el-table-column prop="model" label="模型名称" min-width="180" show-overflow-tooltip></el-table-column>
<el-table-column prop="request_count" label="调用次数" width="120"></el-table-column>
<el-table-column prop="prompt_tokens" label="输入Token" min-width="140"></el-table-column>
<el-table-column prop="completion_tokens" label="输出Token" min-width="140"></el-table-column>
<el-table-column prop="total_tokens" label="总Token" min-width="140"></el-table-column>
<el-table-column prop="amount" label="预估费用" width="130">
<template slot-scope="scope">¥ {{ formatAmount(scope.row.amount) }}</template>
</el-table-column>
<el-table-column prop="usage_time" label="使用时间" min-width="170" show-overflow-tooltip></el-table-column>
<el-table :data="usageList" class="usage-table" style="width: 100%">
<el-table-column prop="modelName" label="模型名称" min-width="180"></el-table-column>
<el-table-column prop="calls" label="调用次数" width="120"></el-table-column>
<el-table-column prop="inputTokens" label="输入Token" min-width="140"></el-table-column>
<el-table-column prop="outputTokens" label="输出Token" min-width="140"></el-table-column>
<el-table-column prop="totalTokens" label="总Token" min-width="140"></el-table-column>
<el-table-column prop="cost" label="预估费用" width="120"></el-table-column>
<el-table-column prop="updatedAt" label="更新时间" min-width="170"></el-table-column>
</el-table>
<div class="table-pagination">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page="query.current_page"
:page-size="query.page_size"
:page-sizes="[10, 20, 50, 100]"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
></el-pagination>
</div>
</div>
</div>
</div>
</template>
<script>
import echarts from 'echarts'
import { reqTokenUsage } from '@/api/model/model'
export default {
name: 'TokenUsage',
data() {
return {
loading: false,
dateRange: [],
summary: {
request_count: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
amount: 0
},
statCards: [
{ label: '总消耗 Token', value: '0', desc: '当前筛选范围', type: 'primary', icon: 'el-icon-coin' },
{ label: '调用次数', value: '0', desc: '当前筛选范围', type: 'success', icon: 'el-icon-s-promotion' },
{ label: '预估费用', value: 0.00', desc: '按当前单价估算', type: 'warning', icon: 'el-icon-wallet' },
{ label: '输入/输出 Token', value: '0 / 0', desc: 'Prompt / Completion', type: 'purple', icon: 'el-icon-pie-chart' }
{ label: '总消耗 Token', value: '1,286,400', desc: '较昨日 +12.6%', type: 'primary' },
{ label: '调用次数', value: '3,482', desc: '今日累计调用', type: 'success' },
{ label: '预估费用', value: '¥ 128.64', desc: '按当前单价估算', type: 'warning' },
{ label: '活跃模型', value: '8', desc: '最近 7 天有调用', type: 'purple' }
],
usageList: [],
total: 0,
tokenRatioChart: null,
modelRankChart: null,
query:{
userid:this.getCurrentUserId(),
range:'week',
start_time:'',
end_time:'',
model: '',
current_page:1,
page_size:10
}
}
},
computed: {
rangeLabel() {
const labelMap = {
hour: '最近1小时',
day: '今天',
week: '最近一周'
}
return labelMap[this.query.range] || '当前范围'
},
filterTimeText() {
if (this.query.start_time && this.query.end_time) {
return `${this.query.start_time}${this.query.end_time}`
}
return this.rangeLabel
},
modelUsageRank() {
const maxTokens = Math.max(...this.usageList.map(item => Number(item.total_tokens || 0)), 0)
return this.usageList
.map(item => ({
model: item.model,
total_tokens: Number(item.total_tokens || 0),
percent: maxTokens ? Math.round((Number(item.total_tokens || 0) / maxTokens) * 100) : 0
}))
.sort((a, b) => b.total_tokens - a.total_tokens)
.slice(0, 5)
usageList: [
]
}
},
created() {
this.getTokenList()
},
mounted() {
this.initCharts()
this.renderCharts()
window.addEventListener('resize', this.resizeCharts)
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeCharts)
if (this.tokenRatioChart) {
this.tokenRatioChart.dispose()
this.tokenRatioChart = null
}
if (this.modelRankChart) {
this.modelRankChart.dispose()
this.modelRankChart = null
}
},
methods: {
getCurrentUserId() {
return sessionStorage.getItem('userId') || localStorage.getItem('userId') || ''
},
handleDateChange(value) {
if (value && value.length === 2) {
this.query.start_time = value[0]
this.query.end_time = value[1]
} else {
this.query.start_time = ''
this.query.end_time = ''
}
this.query.current_page = 1
this.getTokenList()
},
handleRangeChange() {
this.query.current_page = 1
this.getTokenList()
},
handleSearch() {
this.query.current_page = 1
this.getTokenList()
},
async getTokenList() {
this.query.userid = this.getCurrentUserId()
this.loading = true
try {
const res = await reqTokenUsage(this.query)
if(res.status === true) {
const data = res.data || {}
this.summary = {
...this.summary,
...(data.summary || {})
}
this.updateStatCards()
this.usageList = data.items || []
this.total = Number(data.total_count || 0)
this.query.current_page = Number(data.current_page || this.query.current_page)
this.query.page_size = Number(data.page_size || this.query.page_size)
this.$nextTick(this.renderCharts)
}
console.log('token用量',res);
} finally {
this.loading = false
}
},
updateStatCards() {
this.statCards = [
{ label: '总消耗 Token', value: this.formatNumber(this.summary.total_tokens), desc: '当前筛选范围', type: 'primary', icon: 'el-icon-coin' },
{ label: '调用次数', value: this.formatNumber(this.summary.request_count), desc: '当前筛选范围', type: 'success', icon: 'el-icon-s-promotion' },
{ label: '预估费用', value: `¥ ${this.formatAmount(this.summary.amount)}`, desc: '按当前单价估算', type: 'warning', icon: 'el-icon-wallet' },
{ label: '输入/输出 Token', value: `${this.formatNumber(this.summary.prompt_tokens)} / ${this.formatNumber(this.summary.completion_tokens)}`, desc: 'Prompt / Completion', type: 'purple', icon: 'el-icon-pie-chart' }
]
},
formatNumber(value) {
return Number(value || 0).toLocaleString()
},
formatAmount(value) {
return Number(value || 0).toFixed(6).replace(/0+$/, '').replace(/\.$/, '.00')
},
initCharts() {
if (this.$refs.tokenRatioChart && !this.tokenRatioChart) {
this.tokenRatioChart = echarts.init(this.$refs.tokenRatioChart)
}
if (this.$refs.modelRankChart && !this.modelRankChart) {
this.modelRankChart = echarts.init(this.$refs.modelRankChart)
}
},
renderCharts() {
if (!this.$refs.tokenRatioChart || !this.$refs.modelRankChart) return
this.initCharts()
this.renderTokenRatioChart()
this.renderModelRankChart()
},
renderTokenRatioChart() {
if (!this.tokenRatioChart) return
const promptTokens = Number(this.summary.prompt_tokens || 0)
const completionTokens = Number(this.summary.completion_tokens || 0)
const hasData = promptTokens || completionTokens
this.tokenRatioChart.setOption({
tooltip: {
trigger: 'item',
formatter: '{b}: {c} Token ({d}%)'
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#606266'
}
},
color: ['#409eff', '#67c23a'],
series: [
{
name: 'Token占比',
type: 'pie',
radius: ['52%', '72%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
label: {
formatter: '{b}\n{d}%'
},
data: hasData
? [
{ name: '输入Token', value: promptTokens },
{ name: '输出Token', value: completionTokens }
]
: [{ name: '暂无数据', value: 1 }],
itemStyle: {
borderColor: '#fff',
borderWidth: 3
}
}
]
})
},
renderModelRankChart() {
if (!this.modelRankChart) return
const rankList = this.modelUsageRank.slice().reverse()
this.modelRankChart.setOption({
grid: {
top: 12,
right: 24,
bottom: 20,
left: 92
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: params => {
const item = params && params[0]
return item ? `${item.name}<br/>${this.formatNumber(item.value)} Token` : ''
}
},
xAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
lineStyle: {
color: '#edf1f7'
}
}
},
yAxis: {
type: 'category',
data: rankList.map(item => item.model || '-'),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#606266',
width: 82,
overflow: 'truncate'
}
},
series: [
{
name: 'Token消耗',
type: 'bar',
barWidth: 12,
data: rankList.map(item => item.total_tokens),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#a78bfa' },
{ offset: 1, color: '#7c3aed' }
]),
barBorderRadius: [0, 8, 8, 0]
},
label: {
show: true,
position: 'right',
color: '#344054',
formatter: params => this.formatNumber(params.value)
}
}
]
})
},
resizeCharts() {
if (this.tokenRatioChart) this.tokenRatioChart.resize()
if (this.modelRankChart) this.modelRankChart.resize()
},
handleSizeChange(size) {
this.query.page_size = size
this.query.current_page = 1
this.getTokenList()
},
handleCurrentChange(page) {
this.query.current_page = page
this.getTokenList()
},
handleReset() {
this.dateRange = []
this.query = {
userid: this.getCurrentUserId(),
range: 'week',
start_time: '',
end_time: '',
model: '',
current_page: 1,
page_size: 10
}
this.getTokenList()
}
async getTokenList() {
const userid = this.getCurrentUserId()
const res = await reqTokenUsage({ userid:userid })
console.log('token用量',res);
}
}
}
</script>
@ -409,15 +92,13 @@ export default {
.token-usage-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(64, 158, 255, 0.16), transparent 34%),
linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 48%, #ffffff 100%);
background: linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 48%, #ffffff 100%);
}
.usage-shell {
min-height: calc(100vh - 48px);
padding: 24px;
background: rgba(255, 255, 255, 0.92);
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
@ -432,18 +113,7 @@ export default {
}
.page-header {
align-items: center;
padding: 20px 22px;
margin-bottom: 20px;
overflow: hidden;
color: #ffffff;
background: linear-gradient(135deg, #1e6fff 0%, #409eff 48%, #7c3aed 100%);
border-radius: 18px;
box-shadow: 0 14px 34px rgba(64, 158, 255, 0.22);
p {
color: rgba(255, 255, 255, 0.82);
}
margin-bottom: 24px;
}
.title-line {
@ -454,7 +124,7 @@ export default {
h2 {
margin: 0;
color: #ffffff;
color: #1f2d3d;
font-size: 24px;
}
}
@ -465,9 +135,9 @@ export default {
justify-content: center;
width: 36px;
height: 36px;
color: #ffffff;
color: #409eff;
font-size: 18px;
background: rgba(255, 255, 255, 0.18);
background: #eef5ff;
border-radius: 12px;
}
@ -479,26 +149,9 @@ export default {
}
.refresh-btn {
color: #1e6fff;
border: none;
border-radius: 10px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.range-badge {
padding: 7px 12px;
color: #ffffff;
font-size: 12px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@ -507,19 +160,10 @@ export default {
}
.stat-card {
position: relative;
overflow: hidden;
padding: 20px;
border-radius: 16px;
border: 1px solid #edf1f7;
background: #ffffff;
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(31, 45, 61, 0.08);
}
span,
em {
@ -536,130 +180,20 @@ export default {
font-size: 26px;
}
&:before {
position: absolute;
top: 0;
right: 0;
width: 86px;
height: 86px;
content: '';
background: rgba(255, 255, 255, 0.52);
border-radius: 0 0 0 86px;
}
&.primary {
background: linear-gradient(135deg, #eef5ff 0%, #ffffff 100%);
background: #eef5ff;
}
&.success {
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
background: #f0fdf4;
}
&.warning {
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
background: #fff7ed;
}
&.purple {
background: linear-gradient(135deg, #f5f3ff 0%, #ffffff 100%);
}
}
.stat-card-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
i {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
color: #409eff;
font-size: 18px;
background: rgba(64, 158, 255, 0.12);
border-radius: 12px;
}
}
.usage-dashboard {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 16px;
margin-bottom: 20px;
}
.dashboard-card {
padding: 22px;
border: 1px solid #edf1f7;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(31, 45, 61, 0.05);
}
.dashboard-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
margin: 0;
color: #1f2d3d;
font-size: 16px;
}
span {
color: #909399;
font-size: 12px;
}
}
.token-total {
color: #1f2d3d;
font-size: 34px;
font-weight: 700;
line-height: 1.2;
}
.token-ratio-card {
p {
margin: 6px 0 18px;
color: #909399;
font-size: 13px;
}
}
.chart-box {
width: 100%;
height: 240px;
}
.model-rank-chart {
height: 300px;
}
.ratio-list,
.rank-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.ratio-label,
.rank-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
strong {
color: #1f2d3d;
font-weight: 700;
background: #f5f3ff;
}
}
@ -667,8 +201,6 @@ export default {
padding: 22px;
border: 1px solid #edf1f7;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(31, 45, 61, 0.05);
}
.card-header {
@ -680,57 +212,15 @@ export default {
}
}
.filter-actions {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: #f6f8fb;
border: 1px solid #edf1f7;
border-radius: 12px;
/deep/ .el-select {
width: 110px;
}
/deep/ .el-input {
width: 160px;
}
/deep/ .el-input__inner {
border-color: transparent;
}
}
.usage-table {
border-radius: 12px;
overflow: hidden;
/deep/ .el-table__header th {
color: #475467;
font-weight: 700;
background: #f8fafc;
}
/deep/ .el-table__row:hover > td {
background: #f6faff;
}
}
.table-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
@media (max-width: 1100px) {
.stat-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-dashboard {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
@ -742,15 +232,5 @@ export default {
.card-header {
flex-direction: column;
}
.filter-actions {
align-items: flex-start;
flex-direction: column;
width: 100%;
/deep/ .el-date-editor {
width: 100%;
}
}
}
</style>