595 lines
20 KiB
Vue
595 lines
20 KiB
Vue
<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> |