feat: 实现通用组件

顶部栏、侧边栏、分页表格、Markdown 渲染、模型选择对话框、模型状态标签、页面卡片、占位视图等八个公共组件,及 Font Awesome 图标资源。
This commit is contained in:
caoxiaozhu
2026-07-10 16:45:25 +08:00
parent ca9e05aa91
commit c1893cf82c
12 changed files with 1674 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '@/stores/system'
import { useRoute, useRouter } from 'vue-router'
const systemStore = useSystemStore()
const { metrics } = storeToRefs(systemStore)
const route = useRoute()
const router = useRouter()
const showBackButton = computed(() => {
return route.path.split('/').filter(Boolean).length > 1
})
function usageClass(val?: number) {
return val != null && val > 80 ? 'is-danger' : ''
}
const currentTime = ref('')
const currentDate = ref('')
let timer: ReturnType<typeof setInterval> | null = null
function updateTime() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
currentDate.value = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
}
const serverIp = ref(window.location.hostname)
onMounted(() => {
updateTime()
timer = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<header class="app-header">
<div class="header-left">
<el-button
v-if="showBackButton"
class="back-btn"
link
@click="router.back()"
>
<div class="back-icon-wrap">
<i class="fa fa-arrow-left" />
</div>
<span class="back-text">返回上一页</span>
</el-button>
</div>
<div class="header-right">
<!-- 系统监控指标 -->
<div class="metrics-dashboard" @click="router.push('/hardware')">
<el-tooltip content="CPU 使用率" placement="bottom">
<div class="metric-badge">
<i class="fa fa-microchip icon cpu" />
<span class="value" :class="usageClass(metrics.cpu_percent)">
{{ metrics.cpu_percent ?? '--' }}<small>%</small>
</span>
</div>
</el-tooltip>
<el-tooltip content="内存 使用率" placement="bottom">
<div class="metric-badge">
<i class="fa fa-database icon mem" />
<span class="value" :class="usageClass(metrics.memory_percent)">
{{ metrics.memory_percent ?? '--' }}<small>%</small>
</span>
</div>
</el-tooltip>
<el-tooltip content="磁盘 使用率" placement="bottom">
<div class="metric-badge">
<i class="fa fa-hdd-o icon disk" />
<span class="value" :class="usageClass(metrics.disk_percent)">
{{ metrics.disk_percent ?? '--' }}<small>%</small>
</span>
</div>
</el-tooltip>
</div>
<div class="divider"></div>
<!-- 时间日期与服务器IP -->
<div class="system-info">
<div class="info-item">
<i class="fa fa-server" />
<span>{{ serverIp }}</span>
</div>
<div class="info-item">
<i class="fa fa-calendar" />
<span>{{ currentDate }}</span>
</div>
<div class="info-item time-item">
<i class="fa fa-clock-o" />
<span>{{ currentTime }}</span>
</div>
</div>
</div>
</header>
</template>
<style scoped lang="scss">
.app-header {
height: var(--header-height);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.02);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 50;
}
.header-left {
.back-btn {
display: flex;
align-items: center;
color: #64748b;
font-weight: 500;
transition: all 0.2s;
.back-icon-wrap {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
border-radius: 8px;
margin-right: 8px;
transition: all 0.2s;
}
&:hover {
color: var(--primary-color);
.back-icon-wrap {
background: rgba(79, 70, 229, 0.1);
color: var(--primary-color);
}
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.metrics-dashboard {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 4px 6px;
border-radius: 12px;
transition: background 0.2s;
&:hover {
background: #f8fafc;
}
.metric-badge {
display: flex;
align-items: center;
background: #f1f5f9;
padding: 4px 10px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #475569;
border: 1px solid transparent;
transition: all 0.2s;
&:hover {
background: #fff;
border-color: #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.icon {
margin-right: 6px;
font-size: 14px;
&.cpu { color: var(--primary-color); }
&.mem { color: var(--success-color); }
&.disk { color: var(--warning-color); }
}
.value {
small {
font-size: 10px;
color: #94a3b8;
margin-left: 2px;
}
&.is-danger {
color: var(--danger-color);
animation: pulse 2s infinite;
}
}
}
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.divider {
width: 1px;
height: 24px;
background-color: #e2e8f0;
}
.system-info {
display: flex;
align-items: center;
gap: 16px;
color: #64748b;
font-size: 13px;
font-weight: 500;
background: #f8fafc;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #f1f5f9;
.info-item {
display: flex;
align-items: center;
gap: 6px;
i {
color: #94a3b8;
}
}
.time-item {
color: #475569;
font-weight: 600;
min-width: 80px;
font-variant-numeric: tabular-nums;
i {
color: var(--primary-color);
}
}
}
</style>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
/** 当前激活菜单 key取路由路径第一段如 /fine-tune/create → fine-tune */
const activeMenu = computed(() => {
const seg = route.path.split('/')[1] || 'fine-tune'
// 训练日志归到模型调优
if (seg === 'training-log') return 'fine-tune'
// 维度管理归到模型评测
if (route.path.includes('model-eval/dimension')) return 'model-eval'
// 对比对话归到模型推理
if (route.path.startsWith('/model-compare/chat')) return 'model-inference'
// 合并权重归到模型管理
if (route.path === '/model-manage/merge') return 'model-manage'
// 模型管理编辑
if (route.path.match(/^\/model-manage\/\d+\/edit/)) return 'model-manage'
// 数据集编辑/预览
if (route.path.match(/^\/dataset\/(\d+)/)) return 'dataset'
// 工具编辑
if (route.path.match(/^\/tools\/[^/]+\/edit/)) return 'tools'
return seg
})
const menuGroups = [
{
// 服务看板:独立入口,不显示分类小标题
showTitle: false,
items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard' }],
},
{
title: '模型服务',
items: [
{ key: 'fine-tune', label: '模型微调', icon: 'fa-cogs', to: '/fine-tune' },
{ key: 'model-eval', label: '模型评测', icon: 'fa-line-chart', to: '/model-eval' },
{ key: 'model-inference', label: '模型推理', icon: 'fa-server', to: '/model-inference' },
{ key: 'model-manage', label: '模型管理', icon: 'fa-cube', to: '/model-manage' },
],
},
{
title: '数据治理',
items: [
{ key: 'data-process', label: '数据处理', icon: 'fa-filter', to: '/data-process' },
{ key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset' },
],
},
{
title: '其他工具',
items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert' }],
},
{
title: '系统设置',
items: [
{ key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware' },
{ key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs' },
],
},
]
function handleSelect(key: string) {
const all = menuGroups.flatMap((g) => g.items)
const target = all.find((i) => i.key === key)
if (target) router.push(target.to)
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<aside class="app-sidebar">
<!-- 平台 LOGO -->
<div class="sidebar-logo">
<img src="/logo.png" alt="Logo" class="sidebar-logo-img" />
<span class="logo-text">远光软件微调平台</span>
</div>
<!-- 导航 -->
<el-scrollbar class="sidebar-nav" wrap-class="nav-scrollbar-wrap">
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
background-color="transparent"
text-color="var(--sidebar-text)"
active-text-color="var(--sidebar-active-text)"
@select="handleSelect"
>
<template v-for="group in menuGroups" :key="group.title || group.items[0].key">
<div v-if="group.title" class="sidebar-section-title">{{ group.title }}</div>
<el-menu-item
v-for="item in group.items"
:key="item.key"
:index="item.key"
>
<i class="fa" :class="item.icon" />
<span>{{ item.label }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
<!-- 底部信息 -->
<div class="sidebar-footer">
<div class="footer-user" v-if="auth.username">
<el-avatar :size="36" src="https://picsum.photos/id/1005/36/36" class="user-avatar" />
<div class="user-info">
<span class="user-name">{{ auth.username }}</span>
<span class="user-role">Administrator</span>
</div>
<el-tooltip content="退出登录" placement="top">
<div class="logout-btn" @click="handleLogout">
<i class="fa fa-sign-out" />
</div>
</el-tooltip>
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
.app-sidebar {
width: 240px;
flex-shrink: 0;
height: 100vh;
background-color: var(--sidebar-bg);
display: flex;
flex-direction: column;
color: var(--sidebar-text);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.02);
z-index: 100;
border-right: 1px solid #e2e8f0;
}
.sidebar-logo {
display: flex;
align-items: center;
padding: 24px 20px;
margin-bottom: 8px;
.sidebar-logo-img {
width: 36px;
height: 36px;
object-fit: contain;
margin-right: 12px;
}
.logo-text {
color: #334155;
font-weight: 600;
font-size: 16px;
letter-spacing: 0.5px;
white-space: nowrap;
}
}
.sidebar-nav {
flex: 1;
min-height: 0;
padding: 0 12px;
}
:deep(.nav-scrollbar-wrap) {
overflow-x: hidden;
}
.sidebar-menu {
border-right: none;
background: transparent;
:deep(.el-menu-item) {
height: 46px;
line-height: 46px;
margin-bottom: 4px;
border-radius: 8px;
padding: 0 16px !important;
transition: all 0.3s ease;
.fa {
width: 20px;
text-align: center;
margin-right: 10px;
font-size: 16px;
transition: transform 0.2s;
}
&:hover {
background-color: rgba(0, 0, 0, 0.03) !important;
color: var(--sidebar-active-text) !important;
.fa {
transform: scale(1.1);
}
}
&.is-active {
background-color: var(--sidebar-active-bg) !important;
color: var(--sidebar-active-text) !important;
font-weight: 600;
.fa {
color: inherit;
}
}
}
}
.sidebar-section-title {
padding: 16px 16px 8px;
font-size: 11px;
color: #64748b;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.sidebar-footer {
padding: 20px;
background: transparent;
border-top: 1px solid #e2e8f0;
.footer-user {
display: flex;
align-items: center;
background: #ffffff;
padding: 10px;
border-radius: 12px;
border: 1px solid #e2e8f0;
.user-avatar {
border: 2px solid #e2e8f0;
}
.user-info {
flex: 1;
margin-left: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
.user-name {
color: #334155;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.user-role {
color: #64748b;
font-size: 11px;
margin-top: 2px;
}
}
.logout-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
}
}
}
}
</style>

View File

@@ -0,0 +1,380 @@
<script setup lang="ts" generic="T extends Record<string, any>">
import { ref, computed, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import type { TableInstance } from 'element-plus'
import { useRouter } from 'vue-router'
/**
* 通用列表页组件
* 封装原 renderTablePage 的能力:标题、搜索、创建按钮、多选、批量删除、操作列
*/
const props = withDefaults(
defineProps<{
title: string
data: T[]
loading?: boolean
/** 是否显示搜索框 */
searchable?: boolean
/** 搜索字段(默认取数据第一列) */
searchFields?: string[]
/** 是否支持多选 */
multiSelect?: boolean
/** 创建按钮文本,不传则不显示 */
createText?: string
/** 创建按钮跳转路径 */
createTo?: string
/** 删除函数(用于批量删除),返回 boolean 表示成功 */
deleteFn?: (row: any) => Promise<any>
/** 行 key 字段名 */
rowKey?: string
/** 空数据提示 */
emptyText?: string
/** 默认每页条数 */
pageSize?: number
/** 可选每页条数 */
pageSizes?: number[]
}>(),
{
loading: false,
searchable: false,
multiSelect: false,
rowKey: 'id',
emptyText: '暂无数据',
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
},
)
const emit = defineEmits<{
create: []
refresh: []
}>()
const router = useRouter()
const tableRef = ref<TableInstance>()
const selectedIds = ref<Set<string | number>>(new Set())
const keyword = ref('')
const currentPage = ref(1)
const currentPageSize = ref(props.pageSize)
/** 行唯一标识取值 */
function rowId(row: T): string | number {
return row[props.rowKey] ?? row['name'] ?? row['id']
}
/** 过滤后的数据 */
const filteredData = computed(() => {
if (!keyword.value.trim() || !props.searchFields?.length) return props.data
const kw = keyword.value.toLowerCase().trim()
return props.data.filter((row) =>
props.searchFields!.some((f) => String(row[f] ?? '').toLowerCase().includes(kw)),
)
})
/** 当前页数据 */
const pagedData = computed(() => {
const start = (currentPage.value - 1) * currentPageSize.value
return filteredData.value.slice(start, start + currentPageSize.value)
})
/** 处理多选变化 */
function handleSelectionChange(rows: T[]) {
selectedIds.value = new Set(rows.map(rowId))
}
/** 单个删除(子组件或操作列调用) */
async function handleDelete(row: any, confirmText = '确定要删除这条记录吗?') {
if (!props.deleteFn) return
try {
await ElMessageBox.confirm(confirmText, '确认删除', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
await props.deleteFn(row)
emit('refresh')
} catch {
// 用户取消或删除失败
}
}
/** 批量删除 */
async function handleBatchDelete() {
if (selectedIds.value.size === 0 || !props.deleteFn) return
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedIds.value.size} 条记录吗?`,
'批量删除',
{ type: 'warning' },
)
const rows = props.data.filter((row) => selectedIds.value.has(rowId(row)))
let ok = 0
let fail = 0
for (const row of rows) {
try {
await props.deleteFn(row)
ok++
} catch {
fail++
}
}
selectedIds.value.clear()
emit('refresh')
if (fail === 0) {
ElMessageBox.alert(`成功删除 ${ok} 条记录`, '成功', { type: 'success' })
} else {
ElMessageBox.alert(`成功 ${ok} 条,失败 ${fail}`, '部分失败', { type: 'warning' })
}
} catch {
// 取消
}
}
function clearSelection() {
selectedIds.value.clear()
tableRef.value?.clearSelection()
}
function handleCreate() {
if (props.createTo) {
router.push(props.createTo)
} else {
emit('create')
}
}
// 数据变化时清空选中
watch(
() => props.data,
() => {
selectedIds.value.clear()
currentPage.value = 1
tableRef.value?.clearSelection()
},
)
watch(
() => keyword.value,
() => {
currentPage.value = 1
},
)
defineExpose({ handleDelete, clearSelection })
</script>
<template>
<div class="data-table-page">
<el-card shadow="never" class="table-card">
<!-- 工具栏 -->
<div class="table-toolbar">
<slot name="title">
<h2 class="table-title">{{ title }}</h2>
</slot>
<div class="toolbar-actions">
<el-input
v-if="searchable"
v-model="keyword"
:placeholder="`搜索${title}...`"
clearable
class="search-input"
>
<template #prefix><i class="fa fa-search" /></template>
</el-input>
<el-button v-if="createText" type="primary" @click="handleCreate">
<i class="fa fa-plus" style="margin-right: 4px" />
{{ createText }}
</el-button>
<slot name="toolbar-extra" />
</div>
</div>
<!-- 批量操作栏 -->
<transition name="el-zoom-in-top">
<div v-if="multiSelect && selectedIds.size > 0" class="batch-bar">
<span class="batch-info">
已选择 <strong>{{ selectedIds.size }}</strong>
<el-button link type="primary" @click="clearSelection">取消选择</el-button>
</span>
<el-button type="danger" size="small" @click="handleBatchDelete">
<i class="fa fa-trash" style="margin-right: 4px" />
批量删除 ({{ selectedIds.size }})
</el-button>
</div>
</transition>
<!-- 表格 -->
<div class="table-body">
<el-table
ref="tableRef"
:data="pagedData"
v-loading="loading"
:row-key="rowKey as any"
height="100%"
style="width: 100%"
:empty-text="emptyText"
@selection-change="handleSelectionChange"
>
<el-table-column
v-if="multiSelect"
type="selection"
width="50"
align="center"
reserve-selection
fixed="left"
/>
<slot name="columns" />
<el-table-column
v-if="$slots.actions"
label="操作"
align="center"
width="220"
fixed="right"
>
<template #default="{ row }">
<slot name="actions" :row="row" :handleDelete="handleDelete" />
</template>
</el-table-column>
</el-table>
</div>
<div class="table-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
background
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="pageSizes"
:total="filteredData.length"
/>
</div>
</el-card>
</div>
</template>
<style scoped lang="scss">
.data-table-page {
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
.table-card {
margin-bottom: 0;
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 12px;
flex-wrap: wrap;
gap: 12px;
}
.table-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin: 0;
letter-spacing: -0.3px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.search-input {
width: 280px;
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #e2e8f0 inset;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px var(--primary-color) inset;
}
}
}
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(79, 70, 229, 0.05);
border: 1px solid rgba(79, 70, 229, 0.1);
border-radius: 8px;
padding: 10px 16px;
margin: 0 24px 16px;
.batch-info {
font-size: 13px;
color: var(--primary-color);
strong {
font-weight: 600;
margin: 0 4px;
}
}
}
.table-body {
flex: 1;
min-height: 0;
}
.table-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 24px;
border-top: 1px solid #f1f5f9;
:deep(.el-pagination) {
margin-top: 0;
}
}
:deep(.el-card__body) {
display: flex;
flex-direction: column;
padding: 12px 0 0;
flex: 1;
min-height: 0;
}
:deep(.el-table__inner-wrapper::before) {
display: none;
}
/* 表格单元格统一样式(各列表页共用,与模型调优保持一致) */
:deep(.el-table td.el-table__cell) {
padding: 18px 0 !important;
}
:deep(.el-table__cell .cell) {
line-height: 22px;
white-space: nowrap;
}
/* 操作列按钮组统一样式(各列表页共用) */
:deep(.el-table) .action-buttons {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
gap: 8px;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const props = defineProps<{
content: string
}>()
const html = computed(() => {
if (!props.content) return ''
try {
const raw = marked.parse(props.content, { async: false }) as string
return DOMPurify.sanitize(raw)
} catch {
return props.content
}
})
</script>
<template>
<div class="markdown-view" v-html="html" />
</template>
<style lang="scss">
.markdown-view {
line-height: 1.7;
word-break: break-word;
p {
margin: 0 0 8px;
}
h1,
h2,
h3,
h4 {
margin: 16px 0 8px;
font-weight: 600;
}
ul,
ol {
padding-left: 24px;
margin: 0 0 8px;
}
code {
background: rgba(0, 0, 0, 0.06);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.9em;
}
pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 0 0 8px;
code {
background: none;
padding: 0;
color: inherit;
}
}
table {
border-collapse: collapse;
margin: 0 0 8px;
th,
td {
border: 1px solid #dcdfe6;
padding: 6px 12px;
}
}
blockquote {
border-left: 4px solid #dcdfe6;
padding-left: 12px;
color: #909399;
margin: 0 0 8px;
}
a {
color: #1890ff;
}
}
</style>

View File

@@ -0,0 +1,506 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ModelItem } from '@/types'
const props = defineProps<{
modelValue: boolean
models: ModelItem[]
/** 当前已选模型 id打开弹窗时用于初始化草稿与 Tab */
currentModelId: string | number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', modelId: string | number): void
}>()
const activeModelTab = ref<'official' | 'mine'>('official')
const activeModelSeries = ref('')
const draftModelId = ref<string | number>('')
const modelSearchKeyword = ref('')
/** 根据模型名提取系列名(去掉参数量与日期后缀) */
function getModelSeries(name: string): string {
return name
.replace(/-\d+(?:\.\d+)?B.*$/i, '')
.replace(/-\d+(?:\.\d+)?b.*$/i, '')
.replace(/-\d{4}.*$/, '')
}
const dialogModels = computed(() => {
const keyword = modelSearchKeyword.value.trim().toLowerCase()
const tabModels = props.models.filter((model) => {
if (activeModelTab.value === 'official') return model.model_source !== 'api'
return model.model_source === 'api'
})
if (!keyword) return tabModels
return tabModels.filter((model) => {
return [model.name, model.description, model.path, model.online_model_name]
.filter(Boolean)
.some((text) => String(text).toLowerCase().includes(keyword))
})
})
const modelSeriesGroups = computed(() => {
const groupMap = new Map<string, ModelItem[]>()
dialogModels.value.forEach((model) => {
const series = getModelSeries(model.name)
const list = groupMap.get(series) || []
list.push(model)
groupMap.set(series, list)
})
return Array.from(groupMap.entries()).map(([name, list]) => ({
name,
count: list.length,
models: list,
}))
})
const currentSeriesModels = computed(() => {
return modelSeriesGroups.value.find((group) => group.name === activeModelSeries.value)?.models || []
})
const draftModel = computed(() => props.models.find((model) => model.id === draftModelId.value))
function syncActiveModelSeries() {
const firstSeries = modelSeriesGroups.value[0]?.name || ''
if (!activeModelSeries.value || !modelSeriesGroups.value.some((group) => group.name === activeModelSeries.value)) {
activeModelSeries.value = firstSeries
}
}
// 弹窗打开时初始化草稿沿用当前选中、Tab 按来源切换、系列定位到草稿所在组
watch(
() => props.modelValue,
(visible) => {
if (visible) {
const current = props.models.find((model) => model.id === props.currentModelId)
activeModelTab.value = current?.model_source === 'api' ? 'mine' : 'official'
draftModelId.value = props.currentModelId
syncActiveModelSeries()
}
},
)
watch([activeModelTab, modelSearchKeyword], () => {
syncActiveModelSeries()
})
function chooseModelSeries(series: string) {
activeModelSeries.value = series
}
function chooseDraftModel(model: ModelItem) {
draftModelId.value = model.id
}
function handleCancel() {
emit('update:modelValue', false)
}
function handleConfirm() {
if (draftModelId.value !== '' && draftModelId.value !== undefined && draftModelId.value !== null) {
emit('confirm', draftModelId.value)
}
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="model-select-dialog"
width="860px"
top="10vh"
:show-close="false"
append-to-body
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="model-dialog-header">
<div class="model-dialog-title">
<span>选择模型</span>
<em>仅可选 1 个模型</em>
</div>
<button class="model-dialog-close" type="button" @click="handleCancel">
<i class="fa fa-close" />
</button>
</div>
</template>
<div class="model-dialog-body">
<div class="model-dialog-toolbar">
<el-tabs v-model="activeModelTab" class="model-tabs">
<el-tab-pane label="官方模型" name="official" />
<el-tab-pane label="我的模型" name="mine" />
</el-tabs>
<el-input
v-model="modelSearchKeyword"
class="model-search"
placeholder="从全部模型中搜索"
clearable
>
<template #suffix>
<i class="fa fa-search" />
</template>
</el-input>
</div>
<div class="model-dialog-content">
<aside class="model-series-list">
<div class="column-title">模型系列</div>
<button
v-for="series in modelSeriesGroups"
:key="series.name"
class="model-series-item"
:class="{ active: activeModelSeries === series.name }"
type="button"
@click="chooseModelSeries(series.name)"
>
<i class="fa fa-star-o" />
<span>{{ series.name }}</span>
<em>{{ series.count }}</em>
</button>
<el-empty v-if="!modelSeriesGroups.length" description="暂无模型" :image-size="80" />
</aside>
<section class="model-version-list">
<div class="column-title">快照版本</div>
<button
v-for="model in currentSeriesModels"
:key="model.id"
class="model-version-item"
:class="{ active: draftModelId === model.id }"
type="button"
@click="chooseDraftModel(model)"
>
<div class="model-version-main">
<strong>{{ model.name }}</strong>
<span>{{ model.description || model.path || model.online_model_name || '暂无模型描述' }}</span>
</div>
<span class="model-radio-dot" />
</button>
<el-empty v-if="!currentSeriesModels.length" description="暂无快照版本" :image-size="80" />
</section>
</div>
</div>
<template #footer>
<div class="model-dialog-footer">
<div class="selected-model-summary">
<span>已选 {{ draftModel ? 1 : 0 }}/1</span>
<div v-if="draftModel" class="selected-model-pill">
<i class="fa fa-star-o" />
{{ draftModel.name }}
</div>
</div>
<div class="model-dialog-actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
:deep(.model-select-dialog) {
max-width: calc(100vw - 48px);
border-radius: 6px;
box-shadow: 0 18px 46px rgba(15, 23, 42, 0.18);
.el-dialog__header {
padding: 0;
margin: 0;
}
.el-dialog__body {
padding: 0;
}
.el-dialog__footer {
padding: 0;
}
}
.model-dialog-header {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eef0f5;
}
.model-dialog-title {
display: flex;
align-items: baseline;
gap: 12px;
span {
font-size: 16px;
font-weight: 700;
color: #1f2937;
}
em {
font-style: normal;
font-size: 13px;
color: #94a3b8;
}
}
.model-dialog-close {
width: 30px;
height: 30px;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font-size: 16px;
border-radius: 6px;
&:hover {
background: #f5f6fb;
}
}
.model-dialog-body {
height: 452px;
}
.model-dialog-toolbar {
height: 76px;
padding: 14px 20px 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
border-bottom: 1px solid #eef0f5;
}
.model-tabs {
min-width: 210px;
:deep(.el-tabs__item) {
height: 42px;
padding: 0 18px 0 0;
font-size: 14px;
font-weight: 600;
color: #475569;
}
:deep(.el-tabs__active-bar) {
height: 2px;
background: #2563eb;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
}
.model-search {
width: 280px;
:deep(.el-input__wrapper) {
border-radius: 6px;
min-height: 36px;
}
}
.model-dialog-content {
display: grid;
grid-template-columns: 280px 1fr;
height: 376px;
min-height: 0;
}
.model-series-list,
.model-version-list {
padding: 16px 20px;
overflow: auto;
}
.model-series-list {
border-right: 1px solid #eef0f5;
}
.column-title {
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 12px;
}
.model-series-item,
.model-version-item {
width: 100%;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: #1f2937;
&:hover {
border-color: #94a3b8;
background: #f8fafc;
}
&.active {
border-color: #2563eb;
background: #eff6ff;
box-shadow: inset 0 0 0 1px #2563eb;
}
}
.model-series-item {
height: 44px;
padding: 0 12px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
margin-bottom: 8px;
text-align: left;
i {
color: #2563eb;
}
span {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
em {
font-style: normal;
color: #777b92;
}
}
.model-version-item {
min-height: 64px;
padding: 12px 14px;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 14px;
margin-bottom: 8px;
text-align: left;
}
.model-version-main {
display: grid;
gap: 6px;
min-width: 0;
strong {
color: #1f2937;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
color: #64748b;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.model-radio-dot {
width: 16px;
height: 16px;
border: 1px solid #cbd5e1;
border-radius: 50%;
position: relative;
}
.model-version-item.active .model-radio-dot {
border-color: #2563eb;
&::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: #2563eb;
}
}
.model-dialog-footer {
height: 60px;
padding: 0 20px;
border-top: 1px solid #eef0f5;
display: flex;
align-items: center;
justify-content: space-between;
}
.selected-model-summary {
display: flex;
align-items: center;
gap: 10px;
color: #475569;
font-size: 13px;
}
.selected-model-pill {
max-width: 300px;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid #dbeafe;
border-radius: 4px;
background: #eff6ff;
color: #1f2937;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
i {
color: #2563eb;
}
}
.model-dialog-actions {
display: flex;
gap: 10px;
}
@media (max-width: 900px) {
.model-dialog-toolbar,
.model-dialog-footer {
height: auto;
align-items: flex-start;
flex-direction: column;
padding: 18px;
}
.model-search {
width: 100%;
}
.model-dialog-content {
grid-template-columns: 1fr;
}
.model-series-list {
border-right: 0;
border-bottom: 1px solid #eef0f5;
}
}
</style>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
status?: string
}>()
/** 将任务/模型状态映射为 Element Plus tag 类型 */
const tagInfo = computed(() => {
const s = props.status || ''
switch (s) {
case 'running':
case 'ready':
case 'loaded':
return { text: s === 'ready' || s === 'loaded' ? '已就绪' : '运行中', type: 'success' as const }
case 'pending':
return { text: '等待中', type: 'info' as const }
case 'failed':
return { text: '失败', type: 'danger' as const }
case 'starting':
case 'loading':
return { text: '加载中', type: 'warning' as const }
case 'completed':
return { text: '已完成', type: 'success' as const }
case 'stopped':
return { text: '已停止', type: 'info' as const }
default:
return { text: s || '未知', type: 'info' as const }
}
})
</script>
<template>
<el-tag :type="tagInfo.type" size="small" effect="light">{{ tagInfo.text }}</el-tag>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
/**
* 通用页面卡片容器
* 用于表单页、详情页等需要统一卡片样式的场景
*/
withDefaults(
defineProps<{
title?: string
subtitle?: string
bordered?: boolean
}>(),
{
bordered: false,
},
)
</script>
<template>
<el-card shadow="never" class="page-card">
<div v-if="title || $slots.header" class="page-card-header">
<slot name="header">
<div>
<h2 class="page-card-title">{{ title }}</h2>
<p v-if="subtitle" class="page-card-subtitle">{{ subtitle }}</p>
</div>
</slot>
<div v-if="$slots.extra" class="page-card-extra">
<slot name="extra" />
</div>
</div>
<div class="page-card-body">
<slot />
</div>
</el-card>
</template>
<style scoped lang="scss">
.page-card {
border-radius: 8px;
margin-bottom: 24px;
.page-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-card-title {
font-size: 16px;
font-weight: 500;
color: #303133;
margin: 0;
}
.page-card-subtitle {
font-size: 13px;
color: #909399;
margin: 4px 0 0;
}
:deep(.el-card__body) {
padding: 24px;
}
}
</style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* 占位页面
* 用于尚未实现的菜单项,保持统一的卡片样式与「敬请期待」提示
*/
import PageCard from '@/components/PageCard.vue'
defineProps<{
title?: string
desc?: string
}>()
</script>
<template>
<PageCard :title="title">
<div class="placeholder">
<i class="fa fa-wrench placeholder-icon" />
<p class="placeholder-title">{{ title }} 功能开发中</p>
<p class="placeholder-desc">{{ desc || '敬请期待,我们正在努力构建该功能。' }}</p>
</div>
</PageCard>
</template>
<style scoped lang="scss">
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
.placeholder-icon {
font-size: 56px;
color: #c0c4cc;
margin-bottom: 20px;
}
.placeholder-title {
font-size: 18px;
font-weight: 500;
color: #303133;
margin: 0 0 8px;
}
.placeholder-desc {
font-size: 14px;
color: #909399;
margin: 0;
}
}
</style>