This commit is contained in:
ping 2026-05-22 11:07:34 +08:00
commit dcfb872267
26 changed files with 5784 additions and 459 deletions

View File

@ -0,0 +1,9 @@
import request from '@/utils/request'
// 跳转远景
export function gotoYuanJingAPI(data) {
return request({
url: `cntoai/get_deerer_header.dspy`,
method: 'get',
params: data
})
}

View File

@ -0,0 +1,74 @@
import request from "@/utils/request";
// 获取模型列表
export const reqModelList = (params = {}) => {
return request({
url: 'cntoai/model_management_search.dspy',
method: 'get',
params
})
}
// 上架
export const reqModelUp = (id) => {
return request({
url: '/cntoai/model_management_list.dspy',
method: 'get',
params: { id }
})
}
// 下架
export const reqModelDown = (id) => {
return request({
url: '/cntoai/model_management_unlist.dspy',
method: 'get',
params: { id }
})
}
// 模型详情
export const reqModelDetail = (id) => {
return request({
url: '/cntoai/model_management_detail.dspy',
method: 'get',
params: { id }
})
}
// 编辑模型
export const reqModelEdit = (data) => {
return request({
url: '/cntoai/model_management_update.dspy',
method: 'get',
params: data
})
}
// 置顶
export const reqModelTop = (id) => {
return request({
url: '/cntoai/model_management_pin_top.dspy',
method: 'get',
params: { id }
})
}
// 下移
export const reqModelBottom = (id) => {
return request({
url: '/cntoai/model_management_move_down.dspy',
method: 'get',
params: { id }
})
}
// apikey列表
export const reqApikeyList = (params = {}) => {
return request({
url: '/cntoai/get_model_apikey.dspy',
method: 'get',
params
})
}
// 创建apikey
export const reqCreateApikey = (params = {}) => {
return request({
url: '/cntoai/create_model_apikey.dspy',
method: 'get',
params
})
}

View File

@ -101,3 +101,12 @@ export const todoCount = () => {
method: 'post',
})
}
// 获取token市集
export const reqTokenMarket = () => {
return request({
url: '/cntoai/model_management_customer_search.dspy',
method: 'get',
})
}

View File

@ -0,0 +1,186 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
custom-class="listing-confirm-dialog"
width="380px"
:show-close="false"
append-to-body
>
<div class="listing-confirm-content" :class="action === 'up' ? 'is-up' : 'is-down'">
<div class="listing-confirm-icon">
<i :class="action === 'up' ? 'el-icon-check' : 'el-icon-warning-outline'"></i>
</div>
<div class="listing-confirm-main">
<h3>{{ action === 'up' ? '确认上架' : '确认下架' }}</h3>
<p v-if="action === 'up'">确认上架该模型到Token市集?</p>
<p v-else>确认下架模型后该模型将从Token市集中移除用户将无法继续使用</p>
<div class="listing-confirm-model">
模型名称<strong>{{ getModelDisplayName(model || {}) }}</strong>
</div>
<div class="listing-confirm-actions">
<el-button class="listing-cancel-btn" @click="$emit('close')">取消</el-button>
<el-button
class="listing-submit-btn"
:class="action === 'up' ? 'is-up' : 'is-down'"
:loading="loading"
@click="$emit('confirm')"
>
{{ action === 'up' ? '确认上架' : '确认下架' }}
</el-button>
</div>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'ListingConfirmDialog',
props: {
visible: {
type: Boolean,
default: false
},
action: {
type: String,
default: 'up'
},
model: {
type: Object,
default: null
},
loading: {
type: Boolean,
default: false
}
},
computed: {
dialogVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
}
},
methods: {
getModelDisplayName(row) {
return row.display_name || row.model_name || '-'
}
}
}
</script>
<style lang="less">
.listing-confirm-dialog {
position: fixed;
top: 50%;
left: 50%;
margin: 0 !important;
transform: translate(-50%, -50%);
border-radius: 8px;
box-shadow: 0 14px 42px rgba(15, 23, 42, 0.24);
overflow: hidden;
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
.listing-confirm-dialog .listing-confirm-content {
display: flex;
gap: 12px;
padding: 22px 26px 18px;
background: #ffffff;
}
.listing-confirm-dialog .listing-confirm-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 34px;
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 18px;
}
.listing-confirm-dialog .listing-confirm-content.is-up .listing-confirm-icon {
color: #16a34a;
background: #dcfce7;
}
.listing-confirm-dialog .listing-confirm-content.is-down .listing-confirm-icon {
color: #d97706;
background: #fef3c7;
}
.listing-confirm-dialog .listing-confirm-main {
flex: 1;
h3 {
margin: 5px 0 20px;
color: #111827;
font-size: 20px;
font-weight: 800;
line-height: 1;
}
p {
margin: 0 0 12px;
color: #4b5563;
font-size: 12px;
line-height: 1.5;
}
}
.listing-confirm-dialog .listing-confirm-model {
color: #1f2937;
font-size: 13px;
line-height: 1.4;
strong {
margin-left: 6px;
font-weight: 800;
}
}
.listing-confirm-dialog .listing-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
.listing-confirm-dialog .listing-cancel-btn {
min-width: 64px;
height: 32px;
color: #374151;
font-size: 13px;
background: #f3f4f6;
border: none;
border-radius: 4px;
}
.listing-confirm-dialog .listing-submit-btn {
min-width: 80px;
height: 32px;
color: #ffffff;
font-size: 13px;
border: none;
border-radius: 4px;
&.is-up {
background: #16a34a;
}
&.is-down {
background: #eab308;
}
}
</style>

View File

@ -0,0 +1,257 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
custom-class="model-detail-dialog"
width="760px"
:show-close="false"
append-to-body
>
<div slot="title" class="detail-dialog-title">
<span>模型详情</span>
<i class="el-icon-close" @click="dialogVisible = false"></i>
</div>
<div v-if="model" class="detail-content">
<div class="detail-head">
<div>
<h2>{{ getModelDisplayName(model) }}</h2>
<p>模型ID: {{ model.id || '-' }}</p>
</div>
<el-tag
effect="light"
class="detail-status"
:class="Number(model.listing_status) === 1 ? 'is-listed' : 'is-pending'"
>
{{ getDetailStatusText(model.listing_status) }}
</el-tag>
</div>
<div class="detail-grid">
<div class="detail-card">
<span>模型类型</span>
<strong>{{ model.model_type || '-' }}</strong>
</div>
<div class="detail-card">
<span>供应商</span>
<strong>{{ model.provider || '-' }}</strong>
</div>
<div class="detail-card">
<span>计费方式</span>
<strong>{{ model.billing_method || '-' }}</strong>
</div>
</div>
<div class="detail-price">
<h3>价格信息</h3>
<div class="detail-price-list">
<div>
<span>输入</span>
<strong>¥{{ formatPrice(model.input_token_price) }}/千Token</strong>
</div>
<div>
<span>输出</span>
<strong>¥{{ formatPrice(model.output_token_price) }}/千Token</strong>
</div>
</div>
</div>
<div class="detail-footer">
更新时间: {{ model.updated_at || model.created_at || '-' }}
</div>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'ModelDetailDialog',
props: {
visible: {
type: Boolean,
default: false
},
model: {
type: Object,
default: null
}
},
computed: {
dialogVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
}
},
methods: {
getModelDisplayName(row) {
return row.display_name || row.model_name || '-'
},
getDetailStatusText(status) {
return Number(status) === 1 ? '已上架' : '已上传'
},
formatPrice(value) {
return Number(value || 0).toFixed(4)
}
}
}
</script>
<style lang="less">
.model-detail-dialog {
margin-top: 6vh !important;
border-radius: 14px;
box-shadow: 0 24px 72px rgba(15, 23, 42, 0.26);
overflow: hidden;
.el-dialog__header {
padding: 26px 34px;
border-bottom: 1px solid #eef0f4;
}
.el-dialog__body {
padding: 0;
}
}
.model-detail-dialog .detail-dialog-title {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
color: #1f2937;
font-size: 22px;
font-weight: 700;
i {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
color: #6b7280;
cursor: pointer;
font-size: 22px;
border-radius: 50%;
}
}
.model-detail-dialog .detail-content {
padding: 34px 34px 28px;
background: #ffffff;
}
.model-detail-dialog .detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 26px;
h2 {
margin: 0 0 10px;
color: #1f2937;
font-size: 24px;
font-weight: 800;
line-height: 1.2;
}
p {
margin: 0;
color: #7a8494;
font-size: 16px;
}
}
.model-detail-dialog .detail-status {
min-width: 72px;
height: 34px;
padding: 0 18px;
font-size: 16px;
line-height: 32px;
text-align: center;
border-radius: 999px;
&.is-listed {
color: #16a34a;
background: #dcfce7;
border-color: #dcfce7;
}
&.is-pending {
color: #b45309;
background: #fff1d6;
border-color: #fff1d6;
}
}
.model-detail-dialog .detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 22px;
margin-bottom: 26px;
}
.model-detail-dialog .detail-card {
min-height: 84px;
padding: 20px 22px;
background: #f8fafc;
border: 1px solid #f4f6f9;
border-radius: 10px;
span {
display: block;
margin-bottom: 10px;
color: #7a8494;
font-size: 15px;
}
strong {
color: #1f2937;
font-size: 18px;
font-weight: 700;
}
}
.model-detail-dialog .detail-price {
padding: 22px;
margin-bottom: 26px;
background: linear-gradient(135deg, #eaf4ff 0%, #f1f6ff 100%);
border: 1px solid #e2efff;
border-radius: 10px;
h3 {
margin: 0 0 16px;
color: #2f7dcc;
font-size: 16px;
font-weight: 700;
}
}
.model-detail-dialog .detail-price-list {
display: grid;
grid-template-columns: repeat(2, 170px);
gap: 18px;
span {
display: block;
margin-bottom: 7px;
color: #7a8494;
font-size: 15px;
}
strong {
color: #1f2937;
font-size: 18px;
font-weight: 800;
}
}
.model-detail-dialog .detail-footer {
padding-top: 18px;
color: #7a8494;
border-top: 1px solid #edf0f3;
font-size: 16px;
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<el-card class="model-toolbar" shadow="never">
<div class="toolbar-left">
<h3>筛选模型</h3>
<p>按模型名称和类型快速定位目标模型</p>
</div>
<el-form class="toolbar-search" :model="searchForm" inline>
<el-form-item label="模型名称">
<el-input
v-model="searchForm.name"
clearable
size="small"
prefix-icon="el-icon-search"
placeholder="请输入模型名称"
></el-input>
</el-form-item>
<el-form-item label="模型类型">
<el-select
v-model="searchForm.type"
clearable
filterable
size="small"
placeholder="请选择模型类型"
>
<el-option
v-for="item in modelTypeOptions"
:key="item"
:label="item"
:value="item"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="供应商">
<el-select
v-model="searchForm.provider"
clearable
filterable
size="small"
placeholder="请选择供应商"
>
<el-option
v-for="item in providerOptions"
:key="item"
:label="item"
:value="item"
></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" icon="el-icon-search" @click="$emit('search')">查询</el-button>
<el-button size="small" icon="el-icon-refresh-left" @click="$emit('reset')">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script>
export default {
name: 'ModelFilter',
props: {
searchForm: {
type: Object,
required: true
},
modelTypeOptions: {
type: Array,
default: () => []
},
providerOptions: {
type: Array,
default: () => []
}
}
}
</script>
<style lang="less" scoped>
.model-toolbar {
margin-bottom: 16px;
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
/deep/ .el-card__body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px 2px;
}
}
.toolbar-left {
margin-bottom: 16px;
h3 {
margin: 0 0 6px;
color: #1f2d3d;
font-size: 18px;
}
p {
margin: 0;
color: #8a94a6;
font-size: 13px;
}
}
.toolbar-search {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
margin-bottom: 16px;
/deep/ .el-input__inner {
border-radius: 10px;
}
/deep/ .el-button {
border-radius: 10px;
}
}
@media (max-width: 1200px) {
.model-toolbar /deep/ .el-card__body {
align-items: flex-start;
flex-direction: column;
}
.toolbar-search {
justify-content: flex-start;
}
}
</style>

View File

@ -0,0 +1,126 @@
<template>
<div class="model-stat-list">
<div
v-for="item in stats"
:key="item.label"
class="model-stat-item"
:class="item.className"
>
<div class="stat-icon">
<i :class="item.icon"></i>
</div>
<div>
<span class="stat-label">{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ModelStats',
props: {
stats: {
type: Array,
default: () => []
}
}
}
</script>
<style lang="less" scoped>
.model-stat-list {
width: 100%;
display: flex;
justify-content: space-around;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.model-stat-item {
position: relative;
display: flex;
width: 34%;
align-items: center;
gap: 14px;
padding: 20px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
overflow: hidden;
&::after {
content: "";
position: absolute;
right: -28px;
top: -28px;
width: 88px;
height: 88px;
border-radius: 50%;
background: rgba(64, 158, 255, 0.08);
}
&.success::after {
background: rgba(103, 194, 58, 0.1);
}
&.warning::after {
background: rgba(230, 162, 60, 0.12);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 46px;
width: 46px;
height: 46px;
color: #409eff;
font-size: 22px;
background: #eef5ff;
border-radius: 14px;
}
&.success .stat-icon {
color: #67c23a;
background: #f0f9eb;
}
&.warning .stat-icon {
color: #e6a23c;
background: #fdf6ec;
}
.stat-label {
display: block;
margin-bottom: 8px;
color: #909399;
font-size: 14px;
}
strong {
color: #303133;
font-size: 28px;
line-height: 1;
}
}
@media (max-width: 1200px) {
.model-stat-list {
flex-wrap: wrap;
}
.model-stat-item {
width: calc(50% - 8px);
}
}
@media (max-width: 768px) {
.model-stat-item {
width: 100%;
}
}
</style>

View File

@ -731,11 +731,17 @@ export default {
},
async logout() {
store.commit('tagsView/resetBreadcrumbState');
store.commit('permission/RESET_ROUTES');
sessionStorage.removeItem("auths");
sessionStorage.removeItem("routes");
sessionStorage.removeItem("user");
sessionStorage.removeItem("userId");
sessionStorage.removeItem("org_type")
sessionStorage.removeItem("userType");
sessionStorage.removeItem("orgType");
sessionStorage.removeItem("roles");
sessionStorage.removeItem("juese");
sessionStorage.removeItem("jueseNew");
localStorage.removeItem('userId')
localStorage.removeItem("auths");
localStorage.removeItem("routes");
@ -752,10 +758,16 @@ export default {
let url = window.location.href;
await this.$router.push(`/login`);
store.commit('tagsView/resetBreadcrumbState');
store.commit('permission/RESET_ROUTES');
sessionStorage.removeItem("auths");
sessionStorage.removeItem("routes");
sessionStorage.removeItem("user");
sessionStorage.removeItem("userId");
sessionStorage.removeItem("userType");
sessionStorage.removeItem("orgType");
sessionStorage.removeItem("roles");
sessionStorage.removeItem("juese");
sessionStorage.removeItem("jueseNew");
},
changeColor() {
this.dialogFormVisible = false

View File

@ -97,12 +97,12 @@ export default {
//
::v-deep .nest-menu {
.el-menu-item {
padding-left: 60px !important; // 10px
padding-left: 42px !important; //
}
//
.nest-menu .el-menu-item {
padding-left: 100px !important;
padding-left: 64px !important;
}
}
}

View File

@ -11,7 +11,7 @@
:text-color="variables.menuText"
:unique-opened="true"
:active-text-color="variables.menuActiveText"
:collapse-transition="false"
:collapse-transition="true"
:default-active="activeMenu"
mode="vertical"
class="el-menu-vertical"
@ -25,6 +25,14 @@
/>
</el-menu>
</happy-scroll>
<button
class="sidebar-collapse-btn"
:class="{ collapsed: isCollapse }"
:title="isCollapse ? '展开菜单' : '折叠菜单'"
@click="toggleSideBar"
>
<i :class="isCollapse ? 'el-icon-s-unfold' : 'el-icon-s-fold'"></i>
</button>
</div>
</div>
</template>
@ -72,6 +80,12 @@ export default {
mounted() {
console.log("Sidebar mounted - 权限路由:", this.permissionRoutes);
},
methods: {
toggleSideBar() {
this.$store.dispatch("app/toggleSideBar");
}
}
};
</script>
@ -88,6 +102,8 @@ export default {
display: flex;
flex-direction: column;
box-sizing: border-box;
position: relative;
transition: width 0.25s ease;
}
.menu-scroll-container {
@ -107,6 +123,36 @@ export default {
}
}
.sidebar-collapse-btn {
position: absolute;
right: 16px;
bottom: 18px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
color: #8a94a6;
font-size: 18px;
cursor: pointer;
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 50%;
box-shadow: 0 8px 22px rgba(31, 45, 61, 0.12);
transition: all 0.25s ease;
&:hover {
color: #1e6fff;
transform: translateY(-2px);
box-shadow: 0 12px 26px rgba(30, 111, 255, 0.18);
}
&.collapsed {
right: 11px;
}
}
//
::v-deep .el-menu-vertical {
border: none;
@ -117,9 +163,37 @@ export default {
.el-submenu__title,
.el-menu-item {
height: 56px;
line-height: 56px;
margin: 6px 14px;
padding: 0 18px !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 12px;
transition: all 0.25s ease;
}
.el-submenu__title span,
.el-menu-item span {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.el-submenu__title:hover,
.el-menu-item:not(.is-active):hover {
color: #1e6fff !important;
background: #f4f8ff !important;
}
.el-menu-item.is-active {
color: #ffffff !important;
background: linear-gradient(135deg, #1e6fff, #5d8dff) !important;
}
.el-menu-item.is-active i,
.el-menu-item.is-active span {
color: #ffffff !important;
}
//
@ -128,23 +202,18 @@ export default {
.el-menu-item {
//
&.is-active {
background-color: #d7dafd !important;
color: #296ad9 !important;
background: linear-gradient(135deg, #1e6fff, #5d8dff) !important;
color: #ffffff !important;
&:before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: #296ad9;
display: none;
}
}
//
&:not(.is-active):hover {
background-color: #f5f7fa !important;
background-color: #f4f8ff !important;
}
}
}
@ -157,16 +226,24 @@ export default {
.el-submenu__title,
.el-menu-item {
margin: 6px 8px;
padding: 0 !important;
text-overflow: clip;
justify-content: center;
}
.el-submenu__title span,
.el-menu-item span {
opacity: 0;
transform: translateX(-6px);
}
//
.el-menu--popup {
.el-menu-item {
&.is-active {
background-color: #f5f7fa !important;
color: #296ad9 !important;
color: #ffffff !important;
background: linear-gradient(135deg, #1e6fff 0%, #244fbd 100%) !important;
}
}
}

View File

@ -11,7 +11,7 @@ import {getHomePath} from "@/views/setting/tools";
NProgress.configure({showSpinner: false}); // NProgress Configuration
const whiteList = ["/login", "/homePage", "/registrationPage", "/shoppingCart", "/homePageImage","/h5HomePage",'/H5about','/modelProductDetail','/ncmatchHome']; // no redirect whitelist
const whiteList = ["product","/login", "/homePage", "/registrationPage", "/shoppingCart", "/homePageImage","/h5HomePage",'/H5about','/modelProductDetail','/ncmatchHome']; // no redirect whitelist
// 获取用户代理字符串
const userAgent = window.navigator.userAgent;

View File

@ -404,11 +404,36 @@ export const constantRoutes = [
* 需要根据用户角色动态加载的路由
*/
export const asyncRoutes = [
// 运营——模型管理
{
path: "/modelManagement",
component: Layout,
meta: {
// title 是菜单上显示的文字fullPath 用来和后端权限 path 对权限。
title: "模型管理",
fullPath: "/modelManagement",
noCache: true,
// icon 是左侧菜单图标roles 限制只有运营角色能看到。
icon: "el-icon-cpu",
roles: ["运营"]
},
children: [
{
path: "",
component: () => import('@/views/modelManagement/modelManagement.vue'),
name: 'modelManagement',
meta: {
title: "模型管理",
fullPath: "/modelManagement",
noCache: true,
roles: ["运营"]
}
},
]
},
// 全部产品 - 一级菜单
// 全部产品 - 一级菜单(无子路由)
// token市集 - 一级菜单(所有登录用户都能看到)
{
path: "/product",
component: Layout,
@ -416,7 +441,7 @@ export const asyncRoutes = [
title: "全部产品",
fullPath: "/product",
noCache: true,
icon: "el-icon-goods"
icon: "el-icon-coin"
},
children: [
{
@ -431,6 +456,66 @@ export const asyncRoutes = [
},
]
},
// 令牌管理 - 一级菜单(所有登录用户都能看到)
{
path: "/tokenManagement",
component: Layout,
meta: {
title: "令牌管理",
fullPath: "/tokenManagement",
noCache: true,
icon: "el-icon-key"
},
children: [
{
path: "",
component: () => import('@/views/tokenManagement/index.vue'),
name: 'TokenManagement',
meta: {
title: "令牌管理",
fullPath: "/tokenManagement",
noCache: true,
icon: "el-icon-key"
}
},
]
},
// 模型体验
{
path: "/modelExperience",
component: () => import('@/views/modelManagement/Experience.vue'),
hidden: true,
name: 'modelExperience',
meta: {
title: "模型体验",
fullPath: "/modelExperience",
noCache: true
},
},
// 模型详情
{
path: "/modelDetail",
component: () => import('@/views/modelManagement/ModelDetail.vue'),
hidden: true,
name: 'modelDetail',
meta: {
title: "模型详情",
fullPath: "/modelDetail",
noCache: true
},
},
// API文档
{
path: "/modelApiDocument",
component: () => import('@/views/modelManagement/ApiDocument.vue'),
hidden: true,
name: 'modelApiDocument',
meta: {
title: "API文档",
fullPath: "/modelApiDocument",
noCache: true
},
},
{
path: "/overview",
component: Layout,
@ -453,6 +538,32 @@ export const asyncRoutes = [
}
]
},
// 运营——运营报表
{
path: "/operationReport",
component: Layout,
meta: {
title: "运营报表",
fullPath: "/operationReport",
noCache: true,
icon: "el-icon-data-analysis",
roles: ["运营"]
},
children: [
{
path: "",
component: () => import('@/views/operation/operationReport/index.vue'),
name: 'operationReport',
meta: {
title: "运营报表",
fullPath: "/operationReport",
noCache: true,
icon: "el-icon-data-analysis",
roles: ["运营"]
}
}
]
},
{
path: "/orderManagement",
@ -565,13 +676,15 @@ export const asyncRoutes = [
path: "/consultingMangement",
name: 'ConsultingMangement',
component: Layout,
meta: { title: "咨询表单", fullPath: "/consultingMangement", noCache: true, icon: "el-icon-s-platform" },
// 咨询表单是表单/订单类入口,所以菜单图标用 el-icon-s-order。
meta: { title: "咨询表单", fullPath: "/consultingMangement", noCache: true, icon: "el-icon-s-order" },
children: [
{
path: "index",
component: () => import('@/views/operation/consultingMangement/index.vue'),
name: 'ConsultingMangement',
meta: { title: "咨询表单", fullPath: "/consultingMangement/index", noCache: true, icon: "el-icon-s-platform" },
// 子路由也带 icon单子菜单折叠成一级菜单时能继续显示图标。
meta: { title: "咨询表单", fullPath: "/consultingMangement/index", noCache: true, icon: "el-icon-s-order" },
}
]
},
@ -1180,7 +1293,8 @@ export const asyncRoutes = [
component: Layout,
name: "qualificationReview",
redirect: "/qualificationReview/index",
meta: { fullPath: "/qualificationReview", title: "资质审核", noCache: true, icon: 'el-icon-s-home' },
// 资质审核是审核/校验类菜单,所以用 el-icon-s-check。
meta: { fullPath: "/qualificationReview", title: "资质审核", noCache: true, icon: 'el-icon-s-check' },
children: [
{
path: "noApproveInfo",
@ -1203,7 +1317,8 @@ export const asyncRoutes = [
component: Layout,
name: "approveMangement",
redirect: "/approveMangement/index",
meta: { fullPath: "/approveMangement", title: "供需审核", noCache: true, icon: 'el-icon-s-home' },
// 供需审核表示供给和需求两边协作审核,所以用 el-icon-s-cooperation。
meta: { fullPath: "/approveMangement", title: "供需审核", noCache: true, icon: 'el-icon-s-cooperation' },
children: [
{
path: "pendingPro",
@ -1263,12 +1378,14 @@ export const asyncRoutes = [
},
]
},
{
path: "/menuMangement",
component: Layout,
name: "menuMangement",
redirect: "/menuMangement/index",
meta: { fullPath: "/menuMangement", title: "菜单管理", noCache: true, icon: 'el-icon-s-home' },
// 菜单管理就是维护菜单配置,用 Element UI 的菜单图标。
meta: { fullPath: "/menuMangement", title: "菜单管理", noCache: true, icon: 'el-icon-menu' },
children: [
{
path: "index",
@ -1435,7 +1552,8 @@ export const asyncRoutes = [
{
path: "/operation", component: Layout, redirect: "/operation/supplierManagement", meta: {
title: "运营", icon: "el-icon-s-tools", noCache: true, fullPath: "/operation",
// 运营是运营后台入口,用操作/运营类图标。
title: "运营", icon: "el-icon-s-operation", noCache: true, fullPath: "/operation",
}, children: [
{
@ -1559,7 +1677,7 @@ export const asyncRoutes = [
hidden: true,
path: "colony",
title: "管理集群",
title: "管理集群",
component: () => import("@/views/operation/computingCenterManagement/colony/index.vue"),
name: "supplierManagement",
meta: {

View File

@ -1,24 +1,197 @@
// permission.js - 修改后的完整代码
import { asyncRoutes, constantRoutes } from "@/router";
// 获取用户代理字符串
const userAgent = window.navigator.userAgent;
// 用浏览器 UA 判断当前是不是手机端,后面会按 PC / 手机过滤菜单。
const MOBILE_UA_REGEXP = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
// 判断是否为移动设备
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
// 项目里用到的固定角色名,集中放这里,避免代码里到处写字符串。
const CUSTOMER_ROLE = '客户';
const OPERATION_ROLE = '运营';
// 如果是移动设备,添加移动端首页路由和根路径重定向
// 这个用户能看到订单管理里的特殊子菜单,比如历史订单和订单详情。
const SPECIAL_ORDER_USER = 'ZhipuHZ';
// 超级管理员只放行这个一级菜单。
const SUPER_ADMIN_ROUTE_PATH = '/superAdministrator';
// 所有登录用户都能访问的公共路由,不依赖后端 auths 和角色。hidden 路由不会显示在菜单里。
const COMMON_ROUTE_PATHS = ['/product', '/tokenManagement', '/modelExperience', '/modelDetail', '/modelApiDocument'];
// 运营角色需要额外补出来的菜单。
const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/operationReport'];
// 普通客户账号默认要补出来的基础菜单。
const BASE_USER_ROUTE_PATHS = ['/orderManagement', '/resourceManagement'];
// 客户角色额外能看到的一级菜单。
const CUSTOMER_EXTRA_ROUTE_PATHS = [
'/unsubscribeManagement',
'/informationPerfect',
'/rechargeManagement',
'/invoiceManagement',
'/workOrderManagement'
];
// 这些菜单只允许客户角色看到,非客户就算后端给了权限也不展示。
const CUSTOMER_ONLY_ROUTE_PATHS = [
'/overview',
...CUSTOMER_EXTRA_ROUTE_PATHS
];
// 客户登录后必须能看到的入口菜单,不完全依赖后端 auths 返回。
const CUSTOMER_ALWAYS_VISIBLE_ROUTE_PATHS = ['/overview'];
// 订单管理里只给 SPECIAL_ORDER_USER 看的子菜单 path。
const ORDER_CHILDREN_ONLY_FOR_SPECIAL_USER = ['HistoricalOrders', 'orderDetails'];
const isMobile = MOBILE_UA_REGEXP.test(window.navigator.userAgent);
// 把角色统一整理成数组,兼容 undefined、数组、逗号字符串这几种写法。
function normalizeRoles(roles) {
if (!roles) {
return [];
}
if (Array.isArray(roles)) {
return roles;
}
if (typeof roles === 'string') {
return roles.split(',').filter(Boolean);
}
return [];
}
// 从 sessionStorage 里取 roles取不到或格式坏了就当成没有角色。
function getSessionRoles() {
try {
return JSON.parse(sessionStorage.getItem('roles') || '[]');
} catch (error) {
console.warn('读取 roles 失败:', error);
return [];
}
}
// 汇总当前用户的所有角色来源接口参数、vuex、sessionStorage、新旧字段。
function getCurrentRoles(params, rootState) {
return [
...normalizeRoles(params.roles),
...normalizeRoles(rootState.user.roles),
...normalizeRoles(getSessionRoles()),
...normalizeRoles(sessionStorage.getItem('jueseNew'))
];
}
// 判断当前用户是不是客户角色。
function isCustomer(userRoles = []) {
return userRoles.includes(CUSTOMER_ROLE);
}
// 把布尔值转成更好读的设备类型,后面的判断都用 pc / mobile。
function getDeviceType(isMobileDevice) {
return isMobileDevice ? 'mobile' : 'pc';
}
// 判断路由 meta.roles 是否满足。没写 roles 的路由默认所有角色都能继续往下判断。
function hasRouteRole(route, userRoles = []) {
const routeRoles = route.meta?.roles;
if (!routeRoles || routeRoles.length === 0) {
return true;
}
return routeRoles.some(role => userRoles.includes(role));
}
// 根据设备过滤路由手机只要手机路由PC 不要手机专用路由。
function isRouteAllowedByDevice(route, deviceType) {
if (deviceType === 'mobile') {
return route.meta?.isMobile || route.meta?.isMobile === true;
}
return route.meta?.isMobile !== true;
}
// 在一组路由里按 path 找某个路由。
function findRouteByPath(routes, path) {
return routes.find(route => route.path === path);
}
// 后端 auths 里的 path 要和路由 meta.fullPath 对上,对上才算有权限。
function routeHasPermission(route, permissions) {
return permissions.some(permission => permission.path === route.meta?.fullPath);
}
// 客户专属菜单要再卡一层客户角色,防止非客户误展示。
function canShowCustomerOnlyRoute(route, userRoles) {
return !CUSTOMER_ONLY_ROUTE_PATHS.includes(route.path) || isCustomer(userRoles);
}
// 把所有动态路由的 fullPath 收集出来。后端返回 path 为空时,表示拥有全部权限。
function getAllRoutePermissions(routes) {
const permissions = [];
routes.forEach(route => {
if (route.meta?.fullPath) {
permissions.push({ path: route.meta.fullPath });
}
if (route.children) {
permissions.push(...getAllRoutePermissions(route.children));
}
});
return permissions;
}
// 复制路由对象。这里不能用 JSON 深拷贝,因为路由里的 component 是函数,会被 JSON 丢掉。
function cloneRoute(route) {
const clonedRoute = { ...route };
if (route.meta) {
clonedRoute.meta = { ...route.meta };
}
if (route.children) {
clonedRoute.children = route.children.map(cloneRoute);
}
return clonedRoute;
}
// 根据 path 列表批量找到对应路由,没找到的自动过滤掉。
function getRoutesByPath(routes, paths) {
return paths
.map(path => findRouteByPath(routes, path))
.filter(Boolean);
}
// 判断这个一级路由是不是已经加过了,避免菜单重复出现。
function shouldAppendRoute(accessedRoutes, route) {
return !accessedRoutes.some(item => item.path === route.path);
}
// 把缺少的路由补到最终菜单里,补之前会先去重并复制一份。
function appendMissingRoutes(accessedRoutes, routesToAppend) {
routesToAppend.forEach(route => {
if (shouldAppendRoute(accessedRoutes, route)) {
accessedRoutes.push(cloneRoute(route));
}
});
return accessedRoutes;
}
// 如果是手机访问,额外把根路径导到 H5 首页,并注册 H5 首页菜单。
if (isMobile) {
console.log("检测到移动设备,添加移动端路由");
// 先添加根路径重定向到移动端首页
constantRoutes.unshift({
path: '/',
redirect: '/h5HomePage',
hidden: true
});
// 添加移动端首页路由
constantRoutes.push({
path: '/h5HomePage',
name: 'H5HomePage',
@ -82,106 +255,165 @@ if (isMobile) {
});
}
// 修复:更全面的路由过滤逻辑
// 核心过滤函数:拿后端权限、角色和设备类型,一层层筛出最终可访问路由。
function filterAsyncRoutes(routes, permissions, userRoles = [], deviceType = 'pc') {
const res = [];
// 定义需要客户角色才能访问的路由
const customerOnlyRoutes = [
"/product", "/overview", "/workOrderManagement",
"/unsubscribeManagement", "/informationPerfect",
"/rechargeManagement", "/invoiceManagement"
];
routes.forEach(route => {
// 创建路由副本
const tmpRoute = { ...route };
// 先复制一份,避免直接改原始 asyncRoutes。
const tmpRoute = cloneRoute(route);
// 检查当前路由是否在权限列表中
const hasPermission = permissions.some(p => p.path === route.meta?.fullPath);
// 特殊处理:确保"全部产品"和"资源概览"这两个一级路由在客户角色下显示
const isCriticalRoute = route.path === "/product" || route.path === "/overview";
// 检查是否为仅客户可访问的路由
const isCustomerOnlyRoute = customerOnlyRoutes.includes(route.path);
// 如果路由需要客户角色,但用户不是客户,则跳过
if (isCustomerOnlyRoute && !userRoles.includes('客户')) {
return; // 跳过当前路由
// 第一步:角色不符合,或者客户专属菜单但当前用户不是客户,直接跳过。
if (!hasRouteRole(tmpRoute, userRoles) || !canShowCustomerOnlyRoute(route, userRoles)) {
return;
}
// 新增:根据设备类型过滤路由
if (deviceType === 'mobile' && !(route.meta?.isMobile || route.meta?.isMobile === true)) {
return; // 移动设备跳过非移动端路由
}
if (deviceType === 'pc' && route.meta?.isMobile === true) {
return; // PC设备跳过移动端路由
// 第二步:设备不符合也跳过,比如 PC 端不展示 H5 专用路由。
if (!isRouteAllowedByDevice(route, deviceType)) {
return;
}
// 如果当前路由有权限,则加入结果
if (hasPermission) {
// 第三步:看后端 auths 里有没有当前路由的 fullPath。
const hasPermission = routeHasPermission(route, permissions);
// 第四步:客户首页入口特殊处理,客户登录后默认展示。
const isAlwaysVisibleCustomerRoute =
CUSTOMER_ALWAYS_VISIBLE_ROUTE_PATHS.includes(route.path) && isCustomer(userRoles);
// 有权限,或者是客户默认入口,就把这个路由放进最终菜单。
if (hasPermission || isAlwaysVisibleCustomerRoute) {
res.push(tmpRoute);
}
// 如果是关键路由且用户是客户,也要加入结果
else if (isCriticalRoute && userRoles.includes('客户')) {
res.push(tmpRoute);
}
// 如果没有直接权限,但有子路由,递归处理子路由
else if (tmpRoute.children) {
} else if (tmpRoute.children) {
// 父级没权限时继续看子级。只要子级有权限,父级也要保留,否则子菜单没地方挂。
const filteredChildren = filterAsyncRoutes(tmpRoute.children, permissions, userRoles, deviceType);
if (filteredChildren.length > 0) {
tmpRoute.children = filteredChildren;
res.push(tmpRoute); // 即使父路由本身没有权限,只要有子路由有权限,也要保留父路由
res.push(tmpRoute);
}
}
// 如果当前路由既没有权限,也没有有权限的子路由,则不添加到结果中
});
return res;
}
// 新增:为普通用户添加订单管理和资源管理路由
// 给普通用户和客户补充固定菜单:订单、资源,以及客户专属的工单/充值/发票等。
function addUserRoutes(routes, userType, orgType, userRoles = [], deviceType = 'pc') {
console.log("addUserRoutes - userType:", userType, "orgType:", orgType, "userRoles:", userRoles);
const userRoutes = [];
// 修复:包含 orgType 为 2 和 3 的情况(公司客户和个人客户)
if (userType === 'user' || orgType == 2 || orgType == 3) {
const orderManagementRoute = routes.find(route => route.path === "/orderManagement");
const resourceManagementRoute = routes.find(route => route.path === "/resourceManagement");
// orgType 为 2 或 3 时也按客户账号处理。
const isUserAccount = userType === 'user' || orgType == 2 || orgType == 3;
// 新增:根据设备类型过滤
if (orderManagementRoute && (deviceType === 'pc' || orderManagementRoute.meta?.isMobile === true)) {
console.log("添加订单管理路由");
userRoutes.push(JSON.parse(JSON.stringify(orderManagementRoute))); // 深拷贝
}
if (isUserAccount) {
// 普通客户账号默认补订单管理和资源管理。
const baseUserRoutes = getRoutesByPath(routes, BASE_USER_ROUTE_PATHS)
.filter(route => isRouteAllowedByDevice(route, deviceType));
if (resourceManagementRoute && (deviceType === 'pc' || resourceManagementRoute.meta?.isMobile === true)) {
console.log("添加资源管理路由");
userRoutes.push(JSON.parse(JSON.stringify(resourceManagementRoute))); // 深拷贝
}
console.log("添加基础用户菜单路由:", baseUserRoutes.map(route => route.path));
userRoutes.push(...baseUserRoutes);
}
// 新增:为所有用户添加五个新的客户菜单,但只有客户角色才能看到
const newCustomerRoutes = [
routes.find(route => route.path === "/unsubscribeManagement"),
routes.find(route => route.path === "/informationPerfect"),
routes.find(route => route.path === "/rechargeManagement"),
routes.find(route => route.path === "/invoiceManagement"),
routes.find(route => route.path === "/workOrderManagement")
].filter(route => {
// 过滤掉undefined并且只有客户角色才能看到这些路由
return route && userRoles.includes('客户') &&
(deviceType === 'pc' || route.meta?.isMobile === true);
});
if (isCustomer(userRoles)) {
// 只有客户角色才补客户专属菜单。
const customerRoutes = getRoutesByPath(routes, CUSTOMER_EXTRA_ROUTE_PATHS)
.filter(route => isRouteAllowedByDevice(route, deviceType));
console.log("添加新的客户菜单路由:", newCustomerRoutes.map(r => r.path));
userRoutes.push(...newCustomerRoutes);
console.log("添加客户菜单路由:", customerRoutes.map(route => route.path));
userRoutes.push(...customerRoutes);
}
return userRoutes;
}
// 运营角色额外补模型管理菜单,目前只在 PC 端展示。
function addOperationRoutes(accessedRoutes, routes, userRoles = [], deviceType = 'pc') {
if (!userRoles.includes(OPERATION_ROLE) || deviceType !== 'pc') {
return accessedRoutes;
}
return appendMissingRoutes(accessedRoutes, getRoutesByPath(routes, OPERATION_EXTRA_ROUTE_PATHS));
}
// token市集是公共菜单所有登录用户都要能看到。
function addCommonRoutes(accessedRoutes, routes, deviceType = 'pc') {
const commonRoutes = getRoutesByPath(routes, COMMON_ROUTE_PATHS)
.filter(route => isRouteAllowedByDevice(route, deviceType));
return appendMissingRoutes(accessedRoutes, commonRoutes);
}
// 订单管理有两个特殊子菜单,只有 SPECIAL_ORDER_USER 能看到,其他用户过滤掉。
function filterOrderChildrenByUser(routes, username) {
if (username === SPECIAL_ORDER_USER) {
console.log(`用户 ${username}${SPECIAL_ORDER_USER},保留所有订单子路由`);
return routes;
}
return routes.map(route => {
const nextRoute = cloneRoute(route);
// 找到订单管理后,移除特殊用户专属的子菜单。
if (nextRoute.path === '/orderManagement' && nextRoute.children) {
console.log(`用户 ${username} 不是 ${SPECIAL_ORDER_USER},过滤订单管理子路由`);
nextRoute.children = nextRoute.children.filter(child =>
!ORDER_CHILDREN_ONLY_FOR_SPECIAL_USER.includes(child.path)
);
console.log('过滤后订单子路由:', nextRoute.children.map(child => child.path));
}
if (nextRoute.children) {
// 子路由里如果还有订单管理,也继续递归处理。
nextRoute.children = filterOrderChildrenByUser(nextRoute.children, username);
}
return nextRoute;
});
}
// 整理后端权限列表。如果包含空 path就按“拥有全部动态路由权限”处理。
function getPermissionList(auths = []) {
const permissions = JSON.parse(JSON.stringify(auths));
const permissionPaths = permissions.map(item => item.path);
if (permissionPaths.includes('')) {
return getAllRoutePermissions(asyncRoutes);
}
return permissions;
}
// 根据后端 auths 生成第一版可访问路由。没有 auths 就不展示动态菜单。
function getAccessedRoutesByPermission(auths, userRoles, deviceType) {
if (!auths.length) {
return [];
}
const permissions = getPermissionList(auths);
return filterAsyncRoutes(asyncRoutes, permissions, userRoles, deviceType);
}
// 判断是不是超级管理员账号:用户名包含 admin并且不是客户组织。
function isSuperAdminUser(username, orgType) {
return username && username.includes('admin') && orgType != 2 && orgType != 3;
}
// 超级管理员只拿超级管理员菜单;手机端不展示这个菜单。
function getSuperAdminRoutes(deviceType) {
if (deviceType !== 'pc') {
return [];
}
return getRoutesByPath(asyncRoutes, [SUPER_ADMIN_ROUTE_PATH]).map(cloneRoute);
}
// 在已有权限菜单基础上,补充用户类型/客户角色需要固定展示的菜单。
function addUserSpecificRoutes(accessedRoutes, userType, orgType, userRoles, deviceType) {
const userSpecificRoutes = addUserRoutes(asyncRoutes, userType, orgType, userRoles, deviceType);
return appendMissingRoutes(accessedRoutes, userSpecificRoutes);
}
const state = {
routes: [],
addRoutes: [],
@ -192,12 +424,19 @@ const state = {
const mutations = {
SET_ROUTES: (state, routes) => {
console.log("MUTATION SET_ROUTES - received routes:", routes);
// addRoutes 只保存动态生成的菜单,方便 router.addRoutes 使用。
state.addRoutes = routes;
sessionStorage.setItem("routes", JSON.stringify(routes));
// 将移动端首页路由也包含在内
// routes 是侧边栏最终读取的数据:基础路由 + 动态权限路由。
state.routes = constantRoutes.concat(routes);
console.log("MUTATION SET_ROUTES - final state.routes:", state.routes);
},
RESET_ROUTES: (state) => {
// 退出登录或切换账号时,必须清掉内存里的旧菜单,否则不刷新页面会继续显示上个角色的菜单。
state.routes = [];
state.addRoutes = [];
sessionStorage.removeItem("routes");
},
SETUSERS: (state, user) => {
state.users = user;
},
@ -226,131 +465,46 @@ const actions = {
generateRoutes({ commit, rootState, state }, params) {
console.log("ACTION generateRoutes - params:", params);
return new Promise((resolve) => {
let accessedRoutes;
// 从参数或sessionStorage中获取用户类型和组织类型
// 1. 先拿到用户基础信息,优先用传进来的参数,没有就从 sessionStorage / vuex 兜底。
const userType = params.userType || sessionStorage.getItem('userType') || '';
const orgType = params.orgType || parseInt(sessionStorage.getItem('orgType')) || 0;
// 获取用户角色从store或sessionStorage
const userRoles = rootState.user.roles || JSON.parse(sessionStorage.getItem('roles') || '[]');
console.log("用户角色:", userRoles);
// 获取用户名
const username = params.user || rootState.user.user || '';
console.log("当前用户名:", username, "检查是否是ZhipuHZ:", username === 'ZhipuHZ');
const userRoles = getCurrentRoles(params, rootState);
const deviceType = getDeviceType(state.isMobile);
const auths = params.auths ? JSON.parse(JSON.stringify(params.auths)) : [];
console.log("用户类型:", userType, "orgType:", orgType);
// 2. 判断是不是超级管理员,超级管理员走单独菜单逻辑。
const isSuperAdmin = isSuperAdminUser(params.user, orgType);
// 确定设备类型
const deviceType = state.isMobile ? 'mobile' : 'pc';
console.log("设备类型:", deviceType);
console.log("用户角色:", userRoles);
console.log("当前用户名:", username, `检查是否是${SPECIAL_ORDER_USER}:`, username === SPECIAL_ORDER_USER);
console.log("用户类型:", userType, "orgType:", orgType, "设备类型:", deviceType);
console.log("ACTION generateRoutes - auths:", auths);
// 修复:包含 orgType 为 2 和 3 的情况
if (params.user && params.user.includes("admin") && orgType != 2 && orgType != 3) {
// 管理员只显示超级管理员菜单仅PC端
if (deviceType === 'pc') {
accessedRoutes = asyncRoutes.filter(item => item.path === '/superAdministrator');
} else {
accessedRoutes = [];
}
} else {
const auths = params.auths ? JSON.parse(JSON.stringify(params.auths)) : [];
console.log("ACTION generateRoutes - auths:", auths);
// 3. 先生成第一版菜单:超级管理员只拿超管菜单,普通用户按后端 auths 过滤。
let accessedRoutes = isSuperAdmin
? getSuperAdminRoutes(deviceType)
: getAccessedRoutesByPermission(auths, userRoles, deviceType);
if (auths.length) {
// 确保 auths 中的 path 与路由 meta.fullPath 匹配
const paths = auths.map((item) => {
return item.path;
});
console.log("ACTION generateRoutes - paths from auths:", paths);
// 4. token市集是公共入口所有登录用户都补上。
accessedRoutes = addCommonRoutes(accessedRoutes, asyncRoutes, deviceType);
if (paths.includes("")) {
// 如果权限列表包含空路径,认为用户有所有权限
accessedRoutes = asyncRoutes || [];
} else {
// 传入用户角色和设备类型
accessedRoutes = filterAsyncRoutes(asyncRoutes, auths, userRoles, deviceType);
}
} else {
// 如果没有权限列表,不显示任何动态路由
accessedRoutes = [];
}
// 为普通用户添加订单管理和资源管理路由以及新的五个客户菜单
if (!isSuperAdmin) {
// 5. 普通用户再补一些固定入口,比如订单、资源、客户专属菜单。
console.log("为用户添加特定路由");
const userSpecificRoutes = addUserRoutes(asyncRoutes, userType, orgType, userRoles, deviceType);
// 确保不重复添加路由,同时检查角色权限
userSpecificRoutes.forEach(route => {
const isCustomerRoute = [
"/workOrderManagement", "/unsubscribeManagement", "/informationPerfect",
"/rechargeManagement", "/invoiceManagement"
].includes(route.path);
// 如果是客户路由但用户不是客户,则不添加
if (isCustomerRoute && !userRoles.includes('客户')) {
return;
}
if (!accessedRoutes.some(r => r.path === route.path)) {
accessedRoutes.push(route);
}
});
accessedRoutes = addUserSpecificRoutes(accessedRoutes, userType, orgType, userRoles, deviceType);
console.log("添加用户特定路由后的accessedRoutes:", accessedRoutes);
}
// ========== 暴力过滤:直接修改 accessedRoutes ==========
// 遍历所有路由,找到 /orderManagement 路由,然后过滤它的子路由
accessedRoutes = accessedRoutes.map(route => {
if (route.path === "/orderManagement") {
console.log("找到订单管理路由,准备过滤子路由,用户名:", username);
// 6. 运营角色额外补模型管理。
accessedRoutes = addOperationRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType);
// 创建路由副本
const newRoute = { ...route };
if (newRoute.children) {
// 如果不是 ZhipuHZ 用户,过滤掉 HistoricalOrders 和 orderDetails 路由
if (username !== 'ZhipuHZ') {
console.log(`用户 ${username} 不是 ZhipuHZ过滤订单管理子路由`);
newRoute.children = newRoute.children.filter(child =>
child.path !== 'HistoricalOrders' && child.path !== 'orderDetails'
);
console.log(`过滤后子路由:`, newRoute.children.map(c => c.path));
} else {
console.log(`用户 ${username} 是 ZhipuHZ保留所有子路由`);
}
}
return newRoute;
}
// 对于其他路由,保持原样
return route;
});
// 再次检查,确保没有遗漏的任何 orderManagement 路由
accessedRoutes.forEach(route => {
if (route.children) {
route.children = route.children.filter(child => {
// 如果子路由是 orderManagement也需要处理
if (child.path === "/orderManagement") {
console.log("在子路由中找到订单管理路由,准备过滤,用户名:", username);
if (child.children && username !== 'ZhipuHZ') {
child.children = child.children.filter(grandChild =>
grandChild.path !== 'HistoricalOrders' && grandChild.path !== 'orderDetails'
);
}
}
return true;
});
}
});
// 7. 最后处理订单管理里的特殊子菜单权限。
accessedRoutes = filterOrderChildrenByUser(accessedRoutes, username);
console.log("ACTION generateRoutes - 最终 calculated accessedRoutes:", accessedRoutes);
// 8. 保存到 vuex 和 sessionStorage侧边栏会读取 state.permission.routes。
commit("SET_ROUTES", accessedRoutes);
resolve(accessedRoutes);
});

View File

@ -13,6 +13,13 @@ const safeToString = (value, defaultValue = '') => {
return value.toString();
};
const normalizeLoginRoles = (roles) => {
if (!roles || roles === 'None') return [];
if (Array.isArray(roles)) return roles;
if (typeof roles === 'string') return roles.split(',').filter(Boolean);
return [];
};
// 从sessionStorage恢复状态
const getStoredState = () => {
return {
@ -130,8 +137,11 @@ const actions = {
// 修复org_type 为 2 或 3 都表示客户
const userType = (org_type == 2 || org_type == 3) ? 'user' : 'admin';
// 设置用户角色 - 如果是客户,则添加'客户'角色
const userRoles = (org_type == 2 || org_type == 3) ? ['客户'] : ['管理员'];
// 使用接口返回的真实角色生成菜单;客户组织兜底补上“客户”角色。
const userRoles = normalizeLoginRoles(response.roles);
if ((org_type == 2 || org_type == 3) && !userRoles.includes('客户')) {
userRoles.push('客户');
}
commit("SET_USER_TYPE", userType);
// 确保 org_type 不为 undefined
@ -141,6 +151,8 @@ const actions = {
console.log("登录用户类型:", userType, "org_type:", org_type, "用户角色:", userRoles);
data ? commit("SET_AUTHS", data) : commit("SET_AUTHS", []);
resetRouter();
commit("permission/RESET_ROUTES", null, { root: true });
const accessRoutes = await store.dispatch(
"permission/generateRoutes",
{
@ -151,7 +163,6 @@ const actions = {
roles: userRoles // 新增:传递角色信息
}
)
resetRouter();
router.addRoutes(accessRoutes);
resolve(response);
}
@ -215,6 +226,7 @@ const actions = {
commit("SET_AUTHS", []);
removeToken();
resetRouter();
commit("permission/RESET_ROUTES", null, { root: true });
// 清除sessionStorage
sessionStorage.removeItem('user');
@ -223,6 +235,8 @@ const actions = {
sessionStorage.removeItem('orgType');
sessionStorage.removeItem('mybalance');
sessionStorage.removeItem('roles'); // 新增:清除角色信息
sessionStorage.removeItem('juese');
sessionStorage.removeItem('jueseNew');
// reset visited views and cached views
// to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485
@ -243,6 +257,8 @@ const actions = {
commit("SET_USER", "");
commit("SET_AUTHS", []);
removeToken();
resetRouter();
commit("permission/RESET_ROUTES", null, { root: true });
// 清除sessionStorage
sessionStorage.removeItem('user');
@ -250,6 +266,8 @@ const actions = {
sessionStorage.removeItem('userType');
sessionStorage.removeItem('orgType');
sessionStorage.removeItem('roles'); // 新增:清除角色信息
sessionStorage.removeItem('juese');
sessionStorage.removeItem('jueseNew');
resolve();
});
},

View File

@ -25,7 +25,7 @@
// reset element-ui css
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
transition: width .28s ease, padding-left .28s ease, padding-right .28s ease;
}
.scrollbar-wrapper {
@ -100,11 +100,11 @@
.hideSidebar {
.sidebar-container {
width: 54px !important;
width: 64px !important;
}
.main-container {
margin-left: 54px;
margin-left: 64px;
}
.submenu-title-noDropdown {

View File

@ -20,9 +20,9 @@
</p>
<!-- 产品与服务鼠标移入显示子菜单 -->
<p @mouseleave="sildeOut" @mouseenter="sildeIn(product_service)">
<a>产品与服务</a>
<a>基础云</a>
</p>
<p class="nav-hover" @click="$router.push('/product')">模型广场</p>
<p class="nav-hover" @click="handleModelSquareClick">token市集</p>
<p class="nav-hover" @click="goYuanjing">元境</p>
<!-- 供需广场 -->
<p :class="{ active: $route.path.includes('/supply') }">
@ -116,7 +116,11 @@
<div class="panelLeft">
<ul class="outUl">
<li style="cursor: default" class="outLi" v-for="item in showPanelData" :key="item.firTitle">
<span style="cursor: default!important;" :class="['tilte', 'activeFir']">
<span
:style="{ cursor: isPanelFirClickable(item) ? 'pointer' : 'default' }"
:class="['tilte', 'activeFir']"
@click="handlePanelFirClick(item)"
>
{{
item.firTitle
}}
@ -255,6 +259,7 @@ import store from "@/store";
import { getHomePath } from '@/views/setting/tools'
import MessageCenter from '@/components/MessageCenter/MessageCenter.vue'
import { reqAIChat } from '@/api/AI/ai'
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
export default Vue.extend({
name: "TopBox",
@ -376,9 +381,85 @@ export default Vue.extend({
}
},
methods: {
// https://ai.opencomputing.cn/#/index
goYuanjing() {
window.open('https://ai.opencomputing.cn/#/index')
// 广
handleModelSquareClick() {
if (!this.loginState) {
this.$message.warning('请先登录哦~')
return
}
this.$router.push('/product')
},
//
async goYuanjing() {
if (!this.loginState) {
this.$message.warning('请先登录哦~')
return
}
const yuanJingWindow = window.open('', '_blank')
try {
const res = await gotoYuanJingAPI({
user_id: sessionStorage.getItem('userId')
})
const deerer = this.getYuanJingAuthorization(res)
if (!deerer) {
if (yuanJingWindow) {
yuanJingWindow.close()
}
this.$message.error((res && res.msg) || '获取元境授权参数失败')
return
}
const loginUrl = `https://ai.opencomputing.cn/#/getCookie?deerer=${encodeURIComponent(deerer)}`
if (yuanJingWindow) {
yuanJingWindow.location.href = loginUrl
} else {
window.location.href = loginUrl
}
} catch (error) {
if (yuanJingWindow) {
yuanJingWindow.close()
}
this.$message.error('跳转元境失败,请稍后重试')
}
},
getYuanJingAuthorization(res) {
if (!res) {
return ''
}
if (typeof res === 'string') {
return res
}
const data = res.data || res
if (typeof data === 'string') {
return data
}
return data.Authorization || data.authorization || data.token || data.header || data.value || ''
},
isPanelFirClickable(item) {
const title = (item && item.firTitle) || ''
return title === '元境' || title === 'TOKEN市集' || title === 'token市集'
},
handlePanelFirClick(item) {
const title = (item && item.firTitle) || ''
if (title === '元境') {
this.$store.commit('setShowHomeNav', false)
this.goYuanjing()
return
}
if (title === 'TOKEN市集' || title === 'token市集') {
this.$store.commit('setShowHomeNav', false)
this.handleModelSquareClick()
}
},
// AI
handleAIClick() {
@ -668,11 +749,17 @@ export default Vue.extend({
async logout() {
this.$store.commit('setLoginState', false)
store.commit('tagsView/resetBreadcrumbState');
store.commit('permission/RESET_ROUTES');
sessionStorage.removeItem("auths");
sessionStorage.removeItem("routes");
sessionStorage.removeItem("user");
sessionStorage.removeItem("userId");
sessionStorage.removeItem("org_type")
sessionStorage.removeItem("userType");
sessionStorage.removeItem("orgType");
sessionStorage.removeItem("roles");
sessionStorage.removeItem("juese");
sessionStorage.removeItem("jueseNew");
localStorage.removeItem("auths");
localStorage.removeItem("routes");
localStorage.removeItem("user");

File diff suppressed because it is too large Load Diff

View File

@ -464,6 +464,7 @@ export default {
sessionStorage.setItem("username", user.username); //
//
const routeRoles = admin !== 1 ? response.roles : ['admin'];
if (admin !== 1) {
sessionStorage.setItem("juese", response.roles[0]);
sessionStorage.setItem("jueseNew", response.roles);
@ -474,14 +475,18 @@ export default {
sessionStorage.setItem("juese", "admin");
sessionStorage.setItem("jueseNew", "admin");
}
sessionStorage.setItem("roles", JSON.stringify(routeRoles));
//
resetRouter();
this.$store.commit("permission/RESET_ROUTES");
const accessRoutes = await this.$store.dispatch("permission/generateRoutes", {
user: user.username,
auths: data,
admin: admin || "",
orgType: org_type,
roles: routeRoles,
});
resetRouter();
console.log("生成的路径是", accessRoutes);
router.addRoutes(accessRoutes);

View File

@ -0,0 +1,422 @@
<template>
<el-dialog
title="模型上架"
:visible.sync="dialogVisible"
width="760px"
custom-class="add-model-dialog"
:before-close="handleClose"
>
<el-form ref="form" class="model-form" :model="form" :rules="rules" label-position="top">
<section class="form-section">
<div class="section-title">
<i class="el-icon-info"></i>
<span>基本信息</span>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="模型名称/版本" prop="name">
<el-input v-model="form.name" placeholder="请输入模型名称/版本"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="模型类型" prop="type">
<el-input v-model="form.type" placeholder="请输入模型类型"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="供应商" prop="supplier">
<el-input v-model="form.supplier" placeholder="请输入供应商"></el-input>
</el-form-item>
</el-col>
</el-row>
</section>
<section class="form-section">
<div class="section-title">
<i class="el-icon-money"></i>
<span>模型价格</span>
</div>
<el-form-item label="计费方式">
<el-input v-model="form.billingMethod" placeholder="请输入计费方式"></el-input>
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="请输入单位"></el-input>
</el-form-item>
<el-form-item label="输入价格">
<el-input v-model="form.inputPrice" placeholder="请输入输入价格"></el-input>
</el-form-item>
<el-form-item label="输出价格">
<el-input v-model="form.outputPrice" placeholder="请输入输出价格"></el-input>
</el-form-item>
<el-form-item label="缓存命中价格">
<el-input v-model="form.cacheHitInputPrice" placeholder="请输入缓存命中价格"></el-input>
</el-form-item>
</section>
<section class="form-section">
<div class="section-title">
<i class="el-icon-s-operation"></i>
<span>模型介绍</span>
</div>
<el-form-item prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="6"
placeholder="请详细描述模型的功能、特点、使用场景等信息"
></el-input>
</el-form-item>
</section>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button class="cancel-btn" @click="handleClose">取消</el-button>
<el-button type="primary" class="submit-btn" icon="el-icon-upload2" @click="handleSubmit">提交</el-button>
</span>
</el-dialog>
</template>
<script>
const defaultForm = () => ({
id: '',
llmid: '',
name: '',
displayName: '',
type: '',
supplier: '',
contextLength: '',
billingMethod: '',
unit: '',
inputPrice: '',
outputPrice: '',
cacheHitInputPrice: '',
capabilities: '',
limitations: '',
highlights: '',
description: ''
})
export default {
name: 'AddModelDialog',
props: {
visible: {
type: Boolean,
default: false
},
modelDetail: {
type: Object,
default: null
}
},
data() {
return {
form: defaultForm(),
modelNameOptions: ['GPT-3.5-Turbo', 'GPT-4', 'Claude-3', 'DeepSeek-V4', 'Llama-2-70B'],
modelTypeOptions: [
{ label: '自然语言处理', value: '自然语言处理' },
{ label: '计算机视觉', value: '计算机视觉' },
{ label: '语音', value: '语音' },
{ label: '多模态', value: '多模态' }
],
supplierOptions: ['OpenAI', 'Google', '开元云', 'Anthropic', 'Meta', 'DeepSeek'],
rules: {
name: [{ required: true, message: '请输入模型名称/版本', trigger: 'blur' }],
type: [{ required: true, message: '请输入模型类型', trigger: 'blur' }],
supplier: [{ required: true, message: '请输入供应商', trigger: 'blur' }],
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }],
}
}
},
computed: {
dialogVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
}
},
watch: {
visible(value) {
if (value) {
this.fillForm(this.modelDetail)
}
},
modelDetail: {
handler(value) {
if (this.visible) {
this.fillForm(value)
}
},
deep: true
}
},
methods: {
fillForm(detail) {
if (!detail) {
this.form = defaultForm()
return
}
this.form = {
id: detail.id || '',
llmid: detail.llmid || '',
name: detail.model_name || '',
displayName: detail.display_name || detail.model_name || '',
type: detail.model_type || '',
supplier: detail.provider || '',
contextLength: detail.context_length || '',
billingMethod: detail.billing_method || '',
unit: detail.billing_unit || '',
inputPrice: detail.input_token_price == null ? '' : String(detail.input_token_price),
outputPrice: detail.output_token_price == null ? '' : String(detail.output_token_price),
cacheHitInputPrice: detail.cache_hit_input_price == null ? '' : String(detail.cache_hit_input_price),
capabilities: detail.capabilities || '',
limitations: detail.limitations || '',
highlights: detail.highlights || '',
description: detail.description || ''
}
},
buildSubmitPayload() {
return {
id: this.form.id,
llmid: this.form.llmid,
provider: this.form.supplier,
model_name: this.form.name,
display_name: this.form.name,
context_length: this.form.contextLength,
model_type: this.form.type,
input_token_price: this.form.inputPrice,
output_token_price: this.form.outputPrice,
cache_hit_input_price: this.form.cacheHitInputPrice,
billing_method: this.form.billingMethod,
billing_unit: this.form.unit,
capabilities: this.form.capabilities,
limitations: this.form.limitations,
highlights: this.form.highlights,
description: this.form.description
}
},
handleFileChange(file, fileList) {
this.form.fileList = fileList
},
handleFileRemove(file, fileList) {
this.form.fileList = fileList
},
handleClose() {
this.dialogVisible = false
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (!valid) {
return
}
this.$emit('submit', this.buildSubmitPayload())
})
}
}
}
</script>
<style lang="less" scoped>
/deep/ .add-model-dialog {
margin-top: 40px !important;
border-radius: 10px;
overflow: hidden;
.el-dialog__header {
display: flex;
align-items: center;
height: 64px;
padding: 0 24px;
background: #ffffff;
border-bottom: 1px solid #edf0f3;
}
.el-dialog__title {
color: #111827;
font-size: 20px;
font-weight: 700;
}
.el-dialog__headerbtn {
top: 21px;
right: 24px;
font-size: 22px;
}
.el-dialog__body {
padding: 0 24px;
background: #ffffff;
overflow-x: hidden;
}
.el-dialog__footer {
padding: 16px 24px;
background: #ffffff;
border-top: 1px solid #edf0f3;
}
}
.model-form {
max-height: calc(100vh - 300px);
padding: 24px 26px;
overflow-x: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background: #8a8f98;
border-radius: 999px;
}
&::-webkit-scrollbar-track {
background: #f1f2f4;
}
}
/deep/ .el-row {
max-width: 100%;
margin-left: 0 !important;
margin-right: 0 !important;
}
/deep/ .el-col {
padding-left: 0 !important;
padding-right: 16px !important;
}
/deep/ .el-col:nth-child(2n) {
padding-right: 0 !important;
}
.form-section {
margin-bottom: 28px;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 18px;
color: #111827;
font-size: 18px;
font-weight: 700;
i {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-right: 12px;
font-size: 14px;
border-radius: 50%;
}
}
.form-section:nth-child(1) .section-title i {
color: #8b3ff6;
background: #f2e8ff;
}
.form-section:nth-child(2) .section-title i {
color: #16a34a;
background: #dcfce7;
}
.form-section:nth-child(3) .section-title i {
color: #6366f1;
background: #e8e9ff;
}
/deep/ .el-form-item {
margin-bottom: 16px;
max-width: 100%;
}
/deep/ .el-form-item__label {
padding: 0 0 7px;
color: #4b5563;
font-size: 14px;
line-height: 20px;
}
/deep/ .el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label::before {
color: #f56c6c;
}
/deep/ .el-input__inner,
/deep/ .el-textarea__inner {
max-width: 100%;
color: #111827;
border-color: #dcdfe6;
border-radius: 7px;
box-shadow: none;
}
/deep/ .el-select,
/deep/ .el-input,
/deep/ .el-textarea {
max-width: 100%;
}
/deep/ .el-input__inner {
height: 40px;
line-height: 40px;
}
/deep/ .el-input__inner::placeholder,
/deep/ .el-textarea__inner::placeholder {
color: #a8abb2;
}
/deep/ .el-textarea__inner {
min-height: 112px !important;
padding: 12px 14px;
line-height: 1.6;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.cancel-btn {
color: #374151;
border: none;
background: transparent;
&:hover,
&:focus {
color: #111827;
background: transparent;
}
}
.submit-btn {
min-width: 104px;
height: 40px;
font-weight: 600;
background: linear-gradient(135deg, #8b2ff6 0%, #b02cf4 100%);
border: none;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(139, 47, 246, 0.28);
&:hover,
&:focus {
background: linear-gradient(135deg, #7c25e8 0%, #a025e6 100%);
}
}
</style>

View File

@ -0,0 +1,243 @@
<template>
<div class="api-doc-page">
<header class="doc-nav">
<button type="button" @click="$router.back()">
<i class="el-icon-arrow-left"></i>
返回
</button>
<span>API文档</span>
</header>
<main class="doc-container">
<section class="doc-hero">
<h1>MiniMax-M2.5 API 文档</h1>
<p>通过 OpenAI 兼容接口接入模型能力支持对话生成工具调用和流式输出</p>
<div class="quick-tabs">
<span v-for="item in quickTabs" :key="item">{{ item }}</span>
</div>
</section>
<section class="doc-section">
<h2>1. 接口地址</h2>
<p>统一使用 HTTPS 请求所有接口都需要携带平台签发的 API Key</p>
<pre><code>POST https://api.kboss.example.com/v2/chat/completions</code></pre>
</section>
<section class="doc-section">
<h2>2. 模型能力列表</h2>
<el-table :data="capabilityTable" border size="small">
<el-table-column prop="name" label="能力"></el-table-column>
<el-table-column prop="value" label="说明"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '支持' ? 'success' : 'info'" size="mini">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
</section>
<section class="doc-section">
<h2>3. 请求参数</h2>
<el-table :data="requestParams" border size="small">
<el-table-column prop="name" label="参数名" width="160"></el-table-column>
<el-table-column prop="type" label="类型" width="120"></el-table-column>
<el-table-column prop="required" label="必填" width="90"></el-table-column>
<el-table-column prop="desc" label="说明"></el-table-column>
</el-table>
</section>
<section class="doc-section">
<h2>4. 请求示例</h2>
<pre><code>{{ requestExample }}</code></pre>
</section>
<section class="doc-section">
<h2>5. 返回示例</h2>
<pre><code>{{ responseExample }}</code></pre>
</section>
<section class="doc-section">
<h2>6. 错误码</h2>
<el-table :data="errorCodes" border size="small">
<el-table-column prop="code" label="错误码" width="140"></el-table-column>
<el-table-column prop="message" label="说明"></el-table-column>
<el-table-column prop="suggestion" label="处理建议"></el-table-column>
</el-table>
</section>
</main>
<footer class="doc-footer">© 2026 开元云科技 · API 文档中心</footer>
</div>
</template>
<script>
export default {
name: 'ApiDocument',
data() {
return {
quickTabs: ['API概览', '认证方式', '请求参数', '代码示例', '错误码'],
capabilityTable: [
{ name: '文本对话', value: '支持单轮和多轮对话生成', status: '支持' },
{ name: '流式输出', value: '通过 stream=true 开启 SSE 增量返回', status: '支持' },
{ name: 'Function Call', value: '支持工具调用和结构化参数', status: '支持' },
{ name: '图像输入', value: '可在 messages 中传入图片内容', status: '支持' },
{ name: '私有化部署', value: '当前公共服务暂不支持私有化', status: '暂不支持' }
],
requestParams: [
{ name: 'model', type: 'string', required: '是', desc: '模型 ID例如 minimax-m2.5' },
{ name: 'messages', type: 'array', required: '是', desc: '对话消息列表,包含 role 和 content' },
{ name: 'temperature', type: 'number', required: '否', desc: '采样温度,数值越高输出越随机' },
{ name: 'stream', type: 'boolean', required: '否', desc: '是否开启流式返回' },
{ name: 'max_tokens', type: 'number', required: '否', desc: '限制模型最大输出长度' }
],
errorCodes: [
{ code: '401', message: '认证失败', suggestion: '检查 API Key 是否正确或过期' },
{ code: '429', message: '请求过于频繁', suggestion: '降低并发或等待限流恢复' },
{ code: '500', message: '服务异常', suggestion: '稍后重试或联系平台支持' }
],
requestExample: `curl https://api.kboss.example.com/v2/chat/completions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $KBOSS_API_KEY" \\
-d '{
"model": "minimax-m2.5",
"messages": [
{
"role": "user",
"content": "帮我写一段模型上架介绍"
}
],
"temperature": 0.7,
"stream": false
}'`,
responseExample: `{
"id": "chatcmpl_20260518",
"object": "chat.completion",
"model": "minimax-m2.5",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "这是一款适合企业知识问答和内容生成的通用模型..."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 24,
"completion_tokens": 68,
"total_tokens": 92
}
}`
}
}
}
</script>
<style lang="less" scoped>
.api-doc-page {
height: 100vh;
color: #1f2d3d;
background: #f6f8fb;
overflow-y: auto;
overflow-x: hidden;
}
.doc-nav {
display: flex;
align-items: center;
height: 48px;
padding: 0 28px;
color: #667085;
background: #ffffff;
border-bottom: 1px solid #edf1f7;
button {
padding: 0;
margin-right: 16px;
color: #667085;
cursor: pointer;
background: transparent;
border: none;
}
}
.doc-container {
width: 920px;
margin: 28px auto 0;
}
.doc-hero,
.doc-section {
padding: 24px;
margin-bottom: 18px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 12px;
}
.doc-hero {
h1 {
margin: 0 0 10px;
font-size: 28px;
}
p {
margin: 0 0 18px;
color: #667085;
}
}
.quick-tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
span {
padding: 6px 12px;
color: #2f6bff;
background: #eef4ff;
border-radius: 999px;
font-size: 13px;
}
}
.doc-section {
h2 {
margin: 0 0 16px;
padding-left: 10px;
color: #1f2d3d;
font-size: 18px;
border-left: 3px solid #2f6bff;
}
p {
color: #667085;
line-height: 1.8;
}
}
pre {
margin: 0;
padding: 16px;
color: #e6edf3;
overflow-x: auto;
background: #1f2329;
border-radius: 8px;
line-height: 1.7;
}
code {
font-family: Consolas, Monaco, monospace;
font-size: 13px;
}
.doc-footer {
padding: 32px 0;
color: #98a2b3;
text-align: center;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,546 @@
<template>
<div class="experience-page">
<aside class="chat-sidebar">
<button class="back-btn" title="返回上一页" @click="goBack">
<i class="el-icon-arrow-left"></i>
<span>返回</span>
</button>
<div class="brand-card">
<img class="brand-logo" :src="siteLogo" alt="logo">
<div class="brand-info">
<strong>模型体验</strong>
<span>Model Playground</span>
</div>
</div>
<el-button class="new-chat-btn" type="primary" icon="el-icon-plus" @click="startNewChat">
开启新对话
</el-button>
<div class="history-panel">
<div class="panel-title">最近对话</div>
<div
v-for="item in historyList"
:key="item.id"
class="history-item"
:class="{ active: activeHistoryId === item.id }"
@click="activeHistoryId = item.id"
>
<i class="el-icon-chat-dot-round"></i>
<span>{{ item.title }}</span>
</div>
</div>
</aside>
<main class="chat-main">
<header class="chat-header">
<div>
<h2>K-Boss AI 模型助手</h2>
<p>选择模型后可在这里进行问答推理方案生成和能力验证</p>
</div>
</header>
<section ref="messageBody" class="message-body">
<div v-if="messages.length === 0" class="welcome-card">
<img class="welcome-logo" :src="siteLogo" alt="logo">
<h1>你好我是模型体验助手</h1>
<p>可以输入问题体验模型效果也可以点击下方示例快速开始</p>
<div class="prompt-list">
<button
v-for="prompt in promptList"
:key="prompt"
class="prompt-card"
@click="usePrompt(prompt)"
>
{{ prompt }}
</button>
</div>
</div>
<div
v-for="message in messages"
:key="message.id"
class="message-row"
:class="message.role"
>
<div class="avatar">
<img v-if="message.role === 'assistant'" :src="siteLogo" alt="logo">
<i v-else class="el-icon-user"></i>
</div>
<div class="message-bubble">
<div class="message-name">{{ message.role === 'assistant' ? '模型助手' : '我' }}</div>
<div class="message-text">{{ message.content }}</div>
</div>
</div>
</section>
<footer class="chat-input-area">
<div class="billing-tip">
<i class="el-icon-info"></i>
<span>体验模型将会消耗 Tokens费用以实际发生为主</span>
<button type="button">计费说明</button>
</div>
<div class="input-shell">
<el-input
v-model="inputValue"
type="textarea"
:rows="3"
resize="none"
placeholder="请输入你想体验的问题,例如:帮我生成一个模型上架介绍"
@keydown.native.ctrl.enter="sendMessage"
></el-input>
<div class="input-actions">
<span>Ctrl + Enter 发送</span>
<el-button
type="primary"
icon="el-icon-position"
:disabled="!inputValue.trim()"
@click="sendMessage"
>
发送
</el-button>
</div>
</div>
</footer>
</main>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'ModelExperience',
data() {
return {
inputValue: '',
activeHistoryId: 1,
messages: [],
historyList: [
{ id: 1, title: '模型能力咨询' },
{ id: 2, title: '产品介绍生成' },
{ id: 3, title: '部署方案建议' }
],
promptList: [
'帮我介绍一下这个模型适合哪些业务场景',
'生成一段模型上架介绍文案',
'给我一份模型部署前的检查清单',
'对比大语言模型和图像识别模型的使用差异'
]
}
},
computed: {
...mapState({
logoInfoNew: state => state.product.logoInfoNew
}),
siteLogo() {
return this.logoInfoNew?.home?.logoImg || require('@/assets/kyy/LOGO.png')
}
},
methods: {
goBack() {
this.$router.back()
},
startNewChat() {
this.messages = []
this.inputValue = ''
},
usePrompt(prompt) {
this.inputValue = prompt
this.sendMessage()
},
sendMessage() {
const content = this.inputValue.trim()
if (!content) return
this.messages.push({
id: Date.now(),
role: 'user',
content
})
this.inputValue = ''
this.mockAssistantReply(content)
},
mockAssistantReply(question) {
setTimeout(() => {
this.messages.push({
id: Date.now() + 1,
role: 'assistant',
content: `已收到你的问题:“${question}”。这里后续可以接入真实模型接口,目前先展示模型对话页面效果。`
})
this.$nextTick(this.scrollToBottom)
}, 300)
},
scrollToBottom() {
const body = this.$refs.messageBody
if (body) {
body.scrollTop = body.scrollHeight
}
}
}
}
</script>
<style lang="less" scoped>
.experience-page {
display: flex;
width: 100vw;
height: 100vh;
padding: 0;
background: #f5f7fb;
overflow: hidden;
}
.chat-sidebar {
display: flex;
flex-direction: column;
width: 280px;
height: 100vh;
padding: 18px;
margin-right: 0;
background: #ffffff;
border: 1px solid #edf1f7;
border-top: none;
border-bottom: none;
border-left: none;
border-radius: 0;
box-shadow: 0 14px 36px rgba(31, 45, 61, 0.06);
}
.back-btn {
display: inline-flex;
align-items: center;
align-self: flex-start;
height: 34px;
padding: 0 12px;
margin-bottom: 14px;
color: #5d6678;
font-size: 13px;
cursor: pointer;
background: #f7f9fc;
border: 1px solid #edf1f7;
border-radius: 999px;
transition: all 0.2s ease;
i {
margin-right: 4px;
}
&:hover {
color: #1e6fff;
background: #eef5ff;
border-color: #bdd7ff;
}
}
.brand-card {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 18px;
background: linear-gradient(135deg, #f4f8ff 0%, #eef5ff 100%);
border-radius: 16px;
}
.brand-logo {
width: 48px;
height: 40px;
margin-right: 12px;
object-fit: contain;
}
.brand-info {
strong {
display: block;
color: #1f2d3d;
font-size: 16px;
}
span {
color: #8a94a6;
font-size: 12px;
}
}
.new-chat-btn {
width: 100%;
margin-bottom: 18px;
border-radius: 12px;
}
.history-panel {
flex: 1;
overflow-y: auto;
}
.panel-title {
margin-bottom: 10px;
color: #98a2b3;
font-size: 13px;
}
.history-item {
display: flex;
align-items: center;
height: 42px;
padding: 0 12px;
margin-bottom: 8px;
color: #4d5969;
cursor: pointer;
border-radius: 12px;
transition: all 0.2s ease;
i {
margin-right: 8px;
}
&:hover,
&.active {
color: #1e6fff;
background: #f4f8ff;
}
}
.chat-main {
display: flex;
flex: 1;
min-width: 0;
height: 100vh;
flex-direction: column;
background: #ffffff;
border: none;
border-radius: 0;
box-shadow: none;
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #edf1f7;
h2 {
margin: 0 0 6px;
color: #1f2d3d;
font-size: 20px;
}
p {
margin: 0;
color: #8a94a6;
font-size: 13px;
}
}
.message-body {
flex: 1;
padding: 28px;
overflow-y: auto;
background:
radial-gradient(circle at 50% 0%, rgba(30, 111, 255, 0.08), transparent 30%),
#fbfcff;
}
.welcome-card {
max-width: 760px;
margin: 80px auto 0;
text-align: center;
.welcome-logo {
width: 72px;
height: 56px;
object-fit: contain;
}
h1 {
margin: 18px 0 10px;
color: #1f2d3d;
font-size: 28px;
}
p {
margin: 0 0 24px;
color: #8a94a6;
}
}
.prompt-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.prompt-card {
min-height: 62px;
padding: 14px 16px;
color: #475467;
text-align: left;
cursor: pointer;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 16px;
transition: all 0.2s ease;
&:hover {
color: #1e6fff;
border-color: #bdd7ff;
box-shadow: 0 10px 24px rgba(30, 111, 255, 0.1);
}
}
.message-row {
display: flex;
max-width: 860px;
margin: 0 auto 18px;
&.user {
flex-direction: row-reverse;
.avatar {
margin: 0 0 0 12px;
color: #ffffff;
background: #1e6fff;
}
.message-bubble {
color: #ffffff;
background: linear-gradient(135deg, #1e6fff, #5d8dff);
}
.message-name {
color: rgba(255, 255, 255, 0.78);
}
}
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 38px;
width: 38px;
height: 38px;
margin-right: 12px;
background: #eef5ff;
border-radius: 50%;
img {
width: 28px;
height: 24px;
object-fit: contain;
}
}
.message-bubble {
max-width: 70%;
padding: 13px 16px;
color: #344054;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 16px;
box-shadow: 0 8px 22px rgba(31, 45, 61, 0.05);
}
.message-name {
margin-bottom: 6px;
color: #98a2b3;
font-size: 12px;
}
.message-text {
white-space: pre-wrap;
line-height: 1.7;
}
.chat-input-area {
padding: 18px 24px 22px;
background: #ffffff;
border-top: 1px solid #edf1f7;
}
.billing-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0 auto 10px;
color: #8a94a6;
font-size: 12px;
i {
color: #98a2b3;
font-size: 14px;
}
button {
padding: 0;
color: #1e6fff;
font-size: 12px;
cursor: pointer;
background: transparent;
border: none;
&:hover {
text-decoration: underline;
}
}
}
.input-shell {
max-width: 920px;
margin: 0 auto;
padding: 12px;
background: #f8fbff;
border: 1px solid #e5ecf6;
border-radius: 18px;
/deep/ .el-textarea__inner {
border: none;
background: transparent;
box-shadow: none;
}
}
.input-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 2px 0;
span {
color: #98a2b3;
font-size: 12px;
}
/deep/ .el-button {
border-radius: 999px;
}
}
@media (max-width: 900px) {
.experience-page {
flex-direction: column;
height: 100vh;
}
.chat-sidebar {
width: auto;
height: auto;
max-height: 240px;
margin: 0;
}
.prompt-list {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,349 @@
<template>
<div class="model-detail-page">
<header class="top-nav">
<button class="back-btn" type="button" @click="$router.back()">
<i class="el-icon-arrow-left"></i>
返回
</button>
<span class="nav-divider"></span>
<span>Token市集</span>
<div class="nav-actions">
<span>控制台</span>
<span>用户后台</span>
</div>
</header>
<main class="detail-container">
<section class="model-hero-card">
<div class="model-logo">
<i class="el-icon-data-analysis"></i>
</div>
<div class="model-summary">
<div class="model-title-row">
<h1>{{ modelInfo.name }}</h1>
<el-tag size="mini" type="success">深度推理</el-tag>
</div>
<div class="model-meta">
<span>中文</span>
<span>英文</span>
<span>128K上下文</span>
</div>
<p>{{ modelInfo.description }}</p>
</div>
</section>
<section class="version-card">
<div>
<h3>版本介绍</h3>
<p>
模型ID{{ modelInfo.modelId }}
<i class="el-icon-copy-document"></i>
</p>
<p>当前版本能力稳定适合内容生成知识问答工具调用和复杂任务规划</p>
</div>
<div class="version-actions">
<el-button size="small" @click="$router.push('/modelApiDocument')">API文档</el-button>
<el-button size="small" type="primary" @click="$router.push('/modelExperience')">体验</el-button>
</div>
</section>
<section class="two-column">
<div class="info-card">
<h3>模型能力</h3>
<div v-for="item in capabilityList" :key="item.label" class="info-row">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="info-card">
<h3>模型限制</h3>
<div v-for="item in limitList" :key="item.label" class="info-row">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
<section class="price-card">
<h3>服务价格</h3>
<div class="price-list">
<div class="price-item input">
<span>模型输入</span>
<strong>0.0021</strong>
<em>/千Tokens</em>
</div>
<div class="price-item output">
<span>模型输出</span>
<strong>0.0084</strong>
<em>/千Tokens</em>
</div>
</div>
</section>
<section class="article-card">
<h3>1. 模型介绍</h3>
<p>{{ modelInfo.longDescription }}</p>
<h3>2. 模型亮点</h3>
<div class="feature-block blue">
<i class="el-icon-cpu"></i>
<div>
<strong>推理</strong>
<p>擅长处理数学代码逻辑分析和复杂任务拆解适合作为业务助手和智能问答底座</p>
</div>
</div>
<div class="feature-block purple">
<i class="el-icon-magic-stick"></i>
<div>
<strong>模型调优</strong>
<p>提供稳定的上下文理解能力便于后续结合业务数据进行知识增强和场景优化</p>
</div>
</div>
</section>
</main>
<footer class="page-footer">© 2026 开元云科技 · 模型公共服务平台</footer>
</div>
</template>
<script>
export default {
name: 'ModelDetail',
data() {
return {
modelInfo: {
name: 'MiniMax-M2.5',
modelId: 'abab7c72c278cfba',
description: 'MiniMax-M2.5 是面向复杂任务处理的通用语言模型,适用于知识问答、文案创作、工具调用和办公生产力场景。',
longDescription: 'MiniMax-M2.5 是 MiniMax 推出的新一代旗舰语言模型,致力于提升真实世界复杂任务中的表现。在推理、工具使用和搜索、办公生产力场景中均具备较好的任务完成能力。'
},
capabilityList: [
{ label: '接口类型', value: '/v2/chat/completions' },
{ label: '接入ID', value: 'minimax-m2.5' },
{ label: '输入类型', value: '文本 / 图像 / 音频' },
{ label: '输出类型', value: '文本 / 结构化输出' },
{ label: 'Function Call', value: '支持' },
{ label: '联网搜索', value: '支持' },
{ label: '私有化部署', value: '不支持' }
],
limitList: [
{ label: '输入长度', value: '192K' },
{ label: '单轮输出', value: '32K' },
{ label: '输出长度', value: '128K' },
{ label: '服务速度限制', value: '60 RPM / 250000 TPM' }
]
}
}
}
</script>
<style lang="less" scoped>
.model-detail-page {
height: 100vh;
color: #1f2d3d;
background: #f6f8fb;
overflow-y: auto;
overflow-x: hidden;
}
.top-nav {
display: flex;
align-items: center;
height: 48px;
padding: 0 28px;
color: #667085;
background: #ffffff;
border-bottom: 1px solid #edf1f7;
}
.back-btn {
padding: 0;
color: #667085;
cursor: pointer;
background: transparent;
border: none;
}
.nav-divider {
width: 1px;
height: 14px;
margin: 0 14px;
background: #d8dee9;
}
.nav-actions {
display: flex;
gap: 24px;
margin-left: auto;
font-size: 13px;
}
.detail-container {
width: 920px;
margin: 28px auto 0;
}
.model-hero-card,
.version-card,
.info-card,
.price-card,
.article-card {
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 12px;
}
.model-hero-card {
display: flex;
padding: 28px;
}
.model-logo {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 58px;
width: 58px;
height: 58px;
margin-right: 18px;
color: #ffffff;
font-size: 28px;
background: linear-gradient(135deg, #ff6b6b, #7c6cff);
border-radius: 12px;
}
.model-title-row {
display: flex;
align-items: center;
gap: 10px;
h1 {
margin: 0;
font-size: 26px;
}
}
.model-meta {
display: flex;
gap: 12px;
margin: 12px 0;
color: #667085;
font-size: 13px;
}
.model-summary p,
.version-card p,
.article-card p {
color: #667085;
line-height: 1.8;
}
.version-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 22px 28px;
margin-top: 18px;
}
.two-column {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.info-card {
padding: 24px;
h3 {
margin: 0 0 18px;
padding-left: 10px;
border-left: 3px solid #2f6bff;
}
}
.info-row {
display: flex;
justify-content: space-between;
padding: 13px 0;
border-bottom: 1px solid #f0f2f5;
span {
color: #667085;
}
}
.price-card,
.article-card {
padding: 24px;
margin-top: 18px;
}
.price-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.price-item {
padding: 20px;
border-radius: 12px;
span,
em {
display: block;
color: #667085;
font-style: normal;
}
strong {
display: block;
margin: 8px 0 4px;
color: #5356d8;
font-size: 28px;
}
&.input {
background: #eef4ff;
}
&.output {
background: #fff0fa;
}
}
.feature-block {
display: flex;
gap: 14px;
padding: 18px;
margin-top: 14px;
border-radius: 12px;
i {
color: #2f6bff;
font-size: 20px;
}
strong {
display: block;
margin-bottom: 6px;
}
&.blue {
background: #eef4ff;
}
&.purple {
background: #fff0fa;
}
}
.page-footer {
padding: 32px 0;
color: #98a2b3;
text-align: center;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,580 @@
<template>
<div class="model-page">
<!-- 筛选区 -->
<model-filter
:search-form="searchForm"
:model-type-options="modelTypeOptions"
:provider-options="providerOptions"
@search="handleSearch"
@reset="resetSearch"
/>
<!-- 统计区 -->
<model-stats :stats="modelStats" />
<!-- 列表区 -->
<el-card class="model-table-card" shadow="never">
<div class="table-header">
<div>
<h3>模型列表</h3>
<p>展示模型基础信息支持上下架排序编辑等操作</p>
</div>
<el-button size="small" icon="el-icon-refresh" @click="fetchModelList">刷新</el-button>
</div>
<el-tabs v-model="activeStatus" class="model-status-tabs" @tab-click="handleTabChange">
<el-tab-pane label="待上架" name="pending" />
<el-tab-pane label="已上架" name="listed" />
</el-tabs>
<!-- 统一表格列根据页签动态显示 -->
<el-table
v-loading="tableLoading"
:data="pagedModelList"
class="model-table"
style="width: 100%"
>
<!-- 模型ID -->
<el-table-column label="模型ID" min-width="100" align="center">
<template slot-scope="scope">{{ getModelId(scope.row) }}</template>
</el-table-column>
<!-- 模型名称/版本 -->
<el-table-column label="模型名称/版本" min-width="180">
<template slot-scope="scope">
<span class="model-name-text">{{ getModelDisplayName(scope.row) }}</span>
</template>
</el-table-column>
<!-- 模型类型 -->
<el-table-column label="模型类型" min-width="120" align="center">
<template slot-scope="scope">
<el-tag v-if="getModelType(scope.row) !== '-'" size="mini" class="model-tag">
{{ getModelType(scope.row) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<!-- 供应商 -->
<el-table-column label="供应商" min-width="120" align="center">
<template slot-scope="scope">{{ getProvider(scope.row) }}</template>
</el-table-column>
<!-- 仅已上架展示价格 -->
<el-table-column v-if="activeStatus === 'listed'" label="展示价格" min-width="190">
<template slot-scope="scope">
<div class="price-cell">
<p>输入价格: {{ formatPriceText(getInputPrice(scope.row)) }}</p>
<p>输出价格: {{ formatPriceText(getOutputPrice(scope.row)) }}</p>
</div>
</template>
</el-table-column>
<!-- 仅已上架计费方式 -->
<el-table-column v-if="activeStatus === 'listed'" label="计费方式" min-width="110">
<template slot-scope="scope">
<el-tag size="mini" type="info">{{ scope.row.billing_method || '-' }}</el-tag>
</template>
</el-table-column>
<!-- 仅已上架排序序号增强字段兼容
<el-table-column v-if="activeStatus === 'listed'" label="排序序号" width="90" align="center">
<template slot-scope="scope">
<span class="sort-index">{{ getSortOrder(scope.row) }}</span>
</template>
</el-table-column> -->
<!-- 更新时间 -->
<el-table-column label="更新时间" min-width="160">
<template slot-scope="scope">{{ getUpdateTime(scope.row) }}</template>
</el-table-column>
<!-- 状态 -->
<el-table-column label="状态" min-width="100">
<template slot-scope="scope">
<el-tag :type="getListingStatusType(scope.row.listing_status)" effect="light" size="mini">
{{ getListingStatusText(scope.row.listing_status) }}
</el-tag>
</template>
</el-table-column>
<!-- 仅已上架排序操作 -->
<el-table-column v-if="activeStatus === 'listed'" label="排序" width="130">
<template slot-scope="scope">
<el-button
type="text"
size="small"
icon="el-icon-top"
:loading="sortLoadingId === scope.row.id && sortAction === 'top'"
@click="handleModelTop(scope.row)"
>置顶</el-button>
<el-button
type="text"
size="small"
icon="el-icon-bottom"
:loading="sortLoadingId === scope.row.id && sortAction === 'down'"
@click="handleModelMoveDown(scope.row)"
>下移</el-button>
</template>
</el-table-column>
<!-- 操作列固定列必须放在最后避免覆盖前面的更新时间列 -->
<el-table-column label="操作" fixed="right" :width="activeStatus === 'pending' ? 210 : 130">
<template slot-scope="scope">
<el-button type="text" size="small" @click="openModelDetail(scope.row)">详情</el-button>
<template v-if="activeStatus === 'pending'">
<el-button
type="text"
size="small"
:loading="editLoadingId === scope.row.id"
@click="openEditDialog(scope.row)"
>编辑</el-button>
<el-button
type="text"
size="small"
class="success-text"
:loading="listingLoadingId === scope.row.id"
@click="handleModelUp(scope.row)"
>上架</el-button>
</template>
<template v-else>
<el-button
type="text"
size="small"
class="warning-text"
:loading="listingLoadingId === scope.row.id"
@click="handleModelDown(scope.row)"
>下架</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="table-pagination">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="filteredModelList.length"
:page-sizes="[10, 20, 50]"
:page-size="pageSize"
:current-page.sync="currentPage"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 弹窗组件保持不变 -->
<model-detail-dialog :visible.sync="detailDialogVisible" :model="currentModel" />
<listing-confirm-dialog
:visible.sync="listingConfirmVisible"
:action="listingConfirmAction"
:model="listingConfirmModel"
:loading="listingLoadingId === (listingConfirmModel && listingConfirmModel.id)"
@close="closeListingConfirm"
@confirm="confirmListingAction"
/>
<add-model-dialog :visible.sync="editDialogVisible" :model-detail="currentEditModel" @submit="handleEditSubmit" />
</div>
</template>
<script>
import {
reqModelBottom,
reqModelDetail,
reqModelDown,
reqModelEdit,
reqModelList,
reqModelTop,
reqModelUp
} from '@/api/model/model'
import ListingConfirmDialog from '@/components/modelManagement/ListingConfirmDialog.vue'
import ModelDetailDialog from '@/components/modelManagement/ModelDetailDialog.vue'
import ModelFilter from '@/components/modelManagement/ModelFilter.vue'
import ModelStats from '@/components/modelManagement/ModelStats.vue'
import AddModelDialog from './AddModelDialog.vue'
export default {
name: 'ModelManagement',
components: {
AddModelDialog,
ListingConfirmDialog,
ModelDetailDialog,
ModelFilter,
ModelStats
},
data() {
return {
tableLoading: false,
activeStatus: 'pending',
currentPage: 1,
pageSize: 10,
searchForm: { name: '', type: '', provider: '' },
detailDialogVisible: false,
currentModel: null,
editDialogVisible: false,
currentEditModel: null,
editLoadingId: null,
listingLoadingId: null,
sortLoadingId: null,
sortAction: '',
listingConfirmVisible: false,
listingConfirmAction: 'up',
listingConfirmModel: null,
modelList: [],
modelTypeOptions: [],
providerOptions: [],
serverStats: { total: 0, pending: 0, listed: 0 }
}
},
computed: {
filteredModelList() {
const selectedStatus = this.activeStatus === 'pending' ? 0 : 1
return this.modelList.filter(model => Number(model.listing_status) === selectedStatus)
},
pagedModelList() {
const start = (this.currentPage - 1) * this.pageSize
return this.filteredModelList.slice(start, start + this.pageSize)
},
modelStats() {
return [
{ label: '全部模型', value: this.serverStats.total, icon: 'el-icon-cpu', className: 'primary' },
{ label: '待上架', value: this.serverStats.pending, icon: 'el-icon-warning', className: 'warning' },
{ label: '已上架', value: this.serverStats.listed, icon: 'el-icon-success', className: 'success' }
]
}
},
created() {
this.fetchModelList()
},
methods: {
async fetchModelList() {
this.tableLoading = true
try {
const res = await reqModelList(this.getSearchParams())
const data = this.extractModelData(res)
this.serverStats = {
total: Number(data.total_count || 0),
pending: Number(data.pending_count || 0),
listed: Number(data.listed_count || 0)
}
this.modelList = Array.isArray(data.model_list) ? data.model_list : []
this.modelTypeOptions = this.buildOptions(data.model_type_list, this.modelList.map(item => item.model_type))
this.providerOptions = this.buildOptions(data.provider_list, this.modelList.map(item => item.provider))
this.currentPage = 1
} catch {
this.modelList = []
this.modelTypeOptions = []
this.providerOptions = []
this.serverStats = { total: 0, pending: 0, listed: 0 }
this.$message.error('模型列表加载失败,请稍后重试')
} finally {
this.tableLoading = false
}
},
getSearchParams() {
const params = {}
const modelName = this.searchForm.name.trim()
if (modelName) params.model_name = modelName
if (this.searchForm.type) params.model_type = this.searchForm.type
if (this.searchForm.provider) params.provider = this.searchForm.provider
return params
},
extractModelData(res) {
const data = res?.data ?? res
if (data?.model_list) return data
if (data?.id) {
const listingStatus = Number(data.listing_status)
return {
total_count: 1,
pending_count: listingStatus === 0 ? 1 : 0,
listed_count: listingStatus === 1 ? 1 : 0,
model_list: [data]
}
}
return {}
},
buildOptions(primaryList, fallbackList) {
const list = Array.isArray(primaryList) && primaryList.length ? primaryList : fallbackList
return [...new Set((Array.isArray(list) ? list : []).filter(Boolean))]
},
getFieldValue(row, fields) {
if (!row) return ''
const source = row.model_info && typeof row.model_info === 'object'
? { ...row.model_info, ...row }
: row
for (const field of fields) {
const value = source[field]
if (value !== undefined && value !== null && value !== '') return value
}
return ''
},
getModelDisplayName(row) {
return row.display_name || row.model_name || '-'
},
getModelId(row) {
return row?.id || '-'
},
getModelType(row) {
return this.getFieldValue(row, ['model_type', 'modelType', 'type', 'category', 'model_category']) || '-'
},
getProvider(row) {
return row?.provider || '-'
},
getInputPrice(row) {
return this.getFieldValue(row, [
'input_token_price', 'inputTokenPrice',
'input_price', 'inputPrice',
'prompt_price', 'promptPrice'
])
},
getOutputPrice(row) {
return this.getFieldValue(row, [
'output_token_price', 'outputTokenPrice',
'output_price', 'outputPrice',
'completion_price', 'completionPrice'
])
},
//
getSortOrder(row) {
const order = this.getFieldValue(row, ['sort_order', 'sortOrder', 'order', 'sort_index', 'sortIndex'])
return order !== '' ? order : '-'
},
//
getUpdateTime(row) {
if (!row) return '-'
const info = row.model_info && typeof row.model_info === 'object' ? row.model_info : {}
return row.updated_at
|| info.updated_at
|| row.update_time
|| info.update_time
|| row.updatedAt
|| info.updatedAt
|| row.updateTime
|| info.updateTime
|| '-'
},
getListingStatusText(status) {
return Number(status) === 1 ? '已上架' : '待上架'
},
getListingStatusType(status) {
return Number(status) === 1 ? 'success' : 'warning'
},
openModelDetail(row) {
this.currentModel = row
this.detailDialogVisible = true
},
async openEditDialog(row) {
if (!row?.id) {
this.$message.error('缺少模型ID无法编辑')
return
}
this.editLoadingId = row.id
try {
const res = await reqModelDetail(row.id)
const detail = res?.data?.id ? res.data : (res?.id ? res : null)
if (!detail?.id) throw new Error('模型详情为空')
this.currentEditModel = detail
this.editDialogVisible = true
} catch (error) {
this.$message.error(error.message || '模型详情加载失败')
} finally {
this.editLoadingId = null
}
},
async handleEditSubmit(form) {
try {
const res = await reqModelEdit(form)
if (res?.status === false) throw new Error(res.msg || '模型编辑失败')
this.editDialogVisible = false
this.currentEditModel = null
this.$message.success('模型编辑成功')
await this.fetchModelList()
} catch (error) {
this.$message.error(error.message || '模型编辑失败,请稍后重试')
}
},
handleModelUp(row) {
this.openListingConfirm(row, 'up')
},
handleModelDown(row) {
this.openListingConfirm(row, 'down')
},
async handleModelTop(row) {
await this.updateModelSort(row, 'top')
},
async handleModelMoveDown(row) {
await this.updateModelSort(row, 'down')
},
async updateModelSort(row, action) {
if (!row?.id) {
this.$message.error('缺少模型ID无法排序')
return
}
const actionText = action === 'top' ? '置顶' : '下移'
this.sortLoadingId = row.id
this.sortAction = action
try {
const res = action === 'top' ? await reqModelTop(row.id) : await reqModelBottom(row.id)
if (res?.status === false) throw new Error(res.msg || `${actionText}失败`)
this.$message.success(`${actionText}成功`)
this.activeStatus = 'listed'
await this.fetchModelList()
} catch (error) {
this.$message.error(error.message || `${actionText}失败,请稍后重试`)
} finally {
this.sortLoadingId = null
this.sortAction = ''
}
},
openListingConfirm(row, action) {
if (!row?.id) {
this.$message.error('缺少模型ID无法操作')
return
}
this.listingConfirmModel = row
this.listingConfirmAction = action
this.listingConfirmVisible = true
},
closeListingConfirm() {
if (this.listingLoadingId) return
this.listingConfirmVisible = false
this.listingConfirmModel = null
},
async confirmListingAction() {
await this.updateModelListingStatus(this.listingConfirmModel, this.listingConfirmAction)
},
async updateModelListingStatus(row, action) {
if (!row?.id) {
this.$message.error('缺少模型ID无法操作')
return
}
const isUp = action === 'up'
const actionText = isUp ? '上架' : '下架'
this.listingLoadingId = row.id
try {
const res = isUp ? await reqModelUp(row.id) : await reqModelDown(row.id)
if (res?.status === false) throw new Error(res.msg || `${actionText}失败`)
this.$message.success(`${actionText}成功`)
this.listingConfirmVisible = false
this.listingConfirmModel = null
this.activeStatus = isUp ? 'listed' : 'pending'
await this.fetchModelList()
} catch (error) {
this.$message.error(error.message || `${actionText}失败,请稍后重试`)
} finally {
this.listingLoadingId = null
}
},
handleSearch() {
this.currentPage = 1
this.fetchModelList()
},
resetSearch() {
this.searchForm = { name: '', type: '', provider: '' }
this.currentPage = 1
this.fetchModelList()
},
handleTabChange() {
this.currentPage = 1
},
handleSizeChange(size) {
this.pageSize = size
this.currentPage = 1
},
handleCurrentChange(page) {
this.currentPage = page
},
formatPrice(value) {
return Number(value || 0).toFixed(4)
},
formatPriceText(value) {
if (value === undefined || value === null || value === '') return '-'
return `¥${this.formatPrice(value)}/千Token`
}
}
}
</script>
<style lang="less" scoped>
.model-page {
min-height: 100vh;
padding: 24px;
background: linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 44%, #ffffff 100%);
}
.model-table-card {
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
/deep/ .el-card__body {
padding: 20px;
}
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
margin: 0;
color: #1f2d3d;
font-size: 18px;
}
p {
margin: 8px 0 0;
color: #909399;
font-size: 13px;
}
}
.model-table {
/deep/ .el-table__header th {
color: #475467;
background: #f8fbff;
}
/deep/ .el-table__row:hover > td {
background: #f8fbff;
}
}
.model-status-tabs {
margin-bottom: 12px;
/deep/ .el-tabs__nav-wrap::after {
height: 1px;
background: #edf1f7;
}
}
.model-tag {
border-radius: 999px;
}
.model-name-text {
color: #1f2d3d;
font-weight: 600;
}
.sort-index {
color: #606266;
font-weight: 600;
}
.price-cell p {
margin: 0;
color: #606266;
font-size: 12px;
line-height: 1.6;
}
.success-text {
color: #67c23a;
}
.warning-text {
color: #e6a23c;
}
.table-pagination {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
@media (max-width: 768px) {
.model-page {
padding: 16px;
}
}
</style>

View File

@ -0,0 +1,197 @@
<template>
<div class="operation-report-page">
<div class="report-header">
<div>
<h2>运营报表</h2>
<p>模型使用与计费数据概览</p>
</div>
<el-button size="small" icon="el-icon-document" @click="exportReport">导出报表</el-button>
</div>
<div class="stat-grid">
<div class="stat-card purple">
<div class="stat-title">活跃用户</div>
<div class="stat-value">{{ statCards.activeUsers }}</div>
</div>
<div class="stat-card green">
<div class="stat-title">Token消耗</div>
<div class="stat-value">{{ statCards.tokenUsage }}</div>
</div>
<div class="stat-card orange">
<div class="stat-title">Tokens总费用</div>
<div class="stat-value">¥{{ statCards.totalFee }}</div>
</div>
</div>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="用户">
<el-input v-model="filterForm.userName" placeholder="搜索用户名" clearable />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="filterForm.modelName" placeholder="全部模型" clearable>
<el-option v-for="item in modelOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="支付方式">
<el-select v-model="filterForm.paymentMethod" placeholder="全部" clearable>
<el-option label="支付宝" value="支付宝" />
<el-option label="微信支付" value="微信支付" />
</el-select>
</el-form-item>
<el-form-item label="使用时间">
<el-date-picker v-model="filterForm.date" type="date" placeholder="年/月/日" value-format="yyyy-MM-dd" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="table-card">
<el-table :data="pagedList" style="width: 100%">
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="userId" label="用户ID" min-width="110" />
<el-table-column prop="userName" label="用户名" min-width="100" />
<el-table-column prop="modelName" label="使用模型" min-width="140">
<template slot-scope="scope">
<el-tag type="info" size="mini">{{ scope.row.modelName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="useTime" label="使用时间" min-width="160" />
<el-table-column prop="inputToken" label="输入TOKEN" min-width="110" />
<el-table-column prop="outputToken" label="输出TOKEN" min-width="110" />
<el-table-column prop="tokenCost" label="TOKEN费用(元)" min-width="120" />
<el-table-column prop="paymentMethod" label="支付方式" min-width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.paymentMethod === '支付宝' ? 'primary' : 'success'">
{{ scope.row.paymentMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="balance" label="账户余额(元)" min-width="120" />
</el-table>
<div class="pager-wrap">
<el-pagination
background
layout="prev, pager, next, total"
:total="filteredList.length"
:page-size="pageSize"
:current-page.sync="currentPage"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script>
export default {
name: "OperationReport",
data() {
return {
pageSize: 10,
currentPage: 1,
filterForm: {
userName: "",
modelName: "",
paymentMethod: "",
date: ""
},
reportList: [
{ userId: "U100001", userName: "张明远", modelName: "MiniMax-M2.7", useTime: "2026-04-20 14:32:18", inputToken: "1,520", outputToken: "2,380", tokenCost: "¥0.047", paymentMethod: "支付宝", balance: "¥87.53" },
{ userId: "U100002", userName: "李恩涵", modelName: "DeepSeek-V3.2", useTime: "2026-04-20 14:28:45", inputToken: "3,200", outputToken: "4,500", tokenCost: "¥0.012", paymentMethod: "微信支付", balance: "¥32.18" },
{ userId: "U100003", userName: "王建国", modelName: "GLM-5.1", useTime: "2026-04-20 14:15:33", inputToken: "890", outputToken: "1,200", tokenCost: "¥0.017", paymentMethod: "支付宝", balance: "¥185.42" },
{ userId: "U100004", userName: "陈晓鸽", modelName: "Qwen3.5-72B", useTime: "2026-04-20 13:58:21", inputToken: "2,100", outputToken: "3,500", tokenCost: "¥0.063", paymentMethod: "支付宝", balance: "¥56.91" },
{ userId: "U100005", userName: "赵伟", modelName: "GPT-4", useTime: "2026-04-20 13:45:10", inputToken: "4,500", outputToken: "6,800", tokenCost: "¥0.576", paymentMethod: "微信支付", balance: "¥312.45" },
{ userId: "U100006", userName: "刘芳", modelName: "ERNIE-4.5-Turbo", useTime: "2026-04-20 13:30:05", inputToken: "1,800", outputToken: "2,200", tokenCost: "¥0.042", paymentMethod: "支付宝", balance: "¥68.77" }
]
};
},
computed: {
statCards() {
return {
activeUsers: "1,286",
tokenUsage: "3.2M",
totalFee: "38,642"
};
},
modelOptions() {
return [...new Set(this.reportList.map(item => item.modelName))];
},
filteredList() {
return this.reportList.filter(item => {
const matchUser = !this.filterForm.userName || item.userName.includes(this.filterForm.userName);
const matchModel = !this.filterForm.modelName || item.modelName === this.filterForm.modelName;
const matchPay = !this.filterForm.paymentMethod || item.paymentMethod === this.filterForm.paymentMethod;
const matchDate = !this.filterForm.date || item.useTime.startsWith(this.filterForm.date);
return matchUser && matchModel && matchPay && matchDate;
});
},
pagedList() {
const start = (this.currentPage - 1) * this.pageSize;
return this.filteredList.slice(start, start + this.pageSize);
}
},
methods: {
handleSearch() {
this.currentPage = 1;
},
resetSearch() {
this.filterForm = { userName: "", modelName: "", paymentMethod: "", date: "" };
this.currentPage = 1;
},
handlePageChange(page) {
this.currentPage = page;
},
exportReport() {
this.$message.success("报表导出任务已提交");
}
}
};
</script>
<style scoped lang="scss">
.operation-report-page {
padding: 20px;
background: #f5f7fb;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 { margin: 0; font-size: 28px; }
p { margin: 6px 0 0; color: #8b95a7; }
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.stat-card {
background: #fff;
border: 1px solid #eceff5;
border-radius: 12px;
padding: 18px 20px;
}
.stat-title { color: #5f6b7d; margin-bottom: 10px; }
.stat-value { font-size: 38px; font-weight: 700; line-height: 1; }
.purple .stat-value { color: #7f56d9; }
.green .stat-value { color: #16a34a; }
.orange .stat-value { color: #ea580c; }
.filter-card,
.table-card {
border: 1px solid #eceff5;
border-radius: 12px;
margin-bottom: 14px;
}
.pager-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -1,5 +1,40 @@
<template>
<div class="product-service-page">
<!-- <div class="page-hero">
<div class="hero-content">
<span class="hero-tag">Token Market</span>
<h2>token市集</h2>
<p>汇聚云AI 等产品能力按分类快速查找并进入对应服务</p>
</div>
<div class="hero-stats">
<div class="stat-card">
<span>一级分类</span>
<strong>{{ panelData.length }}</strong>
</div>
<div class="stat-card">
<span>当前产品</span>
<strong>{{ currentProducts.length }}</strong>
</div>
<div class="stat-card">
<span>全部产品</span>
<strong>{{ totalProductCount }}</strong>
</div>
</div>
</div>
<div class="page-toolbar">
<div>
<h3>{{ activeCategory || '全部分类' }}</h3>
<p>{{ activeCategorySummary }}</p>
</div>
<el-input
v-model="searchKeyword"
class="product-search"e
clearable
prefix-icon="el-icon-search"
placeholder="搜索产品名称、类型或描述"
></el-input>
</div> -->
<!-- 产品分类导航 -->
<div class="category-nav">
@ -8,7 +43,7 @@
class="nav-item"
:class="{ active: activeCategory === category.firTitle }"
@click="switchCategory(category)">
{{ category.firTitle }}
<span>{{ category.firTitle }}</span>
</div>
</div>
@ -16,7 +51,18 @@
<div class="product-content">
<!-- 左侧菜单区域只有有二级或三级菜单时才显示 -->
<div v-if="hasMenuSidebar" class="menu-sidebar">
<div class="input">
<el-input
v-model="searchKeyword"
class="product-search"
clearable
prefix-icon="el-icon-search"
placeholder="搜索产品名称、类型或描述"
></el-input>
</div>
<!-- 二级菜单 -->
<div v-if="hasSecondLevel" class="subcategory-section">
<div class="subcategory-list">
<div v-for="subItem in currentSubcategories"
@ -50,12 +96,18 @@
<div class="main-content" :class="{ 'full-width': !hasMenuSidebar }">
<!-- 产品网格 -->
<div class="product-grid">
<div v-for="product in currentProducts"
<div v-for="product in displayedProducts"
:key="product.id"
class="product-card"
@click="handleProductClick(product)">
<div class="product-header">
<h3 class="product-name">{{ product.name }}</h3>
<div class="product-icon">
<i :class="getProductIcon(product)"></i>
</div>
<div class="product-title">
<h3 class="product-name">{{ product.name }}</h3>
<span class="product-type">{{ product.type }}</span>
</div>
<span v-if="product.discount" class="discount-badge">
{{ product.discount }}
</span>
@ -64,16 +116,22 @@
{{ getProductDescription(product) }}
</div>
<div class="product-footer">
<span class="product-type">{{ product.type }}</span>
<button class="detail-btn">查看详情</button>
<span class="product-status">稳定服务</span>
<button class="detail-btn" @click.stop="handleProductClick(product)">
查看详情
<i class="el-icon-arrow-right"></i>
</button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!hasProducts" class="empty-state">
<div class="empty-icon">📦</div>
<p class="empty-text">暂无产品数据</p>
<div v-if="!hasDisplayProducts" class="empty-state">
<div class="empty-icon">
<i class="el-icon-box"></i>
</div>
<p class="empty-text">{{ searchKeyword ? '没有匹配的产品' : '暂无产品数据' }}</p>
<span>可以切换分类或调整搜索关键词后再试</span>
</div>
</div>
</div>
@ -81,7 +139,8 @@
</template>
<script>
import { reqNavList, reqNewHomeSync, reqNewHomeFestival } from "@/api/newHome";
import { reqNavList, reqNewHomeSync, reqNewHomeFestival ,reqTokenMarket} from "@/api/newHome";
import { gotoYuanJingAPI } from '@/api/gotoYuanJing'
export default {
name: "ProductServicePage",
@ -91,7 +150,9 @@ export default {
activeCategory: '',
activeSubId: null,
activeThirdId: null,
currentProducts: []
searchKeyword: '',
currentProducts: [],
tokenList: []
};
},
computed: {
@ -109,6 +170,40 @@ export default {
hasProducts() {
return this.currentProducts && this.currentProducts.length > 0;
},
displayedProducts() {
const keyword = this.searchKeyword.trim().toLowerCase();
if (!keyword) {
return this.currentProducts;
}
return this.currentProducts.filter(product => {
const productText = [
product.name,
product.type,
this.getProductDescription(product)
].join(' ').toLowerCase();
return productText.includes(keyword);
});
},
hasDisplayProducts() {
return this.displayedProducts && this.displayedProducts.length > 0;
},
totalProductCount() {
return this.panelData.reduce((total, category) => {
return total + this.getAllProductsFromCategory(category).length;
}, 0);
},
activeCategorySummary() {
if (!this.activeCategory) {
return '选择分类后查看对应产品能力';
}
const productCount = this.currentProducts.length;
const subCount = this.currentSubcategories.length;
return `当前分类包含 ${subCount} 个子类,${productCount} 个可选产品`;
},
hasMenuSidebar() {
return this.hasSecondLevel || this.hasThirdLevel;
},
@ -136,7 +231,18 @@ export default {
await this.loadNavData();
this.initializeDefaultData();
},
created() {
this.getTokenList()
},
methods: {
async getTokenList(){
const res = await reqTokenMarket()
console.log('token市集',res);
if(res.status===true){
this.tokenList = res.data.model_list
}
},
// key
getSubItemKey(subItem) {
return `${subItem.id}_${this.activeCategory}_${subItem.secTitle}`;
@ -253,11 +359,66 @@ export default {
//
switchCategory(category) {
if (category && category.firTitle === '元境') {
this.goYuanjing()
return;
}
this.activeCategory = category.firTitle;
this.activeSubId = null;
this.activeThirdId = null;
this.setDefaultSubcategory(category);
},
async goYuanjing() {
const userId = sessionStorage.getItem('userId')
if (!userId || userId === 'null' || userId === '') {
this.$message.warning('请先登录哦~')
return
}
const yuanJingWindow = window.open('', '_blank')
try {
const res = await gotoYuanJingAPI({ user_id: userId })
const deerer = this.getYuanJingAuthorization(res)
if (!deerer) {
if (yuanJingWindow) {
yuanJingWindow.close()
}
this.$message.error((res && res.msg) || '获取元境授权参数失败')
return
}
const loginUrl = `https://ai.opencomputing.cn/#/getCookie?deerer=${encodeURIComponent(deerer)}`
if (yuanJingWindow) {
yuanJingWindow.location.href = loginUrl
} else {
window.location.href = loginUrl
}
} catch (error) {
if (yuanJingWindow) {
yuanJingWindow.close()
}
this.$message.error('跳转元境失败,请稍后重试')
}
},
getYuanJingAuthorization(res) {
if (!res) {
return ''
}
if (typeof res === 'string') {
return res
}
const data = res.data || res
if (typeof data === 'string') {
return data
}
return data.Authorization || data.authorization || data.token || data.header || data.value || ''
},
//
switchSubcategory(subItem) {
@ -329,6 +490,18 @@ export default {
return descriptions[product.name] || '专业的云服务产品,提供稳定可靠的服务';
},
//
getProductIcon(product) {
const iconMap = {
'百度云': 'el-icon-cloudy',
'阿里云': 'el-icon-cloudy-and-sunny',
'智算': 'el-icon-cpu',
'算力网络': 'el-icon-connection'
};
return iconMap[product.type] || 'el-icon-coin';
},
//
async handleAliyunProductClick(useid) {
//
@ -666,4 +839,347 @@ export default {
}
}
}
.product-service-page {
background: linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 38%, #ffffff 100%);
.page-hero {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 24px;
margin-bottom: 20px;
padding: 28px;
color: #ffffff;
background:
radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.28) 0, rgba(255, 255, 255, 0) 32%),
linear-gradient(135deg, #1e6fff 0%, #5d8dff 52%, #7fb0ff 100%);
border-radius: 22px;
box-shadow: 0 18px 42px rgba(30, 111, 255, 0.18);
.hero-content {
max-width: 560px;
.hero-tag {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 12px;
margin-bottom: 14px;
font-size: 12px;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
}
h2 {
margin: 0 0 10px;
font-size: 30px;
font-weight: 700;
letter-spacing: 1px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.86);
font-size: 14px;
line-height: 1.8;
}
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 116px);
gap: 12px;
align-items: center;
.stat-card {
padding: 16px 14px;
text-align: center;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
span {
display: block;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
}
strong {
font-size: 26px;
line-height: 1;
}
}
}
}
.product-search {
margin: 10px 0;
/deep/ .el-input__inner {
height: 40px;
line-height: 40px;
border-radius: 12px;
}
}
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 16px;
padding: 18px 20px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
h3 {
margin: 0 0 6px;
color: #1f2d3d;
font-size: 18px;
}
p {
margin: 0;
color: #8a94a6;
font-size: 13px;
}
}
.category-nav {
gap: 10px;
padding: 10px;
overflow-x: auto;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.05);
.nav-item {
flex: 1;
min-width: 96px;
padding: 12px 18px;
text-align: center;
border: 0;
border-radius: 12px;
&:hover {
background: #f4f8ff;
}
&.active {
color: #ffffff;
background: linear-gradient(135deg, #1e6fff, #5d8dff);
box-shadow: 0 10px 22px rgba(30, 111, 255, 0.18);
}
}
}
.product-content {
align-items: flex-start;
.menu-sidebar {
position: sticky;
top: 16px;
padding: 16px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
}
}
.product-content .subcategory-list,
.product-content .third-level-list {
.subcategory-item,
.third-level-item {
padding: 12px 14px;
border-radius: 12px;
&.active {
background: linear-gradient(135deg, rgba(30, 111, 255, 0.12), rgba(93, 141, 255, 0.08));
box-shadow: inset 3px 0 0 #1e6fff;
}
}
}
.product-content .main-content {
min-width: 0;
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
.product-card {
position: relative;
min-height: 178px;
overflow: hidden;
border-color: #edf1f7;
border-radius: 18px;
box-shadow: 0 10px 26px rgba(31, 45, 61, 0.06);
&::after {
content: "";
position: absolute;
right: -36px;
top: -36px;
width: 96px;
height: 96px;
background: rgba(30, 111, 255, 0.08);
border-radius: 50%;
pointer-events: none;
}
&:hover {
box-shadow: 0 18px 38px rgba(30, 111, 255, 0.14);
}
.product-header {
position: relative;
z-index: 1;
align-items: flex-start;
gap: 12px;
.product-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 42px;
width: 42px;
height: 42px;
color: #1e6fff;
font-size: 22px;
background: #eef5ff;
border-radius: 14px;
}
.product-title {
flex: 1;
min-width: 0;
}
.product-name {
margin: 0 0 7px;
color: #1f2d3d;
line-height: 1.4;
}
.product-type {
display: inline-flex;
color: #7a8699;
font-size: 12px;
background: #f4f6fa;
padding: 4px 8px;
border-radius: 999px;
}
.discount-badge {
position: relative;
z-index: 1;
border-radius: 999px;
white-space: nowrap;
}
}
.product-desc {
position: relative;
z-index: 1;
min-height: 44px;
color: #667085;
line-height: 1.6;
line-clamp: 2;
-webkit-line-clamp: 2;
}
.product-footer {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
.product-status {
color: #12b76a;
font-size: 12px;
background: #ecfdf3;
padding: 5px 10px;
border-radius: 999px;
}
.detail-btn {
display: inline-flex;
align-items: center;
gap: 4px;
color: #ffffff;
background: #1E6FFF;
border: none;
padding: 8px 13px;
border-radius: 999px;
cursor: pointer;
transition: background 0.25s ease, transform 0.25s ease;
&:hover {
background: #0d5ae0;
transform: translateX(2px);
}
}
}
}
}
.empty-state {
padding: 76px 20px;
text-align: center;
background: #ffffff;
border: 1px dashed #d8e0ee;
border-radius: 18px;
.empty-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: 16px;
color: #98a2b3;
font-size: 32px;
background: #f4f6fa;
border-radius: 18px;
}
.empty-text {
margin: 0 0 8px;
color: #475467;
font-size: 15px;
font-weight: 600;
}
span {
color: #98a2b3;
font-size: 13px;
}
}
}
@media (max-width: 1080px) {
.page-hero {
flex-direction: column;
.hero-stats {
grid-template-columns: repeat(3, 1fr);
}
}
.page-toolbar {
align-items: flex-start;
flex-direction: column;
.product-search {
width: 100%;
}
}
}
}
</style>

View File

@ -0,0 +1,693 @@
<template>
<div class="api-key-page">
<div class="api-key-shell">
<div class="page-header">
<div class="title-block">
<div class="title-line">
<span class="key-icon">
<i class="el-icon-key"></i>
</span>
<h2>API Key 管理</h2>
</div>
<p>创建和管理访问令牌用于在外部系统中安全调用你的能力接口</p>
</div>
<div class="header-actions">
<el-button
size="small"
class="ghost-btn"
icon="el-icon-refresh"
:loading="tableLoading"
@click="fetchApiKeyList"
>
刷新
</el-button>
<el-button
size="small"
type="primary"
class="create-btn"
icon="el-icon-plus"
:loading="createLoading"
@click="handleCreateApiKey"
>
创建新令牌
</el-button>
</div>
</div>
<div class="safe-card">
<div class="safe-title">
<i class="el-icon-warning-outline"></i>
<h3>API 密钥安全指南</h3>
</div>
<div class="safe-list">
<div v-for="item in safeTips" :key="item" class="safe-item">
<span></span>
<p>{{ item }}</p>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-card" v-for="item in statCards" :key="item.label" :class="item.type">
<div class="stat-icon">
<i :class="item.icon"></i>
</div>
<div>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="table-card">
<div class="table-header">
<div>
<h3>令牌列表</h3>
<p>查看已创建的访问令牌后续可接入创建查看和禁用接口</p>
</div>
<span> {{ tokenList.length }} </span>
</div>
<div class="table-wrap">
<el-table
v-loading="tableLoading"
:data="tokenList"
class="api-key-table"
style="width: 100%"
>
<el-table-column prop="name" label="令牌名称" min-width="220">
<template slot-scope="scope">
<span class="token-name">{{ scope.row.name || '默认令牌' }}</span>
</template>
</el-table-column>
<el-table-column prop="apikey" label="API Key" min-width="260">
<template slot-scope="scope">
<span class="masked-key">{{ maskApiKey(scope.row.apikey || scope.row.id) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" class="view-btn" @click="copyApiKey(scope.row)">复制</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-dialog
title="添加令牌"
:visible.sync="createDialogVisible"
width="420px"
custom-class="create-token-dialog"
append-to-body
@close="resetCreateForm"
>
<el-form
ref="createFormRef"
class="create-token-form"
:model="createForm"
:rules="createRules"
label-width="90px"
@submit.native.prevent
>
<el-form-item label="令牌名称" prop="appname">
<el-input
v-model.trim="createForm.appname"
maxlength="64"
clearable
placeholder="请输入令牌名称"
/>
<p class="create-token-tip">用于标识当前令牌的业务应用建议填写便于识别的令牌名称</p>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button class="dialog-cancel-btn" @click="createDialogVisible = false">取消</el-button>
<el-button class="dialog-submit-btn" type="primary" :loading="createLoading" @click="submitCreateApiKey">确定</el-button>
</div>
</el-dialog>
</div>
</div>
</template>
<script>
import { reqApikeyList, reqCreateApikey } from '@/api/model/model'
export default {
name: 'TokenManagement',
data() {
return {
tableLoading: false,
createLoading: false,
createDialogVisible: false,
createForm: {
appname: ''
},
createRules: {
appname: [
{ required: true, message: '请输入令牌名称', trigger: ['blur', 'change'] }
]
},
safeTips: [
'请不要与他人共享您的 API Keys暴露 API Key 可能会破坏您的帐户安全和访问的服务。',
'避免将 API Key 暴露在浏览器或其他客户端代码中。',
'为了保护您的帐户安全,一旦 API Keys 被发现泄露,平台发现后可能会将其禁用。'
],
tokenList: []
}
},
computed: {
statCards() {
return [
{ label: '令牌总数', value: this.tokenList.length, icon: 'el-icon-key', type: 'primary' },
{ label: '启用中', value: this.enabledCount, icon: 'el-icon-success', type: 'success' }
]
},
enabledCount() {
return this.tokenList.filter(item => this.isEnabled(item)).length
}
},
created() {
this.fetchApiKeyList()
},
methods: {
getUserId() {
return sessionStorage.getItem('userId') || localStorage.getItem('userId') || ''
},
getUserParams() {
const userid = this.getUserId()
if (!userid || userid === 'null') {
this.$message.warning('请先登录后再管理 API Key')
return null
}
return { userid }
},
async fetchApiKeyList() {
const params = this.getUserParams()
if (!params) return
this.tableLoading = true
try {
const res = await reqApikeyList(params)
if (res && res.status === false) {
throw new Error(res.msg || '获取 API Key 列表失败')
}
this.tokenList = this.normalizeApiKeyList(res)
} catch (error) {
this.tokenList = []
this.$message.error(error && error.message ? error.message : '获取 API Key 列表失败')
} finally {
this.tableLoading = false
}
},
handleCreateApiKey() {
this.createDialogVisible = true
this.$nextTick(() => {
if (this.$refs.createFormRef) {
this.$refs.createFormRef.clearValidate()
}
})
},
async submitCreateApiKey() {
const isValid = await new Promise(resolve => {
if (!this.$refs.createFormRef) {
resolve(false)
return
}
this.$refs.createFormRef.validate(valid => resolve(valid))
})
if (!isValid) return
const appname = this.createForm.appname
const params = this.getUserParams()
if (!params) return
this.createLoading = true
try {
const res = await reqCreateApikey({ ...params, appname })
if (res && res.status === false) {
throw new Error(res.msg || '创建 API Key 失败')
}
this.$message.success('API Key 创建成功')
this.createDialogVisible = false
this.resetCreateForm()
await this.fetchApiKeyList()
} catch (error) {
this.$message.error(error && error.message ? error.message : '创建 API Key 失败')
} finally {
this.createLoading = false
}
},
resetCreateForm() {
if (this.$refs.createFormRef) {
this.$refs.createFormRef.resetFields()
} else {
this.createForm.appname = ''
}
},
normalizeApiKeyList(res) {
const data = res && res.data !== undefined ? res.data : res
const list = Array.isArray(data)
? data
: Array.isArray(data && data.apikeys)
? data.apikeys
: Array.isArray(data && data.list)
? data.list
: Array.isArray(data && data.apikey_list)
? data.apikey_list
: Array.isArray(data && data.data)
? data.data
: data && typeof data === 'object'
? [data]
: []
return list.map((item, index) => this.normalizeApiKeyItem(item, index))
},
normalizeApiKeyItem(item, index) {
if (typeof item === 'string') {
return {
id: index + 1,
name: `API Key ${index + 1}`,
apikey: item,
status: 1
}
}
return {
...item,
id: item.id || item.apikeyid || item.apikey_id || index + 1,
name: item.name || item.api_key_name || item.apikey_name || `API Key ${index + 1}`,
apikey: item.apikey || item.apikeyid || item.api_key || item.key || item.token || item.id || '-',
created_at: item.created_at || item.create_time || item.updated_at || '',
status: item.status !== undefined ? item.status : 1
}
},
isEnabled(row) {
return Number(row.status) !== 0 && row.status !== 'disabled'
},
getStatusText(row) {
return this.isEnabled(row) ? '启用' : '禁用'
},
getStatusType(row) {
return this.isEnabled(row) ? 'success' : 'info'
},
maskApiKey(value) {
const key = String(value || '').trim()
if (!key) return '-'
if (key.length <= 8) return key
const prefix = key.slice(0, 6)
const suffix = key.slice(-4)
return `${prefix}****${suffix}`
},
copyApiKey(row) {
const value = row && row.apikey
if (!value || value === '-') {
this.$message.warning('暂无可复制的 API Key')
return
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(value).then(() => {
this.$message.success('API Key 已复制')
}).catch(() => {
this.copyByInput(value)
})
return
}
this.copyByInput(value)
},
copyByInput(value) {
const input = document.createElement('input')
input.value = value
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
this.$message.success('API Key 已复制')
}
}
}
</script>
<style lang="less" scoped>
.api-key-page {
min-height: 100vh;
padding: 24px;
background: linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 44%, #ffffff 100%);
}
.api-key-shell {
position: relative;
min-height: calc(100vh - 48px);
padding: 24px;
color: #1f2d3d;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
overflow: hidden;
&::after {
content: "";
position: absolute;
top: -80px;
right: -80px;
width: 220px;
height: 220px;
background: radial-gradient(circle, rgba(64, 158, 255, 0.12) 0%, rgba(64, 158, 255, 0) 70%);
pointer-events: none;
}
}
.page-header {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
}
.title-line {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
h2 {
margin: 0;
color: #1f2d3d;
font-size: 24px;
font-weight: 700;
}
}
.key-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
color: #409eff;
font-size: 18px;
background: #eef5ff;
border-radius: 12px;
}
.title-block p {
margin: 0;
color: #909399;
font-size: 13px;
}
.header-actions {
display: flex;
gap: 12px;
}
.ghost-btn {
color: #606266;
background: #f5f7fa;
border-color: #e4e7ed;
border-radius: 10px;
&:hover,
&:focus {
color: #409eff;
background: #ecf5ff;
border-color: #b3d8ff;
}
}
.create-btn {
background: #409eff;
border-color: #409eff;
border-radius: 10px;
box-shadow: 0 8px 18px rgba(64, 158, 255, 0.24);
}
.safe-card {
position: relative;
z-index: 1;
padding: 22px 24px;
margin-bottom: 20px;
background: linear-gradient(135deg, #f8fbff 0%, #f2f7ff 100%);
border: 1px solid #edf1f7;
border-radius: 14px;
}
.safe-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
i {
color: #409eff;
font-size: 18px;
}
h3 {
margin: 0;
color: #1f2d3d;
font-size: 16px;
}
}
.safe-list {
display: grid;
gap: 8px;
}
.safe-item {
display: flex;
align-items: flex-start;
gap: 8px;
span {
width: 5px;
height: 5px;
margin-top: 8px;
background: #409eff;
border-radius: 50%;
}
p {
margin: 0;
color: #606266;
font-size: 13px;
line-height: 1.7;
}
}
.stat-row {
width: 100%;
// display: grid;
display: flex;
justify-content: space-between;
// grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.stat-card {
width: 48%;
display: flex;
align-items: center;
gap: 14px;
padding: 20px 22px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 14px;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: #409eff;
font-size: 20px;
background: #eef5ff;
border-radius: 14px;
}
&.success .stat-icon {
color: #67c23a;
background: #f0f9eb;
}
span {
display: block;
margin-bottom: 8px;
color: #909399;
font-size: 13px;
}
strong {
color: #303133;
font-size: 28px;
line-height: 1;
}
}
.table-card {
padding: 20px;
background: #ffffff;
border: 1px solid #edf1f7;
border-radius: 16px;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.06);
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
h3 {
margin: 0 0 6px;
color: #1f2d3d;
font-size: 18px;
}
p {
margin: 0;
color: #909399;
font-size: 13px;
}
span {
color: #909399;
font-size: 13px;
}
}
.table-wrap {
max-height: 360px;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f2f4;
}
&::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 999px;
}
}
.api-key-table {
color: #606266;
background: transparent;
/deep/ .el-table__header th {
color: #475467;
background: #f8fbff;
border-bottom: 1px solid #edf1f7;
}
/deep/ .el-table__row {
background: #ffffff;
}
/deep/ tr:hover > td {
background: #f8fbff !important;
}
/deep/ td {
color: #606266;
background: #ffffff;
border-bottom: 1px solid #edf1f7;
}
/deep/ .el-table__empty-block {
background: #ffffff;
}
/deep/ &::before {
display: none;
}
}
.token-name {
color: #1f2d3d;
font-weight: 600;
}
.masked-key,
.view-btn {
color: #409eff;
font-weight: 600;
}
/deep/ .create-token-dialog {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.18);
.el-dialog__header {
padding: 18px 22px 12px;
border-bottom: 1px solid #eef2f7;
}
.el-dialog__title {
color: #1f2d3d;
font-size: 18px;
font-weight: 700;
}
.el-dialog__body {
padding: 18px 22px 8px;
}
.el-dialog__footer {
padding: 14px 22px 18px;
}
}
.create-token-form {
/deep/ .el-form-item__label {
color: #4b5565;
font-weight: 600;
}
/deep/ .el-input__inner {
height: 38px;
border-radius: 10px;
border-color: #d8e3f0;
}
}
.create-token-tip {
margin: 8px 0 0;
color: #8a94a6;
font-size: 12px;
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.dialog-cancel-btn {
min-width: 74px;
border-radius: 8px;
}
.dialog-submit-btn {
min-width: 90px;
border-radius: 8px;
}
</style>