507 lines
11 KiB
Vue
507 lines
11 KiB
Vue
<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>
|