This commit is contained in:
hrx 2026-05-29 17:57:44 +08:00
parent 35d635f0ab
commit 3f65e1cb0c
10 changed files with 1938 additions and 155 deletions

View 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
})
}

View File

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

View File

@ -436,6 +436,44 @@ export const constantRoutes = [
* 需要根据用户角色动态加载的路由
*/
export const asyncRoutes = [
// 财务——财务概览
{
path: "/financialOverview",
component: Layout,
meta: {
title: "财务概览",
fullPath: "/financialOverview",
noCache: true,
icon: "el-icon-data-analysis",
roles: ["财务"]
},
children: [
{
path: "",
component: () => import('@/views/finance/financialOverview/index.vue'),
name: 'FinancialOverview',
meta: {
title: "财务概览",
fullPath: "/financialOverview",
noCache: true,
roles: ["财务"]
}
},
// 计费统计
{
path: "billingStatistics",
component: () => import('@/views/finance/billingStatistics/index.vue'),
name: 'BillingStatistics',
meta: {
title: "计费统计",
fullPath: "/financialOverview/billingStatistics",
noCache: false,
roles: ["财务"]
}
}
]
},
// 运营——模型管理
{
path: "/modelManagement",
@ -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",
}, children: [{
path: "financialOverview",
component: () => import("@/views/finance/financialOverview"),
name: "FinancialOverview",
meta: {
title: "财务概览",
fullPath: "/finance/financialOverview",
icon: "el-icon-data-analysis",
roles: ["财务"]
},
}, {
path: "supplierSettlement",
hidden: true,
component: () => import(

View File

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

View 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>

View 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>

View File

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

View File

@ -100,63 +100,7 @@ export default {
modelType: '',
provider: ''
},
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'
}
]
tableData: []
}
},
computed: {

View File

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

View File

@ -17,6 +17,7 @@
</div>
</div> -->
<div class="stat-row">
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
<div class="stat-card-head">