kboss/f/web-kboss/src/views/modelManagement/modelManagement.vue
2026-07-17 15:28:41 +08:00

595 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="model-page">
<!-- 统计区 -->
<model-stats :stats="modelStats" />
<!-- 筛选区 -->
<model-filter
:search-form="searchForm"
:model-type-options="modelTypeOptions"
:provider-options="providerOptions"
@search="handleSearch"
@reset="resetSearch"
/>
<!-- 列表区 -->
<el-card class="model-table-card" shadow="never">
<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
const modelName = this.normalizeSearchText(this.searchForm.name)
const modelType = this.normalizeSearchText(this.searchForm.type)
const provider = this.normalizeSearchText(this.searchForm.provider)
return this.modelList.filter(model => {
if (Number(model.listing_status) !== selectedStatus) return false
const displayName = this.normalizeSearchText(this.getModelDisplayName(model))
const id = this.normalizeSearchText(this.getModelId(model))
const type = this.normalizeSearchText(this.getModelType(model))
const modelProvider = this.normalizeSearchText(this.getProvider(model))
const nameMatched = !modelName || displayName.includes(modelName) || id.includes(modelName)
const typeMatched = !modelType || type === modelType || type.includes(modelType)
const providerMatched = !provider || modelProvider === provider || modelProvider.includes(provider)
return nameMatched && typeMatched && providerMatched
})
},
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
},
normalizeSearchText(value) {
return String(value || '').trim().toLowerCase()
},
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 {
border-radius: 12px;
border
/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>