updata
This commit is contained in:
parent
7afd47655d
commit
f6743d34d1
9
f/web-kboss/src/api/gotoYuanJing.js
Normal file
9
f/web-kboss/src/api/gotoYuanJing.js
Normal 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
|
||||
})
|
||||
}
|
||||
74
f/web-kboss/src/api/model/model.js
Normal file
74
f/web-kboss/src/api/model/model.js
Normal 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
|
||||
})
|
||||
}
|
||||
@ -101,3 +101,12 @@ export const todoCount = () => {
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 获取token市集
|
||||
export const reqTokenMarket = () => {
|
||||
return request({
|
||||
url: '/cntoai/model_management_customer_search.dspy',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
257
f/web-kboss/src/components/modelManagement/ModelDetailDialog.vue
Normal file
257
f/web-kboss/src/components/modelManagement/ModelDetailDialog.vue
Normal 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>
|
||||
134
f/web-kboss/src/components/modelManagement/ModelFilter.vue
Normal file
134
f/web-kboss/src/components/modelManagement/ModelFilter.vue
Normal 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>
|
||||
126
f/web-kboss/src/components/modelManagement/ModelStats.vue
Normal file
126
f/web-kboss/src/components/modelManagement/ModelStats.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
|
||||
422
f/web-kboss/src/views/modelManagement/AddModelDialog.vue
Normal file
422
f/web-kboss/src/views/modelManagement/AddModelDialog.vue
Normal 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>
|
||||
243
f/web-kboss/src/views/modelManagement/ApiDocument.vue
Normal file
243
f/web-kboss/src/views/modelManagement/ApiDocument.vue
Normal 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>
|
||||
546
f/web-kboss/src/views/modelManagement/Experience.vue
Normal file
546
f/web-kboss/src/views/modelManagement/Experience.vue
Normal 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>
|
||||
349
f/web-kboss/src/views/modelManagement/ModelDetail.vue
Normal file
349
f/web-kboss/src/views/modelManagement/ModelDetail.vue
Normal 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>
|
||||
580
f/web-kboss/src/views/modelManagement/modelManagement.vue
Normal file
580
f/web-kboss/src/views/modelManagement/modelManagement.vue
Normal 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>
|
||||
197
f/web-kboss/src/views/operation/operationReport/index.vue
Normal file
197
f/web-kboss/src/views/operation/operationReport/index.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
693
f/web-kboss/src/views/tokenManagement/index.vue
Normal file
693
f/web-kboss/src/views/tokenManagement/index.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user