Files
Yi.Framework/Yi.Ai.Vue3/src/pages/modelLibrary/index.vue
2025-12-09 19:11:30 +08:00

805 lines
21 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 { onMounted, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { getApiTypeOptions, getModelLibraryList, getModelTypeOptions, getProviderList } from '@/api/model';
import type { ModelApiTypeOption, ModelLibraryDto, ModelTypeOption } from '@/api/model/types';
import { Search, CopyDocument } from '@element-plus/icons-vue';
const loading = ref(false);
const modelList = ref<ModelLibraryDto[]>([]);
const totalCount = ref(0);
const currentPage = ref(1);
const pageSize = ref(18);
// 筛选条件
const searchKey = ref('');
const selectedProvider = ref<string>('');
const selectedModelType = ref<number | undefined>(undefined);
const selectedApiType = ref<number | undefined>(undefined);
const isPremiumOnly = ref(false);
// 供应商列表
const providerList = ref<string[]>(['全部供应商']);
// 模型类型选项
const modelTypeOptions = ref<ModelTypeOption[]>([
{ label: '全部类型', value: undefined as any },
]);
// API类型选项
const apiTypeOptions = ref<ModelApiTypeOption[]>([
{ label: '全部API类型', value: undefined as any },
]);
async function fetchModelList() {
loading.value = true;
try {
const params = {
searchKey: searchKey.value || undefined,
providerName: selectedProvider.value && selectedProvider.value !== '全部供应商' ? selectedProvider.value : undefined,
modelType: selectedModelType.value,
modelApiType: selectedApiType.value,
isPremiumOnly: isPremiumOnly.value || undefined,
skipCount: (currentPage.value - 1) * pageSize.value,
maxResultCount: pageSize.value,
};
const response = await getModelLibraryList(params);
const data = response.data;
modelList.value = data.items;
totalCount.value = data.totalCount;
}
catch (error) {
console.error('获取模型列表失败:', error);
ElMessage.error('获取模型列表失败');
}
finally {
loading.value = false;
}
}
// 获取所有供应商列表(用于筛选栏)
async function fetchProviderList() {
try {
const response = await getProviderList();
providerList.value = ['全部供应商', ...response.data.sort()];
}
catch (error) {
console.error('获取供应商列表失败:', error);
}
}
// 获取模型类型选项
async function fetchModelTypeOptions() {
try {
const response = await getModelTypeOptions();
modelTypeOptions.value = [
{ label: '全部类型', value: undefined as any },
...response.data,
];
}
catch (error) {
console.error('获取模型类型选项失败:', error);
}
}
// 获取API类型选项
async function fetchApiTypeOptions() {
try {
const response = await getApiTypeOptions();
apiTypeOptions.value = [
{ label: '全部API类型', value: undefined as any },
...response.data,
];
}
catch (error) {
console.error('获取API类型选项失败:', error);
}
}
function copyModelId(modelId: string) {
navigator.clipboard.writeText(modelId).then(() => {
ElMessage.success('模型ID已复制');
});
}
function resetFilters() {
searchKey.value = '';
selectedProvider.value = '';
selectedModelType.value = undefined;
selectedApiType.value = undefined;
isPremiumOnly.value = false;
currentPage.value = 1;
}
function selectProvider(provider: string) {
selectedProvider.value = provider === selectedProvider.value ? '' : provider;
currentPage.value = 1;
}
function handlePageChange(page: number) {
currentPage.value = page;
fetchModelList();
}
function handleSizeChange(size: number) {
pageSize.value = size;
currentPage.value = 1;
fetchModelList();
}
// 监听筛选条件变化,自动刷新数据
// 搜索关键词使用防抖,其他条件立即触发
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(searchKey, () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
currentPage.value = 1;
fetchModelList();
}, 500);
});
watch([selectedProvider, selectedModelType, selectedApiType, isPremiumOnly], () => {
currentPage.value = 1;
fetchModelList();
}, { deep: true });
onMounted(() => {
fetchProviderList();
fetchModelTypeOptions();
fetchApiTypeOptions();
fetchModelList();
});
</script>
<template>
<div class="model-library-container">
<!-- 蓝色渐变顶部横幅 -->
<div class="banner-section">
<div class="banner-content">
<div class="banner-header">
<h1 class="banner-title">
{{ selectedProvider || '全部供应商' }}
<span class="model-count"> {{ totalCount }} 个模型</span>
</h1>
</div>
<p class="banner-subtitle">
查看所有可用的AI模型包括大语言模型的供应商输入各种AI模型的提示
</p>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-section">
<div class="search-wrapper">
<el-input
v-model="searchKey"
placeholder="搜索模型或供应商"
class="search-input"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #suffix>
<div class="search-shortcuts">
<span class="shortcut-key"></span>
<span class="shortcut-key">K</span>
</div>
</template>
</el-input>
<div class="filter-tags">
<el-button
v-for="provider in providerList"
:key="provider"
:type="selectedProvider === provider ? 'primary' : ''"
size="small"
plain
@click="selectProvider(provider)"
>
{{ provider }}
</el-button>
</div>
</div>
</div>
<div class="content-wrapper">
<!-- 左侧筛选栏 -->
<aside class="filter-sidebar">
<div class="filter-section">
<div class="filter-header">
<h3 class="filter-title">
筛选
</h3>
<el-button link type="primary" size="small" @click="resetFilters">
重置
</el-button>
</div>
<!-- 供应商 -->
<div class="filter-group">
<h4 class="filter-group-title">
供应商
</h4>
<div class="filter-options">
<div
v-for="provider in providerList"
:key="provider"
class="filter-option"
:class="{ active: selectedProvider === provider }"
@click="selectProvider(provider)"
>
<span class="option-icon">
<svg v-if="provider === 'Anthropic'" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="20" height="20" rx="4" />
</svg>
<svg v-else-if="provider === 'Google'" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<polygon points="12,2 15,8.5 22,9.5 17,14.5 18,21.5 12,18 6,21.5 7,14.5 2,9.5 9,8.5" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" />
</svg>
</span>
<span class="option-label">{{ provider }}</span>
</div>
</div>
</div>
<!-- 模型类型 -->
<div class="filter-group">
<h4 class="filter-group-title">
模型类型
</h4>
<div class="filter-options">
<div
v-for="option in modelTypeOptions"
:key="option.label"
class="filter-option"
:class="{ active: selectedModelType === option.value }"
@click="selectedModelType = option.value"
>
<span class="option-label">{{ option.label }}</span>
</div>
</div>
</div>
<!-- API类型 -->
<div class="filter-group">
<h4 class="filter-group-title">
API类型
</h4>
<div class="filter-options">
<div
v-for="option in apiTypeOptions"
:key="option.label"
class="filter-option"
:class="{ active: selectedApiType === option.value }"
@click="selectedApiType = option.value"
>
<span class="option-label">{{ option.label }}</span>
</div>
</div>
</div>
<!-- 尊享模型 -->
<div class="filter-group">
<h4 class="filter-group-title">
计费类型
</h4>
<div class="filter-options">
<div
class="filter-option"
:class="{ active: !isPremiumOnly }"
@click="isPremiumOnly = false"
>
<span class="option-label">全部模型</span>
</div>
<div
class="filter-option"
:class="{ active: isPremiumOnly }"
@click="isPremiumOnly = true"
>
<span class="option-label">仅尊享模型</span>
</div>
</div>
</div>
</div>
</aside>
<!-- 右侧模型列表 -->
<main class="model-list-section">
<div v-if="loading" class="loading-wrapper">
<el-skeleton :rows="6" animated />
</div>
<div v-else-if="modelList.length === 0" class="empty-wrapper">
<el-empty description="未找到符合条件的模型" />
</div>
<div v-else class="model-grid">
<div
v-for="model in modelList"
:key="model.modelId"
class="model-card"
>
<div class="model-card-header">
<div class="model-icon">
<img
v-if="model.iconUrl"
:src="model.iconUrl"
:alt="model.name"
class="icon-img"
>
<div v-else class="icon-placeholder">
{{ model.name.charAt(0).toUpperCase() }}
</div>
</div>
<div class="model-info">
<h3 class="model-name">
{{ model.name }}
</h3>
<div class="model-pricing">
<span class="price-label">输入 ¥{{ model.multiplierShow }}</span>
<span class="price-label">输出 ¥{{ (model.multiplierShow * 2).toFixed(4) }}</span>
</div>
</div>
<div class="model-actions">
<el-button
circle
size="small"
:icon="CopyDocument"
@click="copyModelId(model.modelId)"
/>
</div>
</div>
<p v-if="model.description" class="model-description">
{{ model.description }}
</p>
<p v-else class="model-description placeholder">
{{ model.modelId }}
</p>
<div class="model-tags">
<el-tag v-if="model.isPremium" size="small" type="warning">
尊享
</el-tag>
<el-tag size="small">
{{ model.modelTypeName }}
</el-tag>
<el-tag size="small">
{{ model.modelApiTypeName }}
</el-tag>
<el-tag v-if="model.providerName" size="small" type="info">
{{ model.providerName }}
</el-tag>
</div>
</div>
</div>
<!-- 分页组件 -->
<div v-if="totalCount > 0" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[12, 18, 24, 36, 48]"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</main>
</div>
</div>
</template>
<style scoped lang="scss">
.model-library-container {
min-height: 100vh;
background: #f8f9fa;
// 蓝色渐变横幅
.banner-section {
background: linear-gradient(135deg, #4e73df 0%, #6c5ce7 50%, #5f72bd 100%);
padding: 60px 24px 80px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.banner-content {
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
.banner-header {
margin-bottom: 12px;
.banner-title {
font-size: 36px;
font-weight: 700;
color: white;
margin: 0;
display: flex;
align-items: center;
gap: 16px;
.model-count {
font-size: 14px;
font-weight: 500;
background: rgba(255, 255, 255, 0.25);
padding: 4px 12px;
border-radius: 20px;
backdrop-filter: blur(10px);
}
}
}
.banner-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
max-width: 600px;
}
}
}
// 搜索栏
.search-section {
padding: 0 24px;
margin-top: -40px;
margin-bottom: 24px;
position: relative;
z-index: 10;
.search-wrapper {
max-width: 1400px;
margin: 0 auto;
.search-input {
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
:deep(.el-input__wrapper) {
padding: 12px 16px;
box-shadow: none;
}
:deep(.el-input__inner) {
font-size: 15px;
}
.search-shortcuts {
display: flex;
gap: 4px;
margin-right: 8px;
.shortcut-key {
display: inline-block;
padding: 2px 6px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
font-size: 12px;
color: #909399;
font-family: monospace;
}
}
}
.filter-tags {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
}
}
.content-wrapper {
display: flex;
gap: 24px;
max-width: 1400px;
margin: 0 auto;
padding: 0 24px 24px;
// 左侧筛选栏
.filter-sidebar {
width: 260px;
flex-shrink: 0;
.filter-section {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
position: sticky;
top: 24px;
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
.filter-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0;
}
}
.filter-group {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.filter-group-title {
font-size: 13px;
font-weight: 600;
color: #606266;
margin: 0 0 12px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-options {
display: flex;
flex-direction: column;
gap: 4px;
.filter-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
.option-icon {
display: flex;
align-items: center;
color: #909399;
svg {
width: 16px;
height: 16px;
}
}
.option-label {
font-size: 14px;
color: #606266;
flex: 1;
}
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
color: #409eff;
.option-icon {
color: #409eff;
}
.option-label {
color: #409eff;
font-weight: 500;
}
}
}
}
}
}
}
// 右侧模型列表
.model-list-section {
flex: 1;
min-width: 0;
.loading-wrapper,
.empty-wrapper {
background: white;
border-radius: 12px;
padding: 60px 40px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
text-align: center;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 20px;
.model-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.3s;
border: 1px solid #f0f0f0;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border-color: #409eff;
}
.model-card-header {
display: flex;
gap: 12px;
margin-bottom: 16px;
.model-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
.icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
border: 2px solid #f0f0f0;
}
.icon-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: bold;
}
}
.model-info {
flex: 1;
min-width: 0;
.model-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-pricing {
display: flex;
gap: 12px;
font-size: 13px;
.price-label {
color: #909399;
&:first-child {
color: #67c23a;
}
&:last-child {
color: #e6a23c;
}
}
}
}
.model-actions {
flex-shrink: 0;
}
}
.model-description {
font-size: 13px;
color: #606266;
line-height: 1.6;
margin: 0 0 16px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 42px;
&.placeholder {
color: #909399;
font-family: monospace;
font-size: 12px;
}
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
}
}
}
// 响应式设计
@media (max-width: 1200px) {
.model-library-container .content-wrapper .model-list-section .model-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
@media (max-width: 768px) {
.model-library-container {
.banner-section {
padding: 40px 16px 60px;
.banner-content .banner-header .banner-title {
font-size: 24px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
.search-section {
padding: 0 16px;
}
.content-wrapper {
flex-direction: column;
padding: 0 16px 16px;
.filter-sidebar {
width: 100%;
position: relative;
top: 0;
}
.model-list-section .model-grid {
grid-template-columns: 1fr;
}
}
}
}
</style>