main #115
2829
b/all_table.sql
Normal file
2829
b/all_table.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1069,10 +1069,10 @@ async def finance_billing_overview(ns=None):
|
|||||||
include_sub = _parse_bool(ns.get('include_sub_reseller_customers'), True)
|
include_sub = _parse_bool(ns.get('include_sub_reseller_customers'), True)
|
||||||
only_accounted = _parse_bool(ns.get('only_accounted'), False)
|
only_accounted = _parse_bool(ns.get('only_accounted'), False)
|
||||||
try:
|
try:
|
||||||
max_bills = int(ns.get('max_bills', 5000) or 5000)
|
max_bills = int(ns.get('max_bills', 50000) or 50000)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
max_bills = 5000
|
max_bills = 50000
|
||||||
max_bills = max(100, min(max_bills, 20000))
|
max_bills = max(100, min(max_bills, 200000))
|
||||||
|
|
||||||
db = DBPools()
|
db = DBPools()
|
||||||
async with db.sqlorContext(DBNAME) as sor:
|
async with db.sqlorContext(DBNAME) as sor:
|
||||||
@ -1228,7 +1228,8 @@ async def finance_billing_overview(ns=None):
|
|||||||
return {'status': True, 'msg': 'ok', 'data': data}
|
return {'status': True, 'msg': 'ok', 'data': data}
|
||||||
|
|
||||||
|
|
||||||
_report = params_kw.get('report') or params_kw.get('api') or 'order_list'
|
# _report = params_kw.get('report') or params_kw.get('api') or 'order_list'
|
||||||
|
_report = None
|
||||||
if _report in ('overview', 'billing_overview', 'finance_billing_overview'):
|
if _report in ('overview', 'billing_overview', 'finance_billing_overview'):
|
||||||
ret = await finance_billing_overview(params_kw)
|
ret = await finance_billing_overview(params_kw)
|
||||||
elif _report in ('detail', 'order_detail'):
|
elif _report in ('detail', 'order_detail'):
|
||||||
@ -1236,3 +1237,4 @@ elif _report in ('detail', 'order_detail'):
|
|||||||
else:
|
else:
|
||||||
ret = await finance_order_report(params_kw)
|
ret = await finance_order_report(params_kw)
|
||||||
return ret
|
return ret
|
||||||
|
return ret
|
||||||
1239
b/bill/finance_order_report_overview.dspy
Normal file
1239
b/bill/finance_order_report_overview.dspy
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
# model_management 可写入字段(不含 id、created_at、updated_at)
|
# model_management 可写入字段(不含 id、created_at、updated_at)
|
||||||
_MODEL_FIELDS = (
|
_MODEL_FIELDS = (
|
||||||
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
|
'llmid', 'provider', 'model_name', 'display_name', 'model_logo', 'model_type',
|
||||||
'context_length', 'input_token_price', 'output_token_price',
|
'context_length', 'input_token_price', 'output_token_price',
|
||||||
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
||||||
'capabilities', 'limitations', 'highlights', 'is_active',
|
'capabilities', 'limitations', 'highlights', 'is_active',
|
||||||
|
|||||||
@ -5,7 +5,7 @@ def _escape(value):
|
|||||||
|
|
||||||
# 客户侧可见字段(不含 listing_status、is_active 等运营字段)
|
# 客户侧可见字段(不含 listing_status、is_active 等运营字段)
|
||||||
_CUSTOMER_MODEL_COLUMNS = """
|
_CUSTOMER_MODEL_COLUMNS = """
|
||||||
id, llmid, provider, model_name, display_name, model_type,
|
id, llmid, provider, model_name, display_name, model_logo, model_type,
|
||||||
context_length, input_token_price, output_token_price,
|
context_length, input_token_price, output_token_price,
|
||||||
cache_hit_input_price, billing_method, billing_unit,
|
cache_hit_input_price, billing_method, billing_unit,
|
||||||
capabilities, limitations, highlights, description, sort_order,is_active, experience
|
capabilities, limitations, highlights, description, sort_order,is_active, experience
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 可写入/更新的字段(不含 id、created_at、updated_at)
|
# 可写入/更新的字段(不含 id、created_at、updated_at)
|
||||||
_MODEL_FIELDS = (
|
_MODEL_FIELDS = (
|
||||||
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
|
'llmid', 'provider', 'model_name', 'display_name', 'model_logo', 'model_type',
|
||||||
'context_length', 'input_token_price', 'output_token_price',
|
'context_length', 'input_token_price', 'output_token_price',
|
||||||
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
||||||
'capabilities', 'limitations', 'highlights', 'is_active',
|
'capabilities', 'limitations', 'highlights', 'is_active',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# model_management 可写入字段(不含 id、created_at、updated_at)
|
# model_management 可写入字段(不含 id、created_at、updated_at)
|
||||||
_MODEL_FIELDS = (
|
_MODEL_FIELDS = (
|
||||||
'llmid', 'provider', 'model_name', 'display_name', 'model_type',
|
'llmid', 'provider', 'model_name', 'display_name', 'model_logo', 'model_type',
|
||||||
'context_length', 'input_token_price', 'output_token_price',
|
'context_length', 'input_token_price', 'output_token_price',
|
||||||
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
'cache_hit_input_price', 'billing_method', 'billing_unit',
|
||||||
'capabilities', 'limitations', 'highlights', 'is_active',
|
'capabilities', 'limitations', 'highlights', 'is_active',
|
||||||
|
|||||||
@ -1,3 +1,26 @@
|
|||||||
|
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):
|
def _escape(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@ -182,18 +205,74 @@ async def _enrich_usage_rows(sor, rows):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_customer_users(sor, orgid, customerid=None):
|
async def _resolve_scope_orgid(sor, user_orgid, user_role):
|
||||||
"""获取机构下客户及其用户映射。"""
|
"""
|
||||||
org_rows = await sor.R('organization', {'parentid': orgid, 'del_flg': '0'})
|
解析报表统计范围的机构 id。
|
||||||
if customerid:
|
运营/运营管理员:users.orgid 即所在机构 id。
|
||||||
org_rows = [row for row in org_rows if row.get('id') == customerid]
|
其他管理员:users.orgid 为用户机构 id,所在机构为 organization.parentid。
|
||||||
org_map = {row['id']: row for row in org_rows}
|
"""
|
||||||
|
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.id,organization.parentid = 机构 id。
|
||||||
|
管理/运营:users.orgid 直接等于机构 id。
|
||||||
|
邀请注册可能存在二级 organization(parentid 指向上级客户机构)。
|
||||||
|
"""
|
||||||
|
inst_esc = _escape(institution_orgid)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 = {}
|
user_map = {}
|
||||||
for oid in org_map:
|
for row in rows:
|
||||||
user_rows = await sor.R('users', {'orgid': oid, 'del_flg': '0'})
|
uid = row['id']
|
||||||
for user in user_rows:
|
oid = row.get('orgid')
|
||||||
user_map[user['id']] = user
|
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'),
|
||||||
|
}
|
||||||
return org_map, user_map
|
return org_map, user_map
|
||||||
|
|
||||||
|
|
||||||
@ -376,7 +455,6 @@ async def model_usage_user_report(ns={}):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'status': False, 'msg': '查询失败, %s' % str(e)}
|
return {'status': False, 'msg': '查询失败, %s' % str(e)}
|
||||||
|
|
||||||
|
|
||||||
async def model_usage_admin_report(ns={}):
|
async def model_usage_admin_report(ns={}):
|
||||||
"""
|
"""
|
||||||
管理员查看当前机构下所有客户的模型使用汇总。
|
管理员查看当前机构下所有客户的模型使用汇总。
|
||||||
@ -413,8 +491,8 @@ async def model_usage_admin_report(ns={}):
|
|||||||
if group_by and group_by not in ('hour', 'day', 'week'):
|
if group_by and group_by not in ('hour', 'day', 'week'):
|
||||||
return {'status': False, 'msg': 'group_by 仅支持 hour / day / week'}
|
return {'status': False, 'msg': 'group_by 仅支持 hour / day / week'}
|
||||||
|
|
||||||
page_size = int(ns.get('page_size', 20))
|
page_size = int(ns.get('page_size')) if ns.get('page_size') else 20
|
||||||
current_page = int(ns.get('current_page', 1))
|
current_page = int(ns.get('current_page')) if ns.get('current_page') else 1
|
||||||
offset = (current_page - 1) * page_size
|
offset = (current_page - 1) * page_size
|
||||||
|
|
||||||
db = DBPools()
|
db = DBPools()
|
||||||
@ -424,11 +502,12 @@ async def model_usage_admin_report(ns={}):
|
|||||||
if not user_rows:
|
if not user_rows:
|
||||||
return {'status': False, 'msg': '用户不存在'}
|
return {'status': False, 'msg': '用户不存在'}
|
||||||
|
|
||||||
orgid = user_rows[0].get('orgid')
|
user_orgid = user_rows[0].get('orgid')
|
||||||
user_role = await get_user_role({'userid': userid, 'sor': sor})
|
user_role = await get_user_role({'userid': userid, 'sor': sor})
|
||||||
if user_role not in ('管理员', '运营', '运营管理员'):
|
if user_role not in ('管理员', '运营', '运营管理员'):
|
||||||
return {'status': False, 'msg': '无权限,仅机构管理员可查看'}
|
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)
|
org_map, user_map = await _fetch_customer_users(sor, orgid, customerid)
|
||||||
user_ids = list(user_map.keys())
|
user_ids = list(user_map.keys())
|
||||||
if not user_ids:
|
if not user_ids:
|
||||||
|
|||||||
@ -316,8 +316,8 @@ async def model_usage_user_report(ns={}):
|
|||||||
if group_by and group_by not in ('hour', 'day', 'week'):
|
if group_by and group_by not in ('hour', 'day', 'week'):
|
||||||
return {'status': False, 'msg': 'group_by 仅支持 hour / day / week'}
|
return {'status': False, 'msg': 'group_by 仅支持 hour / day / week'}
|
||||||
|
|
||||||
page_size = int(ns.get('page_size', 20))
|
page_size = int(ns.get('page_size')) if ns.get('page_size') else 20
|
||||||
current_page = int(ns.get('current_page', 1))
|
current_page = int(ns.get('current_page')) if ns.get('current_page') else 1
|
||||||
offset = (current_page - 1) * page_size
|
offset = (current_page - 1) * page_size
|
||||||
|
|
||||||
db = DBPools()
|
db = DBPools()
|
||||||
|
|||||||
17
f/web-kboss/src/api/FinancialOverview/FinancialOverview.js
Normal file
17
f/web-kboss/src/api/FinancialOverview/FinancialOverview.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -126,19 +126,25 @@ export const reqTokenUsage = (params = {}) => {
|
|||||||
|
|
||||||
// 模型信息配置添加
|
// 模型信息配置添加
|
||||||
export const reqModelInfoConfig = (params = {}) => {
|
export const reqModelInfoConfig = (params = {}) => {
|
||||||
|
const isFormData = params instanceof FormData
|
||||||
return request({
|
return request({
|
||||||
url: '/cntoai/model_management_add.dspy',
|
url: '/cntoai/model_management_add.dspy',
|
||||||
method: 'get',
|
method: isFormData ? 'post' : 'get',
|
||||||
params
|
params: isFormData ? undefined : params,
|
||||||
|
data: isFormData ? params : undefined,
|
||||||
|
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型信息配置编辑(编辑时需要额外传 id)
|
// 模型信息配置编辑(编辑时需要额外传 id)
|
||||||
export const reqModelInfoConfigEdit = (params = {}) => {
|
export const reqModelInfoConfigEdit = (params = {}) => {
|
||||||
|
const isFormData = params instanceof FormData
|
||||||
return request({
|
return request({
|
||||||
url: '/cntoai/model_management_update.dspy',
|
url: '/cntoai/model_management_update.dspy',
|
||||||
method: 'get',
|
method: isFormData ? 'post' : 'get',
|
||||||
params
|
params: isFormData ? undefined : params,
|
||||||
|
data: isFormData ? params : undefined,
|
||||||
|
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,3 +156,12 @@ export const reqModelInfoConfigList = (params = {}) => {
|
|||||||
params
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 运营报表
|
||||||
|
export const reqOperationReport = (params = {}) => {
|
||||||
|
return request({
|
||||||
|
url: '/cntoai/model_usage_admin_report.dspy',
|
||||||
|
method: 'post',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -436,6 +436,44 @@ export const constantRoutes = [
|
|||||||
* 需要根据用户角色动态加载的路由
|
* 需要根据用户角色动态加载的路由
|
||||||
*/
|
*/
|
||||||
export const asyncRoutes = [
|
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",
|
path: "/modelManagement",
|
||||||
@ -539,29 +577,29 @@ export const asyncRoutes = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Token用量 - 一级菜单(所有登录用户都能看到)
|
// Token用量 - 一级菜单(所有登录用户都能看到)
|
||||||
// {
|
{
|
||||||
// path: "/tokenUsage",
|
path: "/tokenUsage",
|
||||||
// component: Layout,
|
component: Layout,
|
||||||
// meta: {
|
meta: {
|
||||||
// title: "Token用量",
|
title: "Token用量",
|
||||||
// fullPath: "/tokenUsage",
|
fullPath: "/tokenUsage",
|
||||||
// noCache: true,
|
noCache: true,
|
||||||
// icon: "el-icon-data-line"
|
icon: "el-icon-data-line"
|
||||||
// },
|
},
|
||||||
// children: [
|
children: [
|
||||||
// {
|
{
|
||||||
// path: "",
|
path: "",
|
||||||
// component: () => import('@/views/tokenUsage/index.vue'),
|
component: () => import('@/views/tokenUsage/index.vue'),
|
||||||
// name: 'TokenUsage',
|
name: 'TokenUsage',
|
||||||
// meta: {
|
meta: {
|
||||||
// title: "Token用量",
|
title: "Token用量",
|
||||||
// fullPath: "/tokenUsage",
|
fullPath: "/tokenUsage",
|
||||||
// noCache: true,
|
noCache: true,
|
||||||
// icon: "el-icon-data-line"
|
icon: "el-icon-data-line"
|
||||||
// }
|
}
|
||||||
// },
|
},
|
||||||
// ]
|
]
|
||||||
// },
|
},
|
||||||
// 模型体验
|
// 模型体验
|
||||||
{
|
{
|
||||||
path: "/modelExperience",
|
path: "/modelExperience",
|
||||||
@ -2049,9 +2087,19 @@ export const asyncRoutes = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/finance", component: Layout, redirect: "/finance", meta: {
|
path: "/finance", component: Layout, redirect: "/finance/financialOverview", meta: {
|
||||||
title: "财务", icon: "el-icon-s-data", noCache: true, fullPath: "/finance",
|
title: "财务", icon: "el-icon-s-data", noCache: true, fullPath: "/finance",
|
||||||
}, children: [{
|
}, children: [{
|
||||||
|
path: "financialOverview",
|
||||||
|
component: () => import("@/views/finance/financialOverview"),
|
||||||
|
name: "FinancialOverview",
|
||||||
|
meta: {
|
||||||
|
title: "财务概览",
|
||||||
|
fullPath: "/finance/financialOverview",
|
||||||
|
icon: "el-icon-data-analysis",
|
||||||
|
roles: ["财务"]
|
||||||
|
},
|
||||||
|
}, {
|
||||||
path: "supplierSettlement",
|
path: "supplierSettlement",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
component: () => import(
|
component: () => import(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const MOBILE_UA_REGEXP = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Ope
|
|||||||
// 项目里用到的固定角色名,集中放这里,避免代码里到处写字符串。
|
// 项目里用到的固定角色名,集中放这里,避免代码里到处写字符串。
|
||||||
const CUSTOMER_ROLE = '客户';
|
const CUSTOMER_ROLE = '客户';
|
||||||
const OPERATION_ROLE = '运营';
|
const OPERATION_ROLE = '运营';
|
||||||
|
const FINANCE_ROLE = '财务';
|
||||||
|
|
||||||
// 这个用户能看到订单管理里的特殊子菜单,比如历史订单和订单详情。
|
// 这个用户能看到订单管理里的特殊子菜单,比如历史订单和订单详情。
|
||||||
const SPECIAL_ORDER_USER = 'ZhipuHZ';
|
const SPECIAL_ORDER_USER = 'ZhipuHZ';
|
||||||
@ -19,6 +20,9 @@ const COMMON_ROUTE_PATHS = ['/product', '/tokenManagement', '/tokenUsage', '/mod
|
|||||||
// 运营角色需要额外补出来的菜单。
|
// 运营角色需要额外补出来的菜单。
|
||||||
const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/modelInfoConfig', '/operationReport'];
|
const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/modelInfoConfig', '/operationReport'];
|
||||||
|
|
||||||
|
// 财务角色需要额外补出来的菜单。
|
||||||
|
const FINANCE_EXTRA_ROUTE_PATHS = ['/financialOverview'];
|
||||||
|
|
||||||
// 普通客户账号默认要补出来的基础菜单。
|
// 普通客户账号默认要补出来的基础菜单。
|
||||||
const BASE_USER_ROUTE_PATHS = ['/orderManagement', '/resourceManagement'];
|
const BASE_USER_ROUTE_PATHS = ['/orderManagement', '/resourceManagement'];
|
||||||
|
|
||||||
@ -336,6 +340,15 @@ function addOperationRoutes(accessedRoutes, routes, userRoles = [], deviceType =
|
|||||||
return appendMissingRoutes(accessedRoutes, getRoutesByPath(routes, OPERATION_EXTRA_ROUTE_PATHS));
|
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市集是公共菜单,所有登录用户都要能看到。
|
// token市集是公共菜单,所有登录用户都要能看到。
|
||||||
function addCommonRoutes(accessedRoutes, routes, deviceType = 'pc') {
|
function addCommonRoutes(accessedRoutes, routes, deviceType = 'pc') {
|
||||||
const commonRoutes = getRoutesByPath(routes, COMMON_ROUTE_PATHS)
|
const commonRoutes = getRoutesByPath(routes, COMMON_ROUTE_PATHS)
|
||||||
@ -499,6 +512,9 @@ const actions = {
|
|||||||
// 6. 运营角色额外补模型管理。
|
// 6. 运营角色额外补模型管理。
|
||||||
accessedRoutes = addOperationRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType);
|
accessedRoutes = addOperationRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType);
|
||||||
|
|
||||||
|
// 6.1 财务角色额外补财务菜单。
|
||||||
|
accessedRoutes = addFinanceRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType);
|
||||||
|
|
||||||
// 7. 最后处理订单管理里的特殊子菜单权限。
|
// 7. 最后处理订单管理里的特殊子菜单权限。
|
||||||
accessedRoutes = filterOrderChildrenByUser(accessedRoutes, username);
|
accessedRoutes = filterOrderChildrenByUser(accessedRoutes, username);
|
||||||
|
|
||||||
|
|||||||
669
f/web-kboss/src/views/finance/billingStatistics/index.vue
Normal file
669
f/web-kboss/src/views/finance/billingStatistics/index.vue
Normal file
@ -0,0 +1,669 @@
|
|||||||
|
<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>
|
||||||
736
f/web-kboss/src/views/finance/financialOverview/index.vue
Normal file
736
f/web-kboss/src/views/finance/financialOverview/index.vue
Normal file
@ -0,0 +1,736 @@
|
|||||||
|
<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>
|
||||||
@ -44,10 +44,23 @@
|
|||||||
<el-option label="多模态" value="多模态"></el-option>
|
<el-option label="多模态" value="多模态"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="供应商LOGO">
|
<el-form-item label="模型LOGO">
|
||||||
<div class="logo-upload">
|
<div class="logo-upload">
|
||||||
<el-button size="small" icon="el-icon-upload2">选择文件</el-button>
|
<input
|
||||||
<span>{{ editForm.logo || '未选择任何文件' }}</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="接口地址" class="span-2">
|
<el-form-item label="接口地址" class="span-2">
|
||||||
@ -260,6 +273,9 @@ const DEFAULT_EDIT_FORM = {
|
|||||||
type: '',
|
type: '',
|
||||||
provider: '',
|
provider: '',
|
||||||
logo: '',
|
logo: '',
|
||||||
|
modelLogo: '',
|
||||||
|
modelLogoFile: null,
|
||||||
|
modelLogoPreview: '',
|
||||||
apiUrl: '',
|
apiUrl: '',
|
||||||
description: '',
|
description: '',
|
||||||
contextLength: '',
|
contextLength: '',
|
||||||
@ -321,6 +337,7 @@ export default {
|
|||||||
const apiUrl = this.toFormValue(apiDoc.api_url || row.api_url || '')
|
const apiUrl = this.toFormValue(apiDoc.api_url || row.api_url || '')
|
||||||
const curlCode = this.toTextareaValue(apiDoc.curl_code || row.curl_code || '')
|
const curlCode = this.toTextareaValue(apiDoc.curl_code || row.curl_code || '')
|
||||||
const pythonCode = this.toTextareaValue(apiDoc.python_code || row.python_code || '')
|
const pythonCode = this.toTextareaValue(apiDoc.python_code || row.python_code || '')
|
||||||
|
const modelLogo = row.model_logo || row.modelLogo || row.provider_logo || ''
|
||||||
|
|
||||||
this.editForm = {
|
this.editForm = {
|
||||||
...defaultForm,
|
...defaultForm,
|
||||||
@ -330,7 +347,10 @@ export default {
|
|||||||
displayName: row.display_name || row.displayName || row.model_name || row.name || '',
|
displayName: row.display_name || row.displayName || row.model_name || row.name || '',
|
||||||
type: row.model_type || row.type || '',
|
type: row.model_type || row.type || '',
|
||||||
provider: row.provider || '',
|
provider: row.provider || '',
|
||||||
logo: row.provider_logo || row.logo || '',
|
logo: row.logo || '',
|
||||||
|
modelLogo,
|
||||||
|
modelLogoFile: null,
|
||||||
|
modelLogoPreview: modelLogo,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
requestUrl: apiUrl,
|
requestUrl: apiUrl,
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
@ -376,7 +396,7 @@ export default {
|
|||||||
this.submitLoading = true
|
this.submitLoading = true
|
||||||
try {
|
try {
|
||||||
const res = isEdit
|
const res = isEdit
|
||||||
? await reqModelInfoConfigEdit({ ...params, id: this.editForm.id })
|
? await reqModelInfoConfigEdit(this.appendEditId(params))
|
||||||
: await reqModelInfoConfig(params)
|
: await reqModelInfoConfig(params)
|
||||||
if (res && res.status) {
|
if (res && res.status) {
|
||||||
this.$message.success(res.msg || (isEdit ? '模型信息编辑成功' : '模型信息添加成功'))
|
this.$message.success(res.msg || (isEdit ? '模型信息编辑成功' : '模型信息添加成功'))
|
||||||
@ -392,7 +412,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
buildSubmitParams() {
|
buildSubmitParams() {
|
||||||
return {
|
const params = {
|
||||||
provider: this.editForm.provider,
|
provider: this.editForm.provider,
|
||||||
model_name: this.editForm.modelName,
|
model_name: this.editForm.modelName,
|
||||||
display_name: this.editForm.displayName,
|
display_name: this.editForm.displayName,
|
||||||
@ -408,10 +428,68 @@ export default {
|
|||||||
highlights: JSON.stringify(this.filterNameValueList(this.editForm.highlights)),
|
highlights: JSON.stringify(this.filterNameValueList(this.editForm.highlights)),
|
||||||
description: this.editForm.description,
|
description: this.editForm.description,
|
||||||
experience: this.editForm.experience,
|
experience: this.editForm.experience,
|
||||||
|
model_logo: this.editForm.modelLogo,
|
||||||
api_url: this.editForm.apiUrl || this.editForm.requestUrl,
|
api_url: this.editForm.apiUrl || this.editForm.requestUrl,
|
||||||
curl_code: this.editForm.curlCode,
|
curl_code: this.editForm.curlCode,
|
||||||
python_code: this.editForm.pythonCode
|
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() {
|
buildCapabilities() {
|
||||||
return [
|
return [
|
||||||
@ -775,6 +853,36 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 32px;
|
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 {
|
span {
|
||||||
color: #98a2b3;
|
color: #98a2b3;
|
||||||
|
|||||||
@ -100,63 +100,7 @@ export default {
|
|||||||
modelType: '',
|
modelType: '',
|
||||||
provider: ''
|
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: {
|
computed: {
|
||||||
|
|||||||
@ -2,196 +2,529 @@
|
|||||||
<div class="operation-report-page">
|
<div class="operation-report-page">
|
||||||
<div class="report-header">
|
<div class="report-header">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="title-line">
|
||||||
|
<span class="title-icon">
|
||||||
|
<i class="el-icon-s-data"></i>
|
||||||
|
</span>
|
||||||
<h2>运营报表</h2>
|
<h2>运营报表</h2>
|
||||||
<p>模型使用与计费数据概览</p>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button size="small" icon="el-icon-document" @click="exportReport">导出报表</el-button>
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat-card purple">
|
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
|
||||||
<div class="stat-title">活跃用户</div>
|
<div class="stat-card-head">
|
||||||
<div class="stat-value">{{ statCards.activeUsers }}</div>
|
<div class="stat-title">{{ item.label }}</div>
|
||||||
|
<i :class="item.icon"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card green">
|
<div class="stat-value">{{ item.value }}</div>
|
||||||
<div class="stat-title">Token消耗</div>
|
<div class="stat-desc">{{ item.desc }}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" class="filter-card">
|
<el-card shadow="never" class="filter-card">
|
||||||
<el-form :inline="true" :model="filterForm">
|
<div class="filter-bar">
|
||||||
<el-form-item label="用户">
|
<div>
|
||||||
<el-input v-model="filterForm.userName" placeholder="搜索用户名" clearable />
|
<h3>筛选条件</h3>
|
||||||
</el-form-item>
|
<p>按模型和时间范围查询运营用量。</p>
|
||||||
<el-form-item label="模型">
|
</div>
|
||||||
<el-select v-model="filterForm.modelName" placeholder="全部模型" clearable>
|
<el-form :inline="true" :model="query" class="filter-form">
|
||||||
<el-option v-for="item in modelOptions" :key="item" :label="item" :value="item" />
|
<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-select>
|
||||||
</el-form-item>
|
<el-input
|
||||||
<el-form-item label="支付方式">
|
v-model="query.model"
|
||||||
<el-select v-model="filterForm.paymentMethod" placeholder="全部" clearable>
|
size="small"
|
||||||
<el-option label="支付宝" value="支付宝" />
|
clearable
|
||||||
<el-option label="微信支付" value="微信支付" />
|
placeholder="搜索模型"
|
||||||
</el-select>
|
@keyup.enter.native="handleSearch"
|
||||||
</el-form-item>
|
@clear="handleSearch"
|
||||||
<el-form-item label="使用时间">
|
></el-input>
|
||||||
<el-date-picker v-model="filterForm.date" type="date" placeholder="年/月/日" value-format="yyyy-MM-dd" />
|
<el-input
|
||||||
</el-form-item>
|
v-model="query.customerid"
|
||||||
<el-form-item>
|
size="small"
|
||||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
clearable
|
||||||
<el-button @click="resetSearch">重置</el-button>
|
placeholder="搜索用户ID"
|
||||||
</el-form-item>
|
@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>
|
</el-form>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never" class="table-card">
|
<el-card shadow="never" class="table-card">
|
||||||
<el-table :data="pagedList" style="width: 100%">
|
<div class="table-title">
|
||||||
<el-table-column type="index" label="序号" width="70" />
|
<div>
|
||||||
<el-table-column prop="userId" label="用户ID" min-width="110" />
|
<h3>用量明细</h3>
|
||||||
<el-table-column prop="userName" label="用户名" min-width="100" />
|
<p>共 {{ total }} 条记录</p>
|
||||||
<el-table-column prop="modelName" label="使用模型" min-width="140">
|
</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>
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-tag type="info" size="mini">{{ scope.row.modelName }}</el-tag>
|
<el-tag size="mini" type="info">{{ scope.row.model || '-' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="useTime" label="使用时间" min-width="160" />
|
<el-table-column prop="request_count" label="请求次数" width="100"></el-table-column>
|
||||||
<el-table-column prop="inputToken" label="输入TOKEN" min-width="110" />
|
<el-table-column prop="prompt_tokens" label="输入Token" min-width="110">
|
||||||
<el-table-column prop="outputToken" label="输出TOKEN" min-width="110" />
|
<template slot-scope="scope">{{ formatNumber(scope.row.prompt_tokens) }}</template>
|
||||||
<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>
|
||||||
<el-table-column prop="balance" label="账户余额(元)" min-width="120" />
|
<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>
|
</el-table>
|
||||||
|
|
||||||
<div class="pager-wrap">
|
<div class="pager-wrap">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
background
|
||||||
layout="prev, pager, next, total"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
:total="filteredList.length"
|
:total="total"
|
||||||
:page-size="pageSize"
|
:page-size="query.page_size"
|
||||||
:current-page.sync="currentPage"
|
:current-page="query.current_page"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
@current-change="handlePageChange"
|
@current-change="handlePageChange"
|
||||||
/>
|
></el-pagination>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { reqOperationReport } from '@/api/model/model'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "OperationReport",
|
name: 'OperationReport',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pageSize: 10,
|
loading: false,
|
||||||
currentPage: 1,
|
dateRange: [],
|
||||||
filterForm: {
|
summary: {
|
||||||
userName: "",
|
request_count: 0,
|
||||||
modelName: "",
|
prompt_tokens: 0,
|
||||||
paymentMethod: "",
|
completion_tokens: 0,
|
||||||
date: ""
|
total_tokens: 0,
|
||||||
|
amount: 0
|
||||||
},
|
},
|
||||||
reportList: [
|
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" },
|
total: 0,
|
||||||
{ 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" },
|
timeText: '',
|
||||||
{ 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" },
|
query: {
|
||||||
{ 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" },
|
range: 'week',
|
||||||
{ 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" },
|
start_time: '',
|
||||||
{ 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" }
|
end_time: '',
|
||||||
]
|
username: '',
|
||||||
};
|
model: '',
|
||||||
|
customerid: '',
|
||||||
|
current_page: 1,
|
||||||
|
page_size: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
statCards() {
|
statCards() {
|
||||||
return {
|
return [
|
||||||
activeUsers: "1,286",
|
{ label: '请求次数', value: this.formatNumber(this.summary.request_count), desc: '当前筛选范围', type: 'purple', icon: 'el-icon-s-promotion' },
|
||||||
tokenUsage: "3.2M",
|
{ 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' },
|
||||||
totalFee: "38,642"
|
{ label: 'Token总费用', value: `¥${this.formatAmount(this.summary.amount)}`, desc: '按调用记录汇总', type: 'orange', icon: 'el-icon-wallet' }
|
||||||
};
|
]
|
||||||
},
|
},
|
||||||
modelOptions() {
|
filterTimeText() {
|
||||||
return [...new Set(this.reportList.map(item => item.modelName))];
|
if (this.query.start_time && this.query.end_time) {
|
||||||
},
|
return `${this.query.start_time} 至 ${this.query.end_time}`
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
const labelMap = {
|
||||||
|
hour: '最近1小时',
|
||||||
|
day: '今天',
|
||||||
|
week: '最近一周'
|
||||||
|
}
|
||||||
|
return labelMap[this.query.range] || '当前范围'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.getReportList()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleSearch() {
|
async getReportList() {
|
||||||
this.currentPage = 1;
|
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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resetSearch() {
|
handleDateChange(value) {
|
||||||
this.filterForm = { userName: "", modelName: "", paymentMethod: "", date: "" };
|
if (value && value.length === 2) {
|
||||||
this.currentPage = 1;
|
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()
|
||||||
|
},
|
||||||
|
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()
|
||||||
},
|
},
|
||||||
handlePageChange(page) {
|
handlePageChange(page) {
|
||||||
this.currentPage = page;
|
this.query.current_page = page
|
||||||
|
this.getReportList()
|
||||||
},
|
},
|
||||||
exportReport() {
|
formatNumber(value) {
|
||||||
this.$message.success("报表导出任务已提交");
|
return Number(value || 0).toLocaleString()
|
||||||
|
},
|
||||||
|
formatAmount(value) {
|
||||||
|
return Number(value || 0).toFixed(6).replace(/0+$/, '').replace(/\.$/, '.00')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.operation-report-page {
|
.operation-report-page {
|
||||||
padding: 20px;
|
min-height: 100vh;
|
||||||
background: #f5f7fb;
|
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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-header {
|
.report-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
padding: 20px 22px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
h2 { margin: 0; font-size: 28px; }
|
overflow: hidden;
|
||||||
p { margin: 6px 0 0; color: #8b95a7; }
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: #fff;
|
position: relative;
|
||||||
border: 1px solid #eceff5;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
padding: 20px;
|
||||||
padding: 18px 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%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.stat-title { color: #5f6b7d; margin-bottom: 10px; }
|
|
||||||
.stat-value { font-size: 38px; font-weight: 700; line-height: 1; }
|
.stat-card-head {
|
||||||
.purple .stat-value { color: #7f56d9; }
|
position: relative;
|
||||||
.green .stat-value { color: #16a34a; }
|
z-index: 1;
|
||||||
.orange .stat-value { color: #ea580c; }
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-card,
|
.filter-card,
|
||||||
.table-card {
|
.table-card {
|
||||||
border: 1px solid #eceff5;
|
margin-bottom: 16px;
|
||||||
border-radius: 12px;
|
background: #ffffff;
|
||||||
margin-bottom: 14px;
|
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-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 {
|
.pager-wrap {
|
||||||
margin-top: 14px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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>
|
</style>
|
||||||
|
|||||||
@ -116,37 +116,65 @@
|
|||||||
@click="goModelDetail(product)"
|
@click="goModelDetail(product)"
|
||||||
>
|
>
|
||||||
<div class="token-card-top">
|
<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>
|
<h3>{{ product.display_name || product.model_name }}</h3>
|
||||||
<!-- <span v-if="product.sort_order <= 10" class="token-new-badge">NEW</span> -->
|
<p>{{ product.provider || product.model_name || '-' }}</p>
|
||||||
<!-- <i class="el-icon-more token-more"></i> -->
|
|
||||||
</div>
|
</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>
|
||||||
<div class="token-price-line">
|
|
||||||
<span>输入 ¥{{ formatTokenPrice(product.input_token_price) }}/千Token</span>
|
<div class="token-price-grid">
|
||||||
<span>输出 ¥{{ formatTokenPrice(product.output_token_price) }}/千Token</span>
|
<div class="token-price-item">
|
||||||
|
<span>输入</span>
|
||||||
|
<strong>¥{{ formatTokenPrice(product.input_token_price) }}</strong>
|
||||||
|
<em>元/百万 tokens</em>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-meta">
|
<div class="token-price-item">
|
||||||
<span class="token-provider-avatar">{{ getProviderInitial(product.provider) }}</span>
|
<span>输出</span>
|
||||||
<span>{{ product.provider || '-' }}</span>
|
<strong>¥{{ formatTokenPrice(product.output_token_price) }}</strong>
|
||||||
|
<em>元/百万 tokens</em>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-actions">
|
<div class="token-price-item">
|
||||||
<button @click.stop="goModelApiDocument(product)">
|
<span>缓存读</span>
|
||||||
<i class="el-icon-document"></i>
|
<strong>¥{{ formatTokenPrice(product.cache_hit_input_price) }}</strong>
|
||||||
API文档
|
<em>元/百万 tokens</em>
|
||||||
</button>
|
</div>
|
||||||
|
<div class="token-price-item">
|
||||||
|
<span>缓存创建</span>
|
||||||
|
<strong>¥{{ formatTokenPrice(product.input_token_price) }}</strong>
|
||||||
|
<em>元/百万 tokens</em>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="token-hover-actions">
|
||||||
<button
|
<button
|
||||||
class="experience"
|
class="primary-action"
|
||||||
:class="{ disabled: !isModelExperienceEnabled(product) }"
|
:class="{ disabled: !isModelExperienceEnabled(product) }"
|
||||||
:disabled="!isModelExperienceEnabled(product)"
|
:disabled="!isModelExperienceEnabled(product)"
|
||||||
@click.stop="goModelExperience(product)"
|
@click.stop="goModelExperience(product)"
|
||||||
>
|
>
|
||||||
<i class="el-icon-video-play"></i>
|
<i class="el-icon-video-play"></i>
|
||||||
体验
|
快速体验
|
||||||
|
</button>
|
||||||
|
<button @click.stop="goModelApiDocument(product)">
|
||||||
|
<i class="el-icon-document"></i>
|
||||||
|
API参考
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -202,6 +230,20 @@ import { reqNavList, reqNewHomeSync, reqNewHomeFestival } from "@/api/newHome";
|
|||||||
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
|
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
|
||||||
import TopBox from '@/views/homePage/components/topBox/index.vue'
|
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 {
|
export default {
|
||||||
name: "ProductServicePage",
|
name: "ProductServicePage",
|
||||||
components: {
|
components: {
|
||||||
@ -219,7 +261,8 @@ export default {
|
|||||||
tokenModelTypeList: [],
|
tokenModelTypeList: [],
|
||||||
tokenProviderList: [],
|
tokenProviderList: [],
|
||||||
tokenActiveModelType: '',
|
tokenActiveModelType: '',
|
||||||
tokenActiveProvider: ''
|
tokenActiveProvider: "",
|
||||||
|
IMG_URL: getImageUrlPrefix(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -359,6 +402,61 @@ export default {
|
|||||||
return provider ? provider.slice(0, 1) : 'M';
|
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 时按钮置灰禁用。
|
// experience 为 1 才允许模型体验,0 时按钮置灰禁用。
|
||||||
isModelExperienceEnabled(model) {
|
isModelExperienceEnabled(model) {
|
||||||
return Number(model && model.experience) === 1;
|
return Number(model && model.experience) === 1;
|
||||||
@ -1293,42 +1391,86 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.token-market-grid {
|
.token-market-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-market-card {
|
.token-market-card {
|
||||||
flex: 0 0 calc((100% - 28px) / 3);
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px;
|
min-height: 224px;
|
||||||
|
padding: 12px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #edf1f7;
|
border: 1px solid #dfe7f3;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 10px 24px rgba(31, 45, 61, 0.05);
|
box-shadow: 0 8px 20px rgba(31, 45, 61, 0.08);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #d7e4f5;
|
border-color: #8bbcff;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 16px 32px rgba(31, 45, 61, 0.08);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-card-top {
|
.token-card-top {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
min-height: 38px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.token-title-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
flex: 1;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #1f2d3d;
|
color: #111827;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.35;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1346,70 +1488,164 @@ export default {
|
|||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-tags {
|
.token-price-grid {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-wrap: wrap;
|
z-index: 1;
|
||||||
gap: 6px;
|
display: grid;
|
||||||
padding-bottom: 10px;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
border-bottom: 1px dashed #d8e0ee;
|
}
|
||||||
|
|
||||||
|
.token-price-item {
|
||||||
|
span,
|
||||||
|
em {
|
||||||
|
display: block;
|
||||||
|
color: #6b7890;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
padding: 2px 6px;
|
margin-bottom: 2px;
|
||||||
color: #667085;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: #f8fafc;
|
}
|
||||||
border: 1px solid #edf1f7;
|
|
||||||
border-radius: 6px;
|
strong {
|
||||||
|
display: block;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-price-line {
|
.token-description {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-wrap: wrap;
|
z-index: 1;
|
||||||
gap: 10px;
|
min-height: 58px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 12px;
|
||||||
color: #1f2937;
|
color: #728096;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-meta {
|
.token-card-footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: space-between;
|
||||||
margin-bottom: 10px;
|
gap: 8px;
|
||||||
color: #667085;
|
min-width: 0;
|
||||||
font-size: 12px;
|
margin-top: auto;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-feature-tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-provider-avatar {
|
.token-provider-avatar {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 16px;
|
flex: 0 0 36px;
|
||||||
height: 16px;
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 10px;
|
font-size: 16px;
|
||||||
background: #1e6fff;
|
font-weight: 700;
|
||||||
border-radius: 4px;
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-actions {
|
.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;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
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 {
|
button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 8px;
|
||||||
color: #475467;
|
color: #475467;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d8e0ee;
|
border: 1px solid #d8e0ee;
|
||||||
border-radius: 7px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
@ -1417,30 +1653,32 @@ export default {
|
|||||||
color: #1e6fff;
|
color: #1e6fff;
|
||||||
border-color: #9ec5ff;
|
border-color: #9ec5ff;
|
||||||
background: #f4f8ff;
|
background: #f4f8ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(30, 111, 255, 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.experience {
|
.primary-action {
|
||||||
color: #4f46e5;
|
color: #ffffff;
|
||||||
border-color: #c7d2fe;
|
background: #1e6fff;
|
||||||
|
border-color: #1e6fff;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #4338ca;
|
color: #ffffff;
|
||||||
border-color: #a5b4fc;
|
background: #155eef;
|
||||||
background: #eef2ff;
|
border-color: #155eef;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled,
|
&.disabled,
|
||||||
&:disabled {
|
&:disabled {
|
||||||
color: #98a2b3;
|
color: #ffffff;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background: #f3f4f6;
|
background: #cbd5e1;
|
||||||
border-color: #e5e7eb;
|
border-color: #cbd5e1;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #98a2b3;
|
color: #ffffff;
|
||||||
background: #f3f4f6;
|
background: #cbd5e1;
|
||||||
border-color: #e5e7eb;
|
border-color: #cbd5e1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1618,14 +1856,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-content .main-content .token-market-card {
|
.product-content .main-content .token-market-grid {
|
||||||
flex-basis: calc((100% - 14px) / 2);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.product-content .main-content .token-market-card {
|
.product-content .main-content .token-market-grid {
|
||||||
flex-basis: 100%;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="token-usage-page">
|
<div class="token-usage-page">
|
||||||
<div class="usage-shell">
|
<div class="usage-shell">
|
||||||
<div class="page-header">
|
<!-- <div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="title-line">
|
<div class="title-line">
|
||||||
<span class="title-icon">
|
<span class="title-icon">
|
||||||
@ -11,78 +11,395 @@
|
|||||||
</div>
|
</div>
|
||||||
<p>查看模型调用 Token 消耗、调用次数和费用趋势。</p>
|
<p>查看模型调用 Token 消耗、调用次数和费用趋势。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button size="small" icon="el-icon-refresh" class="refresh-btn">刷新</el-button>
|
<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> -->
|
||||||
|
|
||||||
|
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
|
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
|
||||||
|
<div class="stat-card-head">
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
|
<i :class="item.icon"></i>
|
||||||
|
</div>
|
||||||
<strong>{{ item.value }}</strong>
|
<strong>{{ item.value }}</strong>
|
||||||
<em>{{ item.desc }}</em>
|
<em>{{ item.desc }}</em>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="content-card">
|
<div class="content-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>用量明细</h3>
|
<h3>用量明细</h3>
|
||||||
<p>按模型维度统计 Token 输入、输出与费用。</p>
|
<p>按模型维度统计 Token 输入、输出与费用。</p>
|
||||||
</div>
|
</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
|
<el-date-picker
|
||||||
v-model="dateRange"
|
v-model="dateRange"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
size="small"
|
size="small"
|
||||||
|
value-format="yyyy-MM-dd"
|
||||||
range-separator="至"
|
range-separator="至"
|
||||||
start-placeholder="开始日期"
|
start-placeholder="开始日期"
|
||||||
end-placeholder="结束日期"
|
end-placeholder="结束日期"
|
||||||
|
@change="handleDateChange"
|
||||||
></el-date-picker>
|
></el-date-picker>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table :data="usageList" class="usage-table" style="width: 100%">
|
<el-table v-loading="loading" :data="usageList" class="usage-table" style="width: 100%">
|
||||||
<el-table-column prop="modelName" label="模型名称" min-width="180"></el-table-column>
|
<el-table-column prop="model" label="模型名称" min-width="180" show-overflow-tooltip></el-table-column>
|
||||||
<el-table-column prop="calls" label="调用次数" width="120"></el-table-column>
|
<el-table-column prop="request_count" label="调用次数" width="120"></el-table-column>
|
||||||
<el-table-column prop="inputTokens" label="输入Token" min-width="140"></el-table-column>
|
<el-table-column prop="prompt_tokens" 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="completion_tokens" 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="total_tokens" label="总Token" min-width="140"></el-table-column>
|
||||||
<el-table-column prop="cost" label="预估费用" width="120"></el-table-column>
|
<el-table-column prop="amount" label="预估费用" width="130">
|
||||||
<el-table-column prop="updatedAt" label="更新时间" min-width="170"></el-table-column>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import echarts from 'echarts'
|
||||||
import { reqTokenUsage } from '@/api/model/model'
|
import { reqTokenUsage } from '@/api/model/model'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TokenUsage',
|
name: 'TokenUsage',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
loading: false,
|
||||||
dateRange: [],
|
dateRange: [],
|
||||||
|
summary: {
|
||||||
|
request_count: 0,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
amount: 0
|
||||||
|
},
|
||||||
statCards: [
|
statCards: [
|
||||||
{ label: '总消耗 Token', value: '1,286,400', desc: '较昨日 +12.6%', type: 'primary' },
|
{ label: '总消耗 Token', value: '0', desc: '当前筛选范围', type: 'primary', icon: 'el-icon-coin' },
|
||||||
{ label: '调用次数', value: '3,482', desc: '今日累计调用', type: 'success' },
|
{ label: '调用次数', value: '0', desc: '当前筛选范围', type: 'success', icon: 'el-icon-s-promotion' },
|
||||||
{ label: '预估费用', value: '¥ 128.64', desc: '按当前单价估算', type: 'warning' },
|
{ label: '预估费用', value: '¥ 0.00', desc: '按当前单价估算', type: 'warning', icon: 'el-icon-wallet' },
|
||||||
{ label: '活跃模型', value: '8', desc: '最近 7 天有调用', type: 'purple' }
|
{ label: '输入/输出 Token', value: '0 / 0', desc: 'Prompt / Completion', type: 'purple', icon: 'el-icon-pie-chart' }
|
||||||
],
|
],
|
||||||
usageList: [
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.getTokenList()
|
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: {
|
methods: {
|
||||||
getCurrentUserId() {
|
getCurrentUserId() {
|
||||||
return sessionStorage.getItem('userId') || localStorage.getItem('userId') || ''
|
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() {
|
async getTokenList() {
|
||||||
const userid = this.getCurrentUserId()
|
this.query.userid = this.getCurrentUserId()
|
||||||
const res = await reqTokenUsage({ userid:userid })
|
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);
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -92,13 +409,15 @@ export default {
|
|||||||
.token-usage-page {
|
.token-usage-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 48%, #ffffff 100%);
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(64, 158, 255, 0.16), transparent 34%),
|
||||||
|
linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 48%, #ffffff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-shell {
|
.usage-shell {
|
||||||
min-height: calc(100vh - 48px);
|
min-height: calc(100vh - 48px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: #ffffff;
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border: 1px solid #edf1f7;
|
border: 1px solid #edf1f7;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
|
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
|
||||||
@ -113,7 +432,18 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 24px;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-line {
|
.title-line {
|
||||||
@ -124,7 +454,7 @@ export default {
|
|||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #1f2d3d;
|
color: #ffffff;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,9 +465,9 @@ export default {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
color: #409eff;
|
color: #ffffff;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
background: #eef5ff;
|
background: rgba(255, 255, 255, 0.18);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,9 +479,26 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
|
color: #1e6fff;
|
||||||
|
border: none;
|
||||||
border-radius: 10px;
|
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 {
|
.stat-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@ -160,10 +507,19 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid #edf1f7;
|
border: 1px solid #edf1f7;
|
||||||
background: #ffffff;
|
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,
|
span,
|
||||||
em {
|
em {
|
||||||
@ -180,20 +536,130 @@ export default {
|
|||||||
font-size: 26px;
|
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 {
|
&.primary {
|
||||||
background: #eef5ff;
|
background: linear-gradient(135deg, #eef5ff 0%, #ffffff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
background: #f0fdf4;
|
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.warning {
|
&.warning {
|
||||||
background: #fff7ed;
|
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.purple {
|
&.purple {
|
||||||
background: #f5f3ff;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +667,8 @@ export default {
|
|||||||
padding: 22px;
|
padding: 22px;
|
||||||
border: 1px solid #edf1f7;
|
border: 1px solid #edf1f7;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 26px rgba(31, 45, 61, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@ -212,15 +680,57 @@ 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 {
|
.usage-table {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
.stat-row {
|
.stat-row {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usage-dashboard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
@ -232,5 +742,15 @@ export default {
|
|||||||
.card-header {
|
.card-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
/deep/ .el-date-editor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user