Files
YG_FT/frontend/src/components/ModelSelectDialog.vue
caoxiaozhu c1893cf82c feat: 实现通用组件
顶部栏、侧边栏、分页表格、Markdown 渲染、模型选择对话框、模型状态标签、页面卡片、占位视图等八个公共组件,及 Font Awesome 图标资源。
2026-07-10 16:45:25 +08:00

507 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>