Files
Yi.Framework/Yi.Ai.Vue3/src/pages/modelLibrary/index.vue
2025-12-11 21:35:32 +08:00

1106 lines
31 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 type { ModelApiTypeOption, ModelLibraryDto, ModelTypeOption } from '@/api/model/types';
import { Box, CopyDocument, HomeFilled, OfficeBuilding, Search } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { getApiTypeOptions, getModelLibraryList, getModelTypeOptions, getProviderList } from '@/api/model';
const router = useRouter();
const loading = ref(false);
const modelList = ref<ModelLibraryDto[]>([]);
const totalCount = ref(0);
const currentPage = ref(1);
const pageSize = ref(12);
// 筛选条件
const searchKey = ref('');
const selectedProviders = ref<string[]>([]);
const selectedModelTypes = ref<number[]>([]);
const selectedApiTypes = ref<number[]>([]);
const isPremiumOnly = ref(false);
// 供应商列表
const providerList = ref<string[]>([]);
// 模型类型选项
const modelTypeOptions = ref<ModelTypeOption[]>([]);
// API类型选项
const apiTypeOptions = ref<ModelApiTypeOption[]>([]);
async function fetchModelList() {
loading.value = true;
try {
const params = {
searchKey: searchKey.value || undefined,
providerNames: selectedProviders.value.length > 0 ? selectedProviders.value : undefined,
modelTypes: selectedModelTypes.value.length > 0 ? selectedModelTypes.value : undefined,
modelApiTypes: selectedApiTypes.value.length > 0 ? selectedApiTypes.value : undefined,
isPremiumOnly: isPremiumOnly.value || undefined,
skipCount: currentPage.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 = response.data;
}
catch (error) {
console.error('获取模型类型选项失败:', error);
}
}
// 获取API类型选项
async function fetchApiTypeOptions() {
try {
const response = await getApiTypeOptions();
apiTypeOptions.value = response.data;
}
catch (error) {
console.error('获取API类型选项失败:', error);
}
}
function copyModelId(modelId: string) {
navigator.clipboard.writeText(modelId).then(() => {
ElMessage.success('模型ID已复制');
});
}
function resetFilters() {
searchKey.value = '';
selectedProviders.value = [];
selectedModelTypes.value = [];
selectedApiTypes.value = [];
isPremiumOnly.value = false;
currentPage.value = 1;
}
function toggleProvider(provider: string | null) {
if (provider === null) {
// 点击"全部",清空所有选择
selectedProviders.value = [];
}
else {
const index = selectedProviders.value.indexOf(provider);
if (index > -1) {
selectedProviders.value.splice(index, 1);
}
else {
selectedProviders.value.push(provider);
}
}
currentPage.value = 1;
}
function toggleModelType(type: number | null) {
if (type === null) {
// 点击"全部",清空所有选择
selectedModelTypes.value = [];
}
else {
const index = selectedModelTypes.value.indexOf(type);
if (index > -1) {
selectedModelTypes.value.splice(index, 1);
}
else {
selectedModelTypes.value.push(type);
}
}
currentPage.value = 1;
}
function toggleApiType(type: number | null) {
if (type === null) {
// 点击"全部",清空所有选择
selectedApiTypes.value = [];
}
else {
const index = selectedApiTypes.value.indexOf(type);
if (index > -1) {
selectedApiTypes.value.splice(index, 1);
}
else {
selectedApiTypes.value.push(type);
}
}
currentPage.value = 1;
}
function togglePremiumOnly(isPremium: boolean | null) {
if (isPremium === null) {
// 点击"全部"设置为false
isPremiumOnly.value = false;
}
else {
isPremiumOnly.value = isPremium;
}
currentPage.value = 1;
}
function handlePageChange(page: number) {
currentPage.value = page;
fetchModelList();
}
function handleSizeChange(size: number) {
pageSize.value = size;
currentPage.value = 1;
fetchModelList();
}
function goToHome() {
router.push('/');
}
// 监听筛选条件变化,自动刷新数据
// 搜索关键词使用防抖,其他条件立即触发
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(searchKey, () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
currentPage.value = 1;
fetchModelList();
}, 500);
});
watch([selectedProviders, selectedModelTypes, selectedApiTypes, 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">
<div class="banner-left">
<div class="banner-text-section">
<h1 class="banner-title">
意心AI模型库
</h1>
<p class="banner-subtitle">
探索并接入全球顶尖AI模型覆盖文本图像嵌入等多个领域
</p>
</div>
<!-- 统计信息卡片 -->
<div class="stats-cards">
<div class="stat-card">
<div class="stat-icon">
<el-icon><Box /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">
{{ totalCount }}
</div>
<div class="stat-label">
可用模型
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<el-icon><OfficeBuilding /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">
{{ providerList.length > 1 ? providerList.length : 1 - 1 }}
</div>
<div class="stat-label">
支持供应商
</div>
</div>
</div>
</div>
</div>
<el-button
:icon="HomeFilled"
class="home-btn"
round
@click="goToHome"
>
返回首页
</el-button>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="main-content">
<div class="content-wrapper">
<!-- 左侧筛选栏 -->
<aside class="filter-sidebar">
<div class="filter-section">
<div class="filter-header">
<h3 class="filter-title">
<el-icon><i-ep-filter /></el-icon>
筛选条件
</h3>
<el-button link type="primary" size="small" @click="resetFilters">
重置
</el-button>
</div>
<!-- 搜索框 -->
<div class="filter-group">
<el-input
v-model="searchKey"
placeholder="搜索模型..."
clearable
size="default"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 供应商 -->
<div class="filter-group">
<h4 class="filter-group-title">
供应商
</h4>
<div class="filter-tags">
<el-check-tag
:checked="selectedProviders.length === 0"
class="filter-tag"
@change="toggleProvider(null)"
>
全部供应商
</el-check-tag>
<el-check-tag
v-for="provider in providerList"
:key="provider"
:checked="selectedProviders.includes(provider)"
class="filter-tag"
@change="toggleProvider(provider)"
>
{{ provider }}
</el-check-tag>
</div>
</div>
<!-- 模型类型 -->
<div class="filter-group">
<h4 class="filter-group-title">
模型类型
</h4>
<div class="filter-tags">
<el-check-tag
:checked="selectedModelTypes.length === 0"
class="filter-tag"
@change="toggleModelType(null)"
>
全部类型
</el-check-tag>
<el-check-tag
v-for="option in modelTypeOptions"
:key="option.value"
:checked="selectedModelTypes.includes(option.value)"
class="filter-tag"
@change="toggleModelType(option.value)"
>
{{ option.label }}
</el-check-tag>
</div>
</div>
<!-- API类型 -->
<div class="filter-group">
<h4 class="filter-group-title">
API类型
</h4>
<div class="filter-tags">
<el-check-tag
:checked="selectedApiTypes.length === 0"
class="filter-tag"
@change="toggleApiType(null)"
>
全部API类型
</el-check-tag>
<el-check-tag
v-for="option in apiTypeOptions"
:key="option.value"
:checked="selectedApiTypes.includes(option.value)"
class="filter-tag"
@change="toggleApiType(option.value)"
>
{{ option.label }}
</el-check-tag>
</div>
</div>
<!-- 计费类型 -->
<div class="filter-group">
<h4 class="filter-group-title">
计费类型
</h4>
<div class="filter-tags">
<el-check-tag
:checked="!isPremiumOnly"
class="filter-tag"
@change="togglePremiumOnly(null)"
>
全部模型
</el-check-tag>
<el-check-tag
:checked="isPremiumOnly"
class="filter-tag"
@change="togglePremiumOnly(true)"
>
仅尊享模型
</el-check-tag>
</div>
</div>
</div>
</aside>
<!-- 右侧模型列表 -->
<main class="model-list-section">
<!-- 加载状态 -->
<div v-if="loading" class="loading-wrapper">
<el-skeleton :rows="8" animated />
</div>
<!-- 空状态 -->
<div v-else-if="modelList.length === 0" class="empty-wrapper">
<el-empty description="未找到符合条件的模型">
<el-button type="primary" @click="resetFilters">
清除筛选条件
</el-button>
</el-empty>
</div>
<!-- 模型网格 -->
<el-row v-else :gutter="20" class="model-grid">
<el-col
v-for="model in modelList"
:key="model.modelId"
:xs="24"
:sm="8"
:lg="6"
>
<div
class="model-card"
:class="{ 'premium-card': model.isPremium }"
>
<el-button
circle
size="small"
:icon="CopyDocument"
class="copy-btn-corner"
title="复制模型ID"
@click="copyModelId(model.modelId)"
/>
<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-provider">
{{ model.providerName }}
</div>
</div>
</div>
<div class="model-id">
<span class="model-id-label">ModelId:</span>
<span class="model-id-value">{{ model.modelId }}</span>
</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-footer">
<div class="model-tags">
<el-tag size="small">
{{ model.modelTypeName }}
</el-tag>
<el-tag v-for="item in model.modelApiTypes" :key="item" size="small">
{{ item.modelApiTypeName }}
</el-tag>
</div>
<div class="model-pricing">
<span class="pricing-label">计费倍率</span>
<span class="pricing-value">{{ model.multiplierShow }}×</span>
</div>
</div>
</div>
</el-col>
</el-row>
<!-- 分页 -->
<div v-if="totalCount > 0 && !loading" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[12, 24, 48, 96]"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
prev-text="上一页"
next-text="下一页"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</main>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.model-library-container {
min-height: 100vh;
background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 100%);
// 顶部横幅
.banner-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 24px;
position: relative;
overflow: hidden;
// 装饰性背景元素
&::before,
&::after {
content: '';
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
&::before {
width: 400px;
height: 400px;
top: -200px;
right: -100px;
}
&::after {
width: 300px;
height: 300px;
bottom: -150px;
left: -100px;
}
.banner-content {
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
.banner-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 32px;
.banner-left {
flex: 1;
display: flex;
align-items: center;
gap: 32px;
.banner-text-section {
flex-shrink: 0;
.banner-title {
font-size: 36px;
font-weight: 800;
color: white;
margin: 0 0 12px 0;
letter-spacing: -0.5px;
}
.banner-subtitle {
font-size: 15px;
color: rgba(255, 255, 255, 0.95);
margin: 0;
line-height: 1.6;
max-width: 500px;
}
}
// 统计卡片
.stats-cards {
display: flex;
gap: 16px;
flex-shrink: 0;
.stat-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s;
min-width: 160px;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.25);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.stat-info {
.stat-value {
font-size: 28px;
font-weight: 700;
color: white;
line-height: 1;
margin-bottom: 6px;
}
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
}
}
}
}
}
.home-btn {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
backdrop-filter: blur(10px);
padding: 10px 24px;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.35);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
}
}
}
}
// 主内容区
.main-content {
padding: 32px 16px;
.content-wrapper {
max-width: 100%;
margin: 0 auto;
padding: 0 8px;
display: flex;
gap: 24px;
// 左侧筛选栏
.filter-sidebar {
width: 260px;
flex-shrink: 0;
.filter-section {
background: white;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
position: sticky;
top: 24px;
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 2px solid #f0f0f0;
.filter-title {
font-size: 16px;
font-weight: 700;
color: #303133;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
}
.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-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
.filter-tag {
cursor: pointer;
transition: all 0.3s;
user-select: none;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
}
}
.filter-options {
display: flex;
flex-direction: column;
gap: 4px;
.filter-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
.option-label {
font-size: 14px;
color: #606266;
flex: 1;
}
.option-check {
color: #667eea;
font-size: 16px;
display: flex;
align-items: center;
}
&:hover {
background: #f5f7fa;
}
&.active {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border: 1px solid rgba(102, 126, 234, 0.3);
.option-label {
color: #667eea;
font-weight: 600;
}
}
}
}
}
}
}
// 右侧模型列表
.model-list-section {
flex: 1;
min-width: 0;
.loading-wrapper,
.empty-wrapper {
background: white;
border-radius: 16px;
padding: 80px 40px;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
// 模型网格
.model-grid {
margin-bottom: 32px;
.model-card {
margin-bottom: 20px;
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #f0f0f0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
opacity: 0;
transition: opacity 0.3s;
}
// 尊享模型流光溢彩效果
&.premium-card {
border: 2px solid transparent;
background-image:
linear-gradient(white, white),
linear-gradient(45deg, #ff0000, #ff8000, #ffff00, #00ff00, #00ffff, #0000ff, #8000ff, #ff0080);
background-origin: border-box;
background-clip: padding-box, border-box;
animation: gradientFlow 3s ease infinite;
background-size: 400% 400%;
&::before {
background: linear-gradient(90deg, #ff0000, #ff8000, #ffff00, #00ff00, #00ffff, #0000ff, #8000ff, #ff0080);
background-size: 400% 400%;
animation: gradientFlow 3s ease infinite;
opacity: 1;
height: 3px;
}
&:hover {
box-shadow: 0 12px 32px rgba(255, 0, 128, 0.25);
}
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.15);
border-color: #667eea;
&::before {
opacity: 1;
}
.copy-btn-corner {
opacity: 1;
}
}
// 右上角复制按钮
.copy-btn-corner {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
opacity: 1;
transition: all 0.3s;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #e0e0e0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
background: white;
border-color: #667eea;
color: #667eea;
transform: scale(1.1);
}
}
.model-card-header {
display: flex;
gap: 16px;
margin-bottom: 16px;
.model-icon {
width: 56px;
height: 56px;
flex-shrink: 0;
.icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
border: 2px solid #f0f0f0;
}
.icon-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: 700;
}
}
.model-info {
flex: 1;
min-width: 0;
.model-name {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0 0 6px 0;
//overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-provider {
font-size: 13px;
color: #909399;
font-weight: 500;
}
}
}
// ModelId 显示区域
.model-id {
margin-bottom: 12px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 8px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
display: flex;
align-items: center;
gap: 8px;
.model-id-label {
font-size: 11px;
color: #909399;
font-weight: 600;
//text-transform: uppercase;
letter-spacing: 0.5px;
}
.model-id-value {
font-size: 12px;
color: #606266;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.model-description {
font-size: 14px;
color: #606266;
line-height: 1.7;
margin: 0 0 20px 0;
-webkit-box-orient: vertical;
min-height: 48px; /* 保持2行的高度 */
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
/* 添加过渡效果 */
transition: all 0.3s ease;
max-height: 3.4em; /* 2行高度 (1.7 * 2 = 3.4em) */
&.placeholder {
color: #c0c4cc;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
}
/* 悬停时展开 */
&:hover {
-webkit-line-clamp: unset; /* 取消行数限制 */
line-clamp: unset;
max-height: none; /* 取消最大高度限制 */
overflow: visible; /* 显示全部内容 */
/* 可选:添加背景或边框突出显示 */
background-color: #f9f9f9;
//padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
//margin-bottom: 20px; /* 保持原有间距 */
/* 如果是绝对定位的父容器可以增加z-index */
z-index: 10;
position: relative;
}
}
.model-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
}
.model-pricing {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 6px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
border-radius: 8px;
white-space: nowrap;
.pricing-label {
font-size: 12px;
color: #909399;
}
.pricing-value {
font-size: 16px;
font-weight: 700;
color: #667eea;
}
}
}
}
}
// 分页
.pagination-wrapper {
display: flex;
justify-content: center;
padding: 0px 0;
:deep(.el-pagination) {
gap: 8px;
.btn-prev,
.btn-next {
border-radius: 10px;
padding: 0 16px;
font-weight: 500;
transition: all 0.3s;
&:hover:not(:disabled) {
color: #667eea;
transform: translateY(-2px);
}
}
.el-pager li {
border-radius: 10px;
min-width: 40px;
height: 40px;
line-height: 40px;
transition: all 0.3s;
&:hover {
color: #667eea;
transform: translateY(-2px);
}
&.is-active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
}
}
}
}
}
}
}
// 流光溢彩动画
@keyframes gradientFlow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
</style>