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

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>