feat: 实现通用组件
顶部栏、侧边栏、分页表格、Markdown 渲染、模型选择对话框、模型状态标签、页面卡片、占位视图等八个公共组件,及 Font Awesome 图标资源。
This commit is contained in:
264
frontend/src/components/AppHeader.vue
Normal file
264
frontend/src/components/AppHeader.vue
Normal 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>
|
||||
281
frontend/src/components/AppSidebar.vue
Normal file
281
frontend/src/components/AppSidebar.vue
Normal 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>
|
||||
380
frontend/src/components/DataTablePage.vue
Normal file
380
frontend/src/components/DataTablePage.vue
Normal 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>
|
||||
86
frontend/src/components/MarkdownView.vue
Normal file
86
frontend/src/components/MarkdownView.vue
Normal 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>
|
||||
506
frontend/src/components/ModelSelectDialog.vue
Normal file
506
frontend/src/components/ModelSelectDialog.vue
Normal 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>
|
||||
35
frontend/src/components/ModelStatusTag.vue
Normal file
35
frontend/src/components/ModelStatusTag.vue
Normal 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>
|
||||
66
frontend/src/components/PageCard.vue
Normal file
66
frontend/src/components/PageCard.vue
Normal 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>
|
||||
52
frontend/src/components/PlaceholderView.vue
Normal file
52
frontend/src/components/PlaceholderView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user