feat: 完成模型库

This commit is contained in:
chenchun
2025-12-09 19:11:30 +08:00
parent 8dcbfcad33
commit 54a1d2a66f
21 changed files with 1374 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dir:*)"
]
}
}

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// API类型选项
/// </summary>
public class ModelApiTypeOption
{
/// <summary>
/// 显示名称
/// </summary>
public string Label { get; set; }
/// <summary>
/// 枚举值
/// </summary>
public int Value { get; set; }
}

View File

@@ -0,0 +1,64 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// 模型库展示数据
/// </summary>
public class ModelLibraryDto
{
/// <summary>
/// 模型ID
/// </summary>
public string ModelId { get; set; }
/// <summary>
/// 模型名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 模型描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 模型类型
/// </summary>
public ModelTypeEnum ModelType { get; set; }
/// <summary>
/// 模型类型名称
/// </summary>
public string ModelTypeName { get; set; }
/// <summary>
/// 模型API类型
/// </summary>
public ModelApiTypeEnum ModelApiType { get; set; }
/// <summary>
/// 模型API类型名称
/// </summary>
public string ModelApiTypeName { get; set; }
/// <summary>
/// 模型显示倍率
/// </summary>
public decimal MultiplierShow { get; set; }
/// <summary>
/// 供应商分组名称
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// 模型图标URL
/// </summary>
public string? IconUrl { get; set; }
/// <summary>
/// 是否为尊享模型PremiumChat类型
/// </summary>
public bool IsPremium { get; set; }
}

View File

@@ -0,0 +1,35 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// 获取模型库列表查询参数
/// </summary>
public class ModelLibraryGetListInput : PagedAllResultRequestDto
{
/// <summary>
/// 搜索关键词搜索模型名称、模型ID
/// </summary>
public string? SearchKey { get; set; }
/// <summary>
/// 供应商名称筛选
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// 模型类型筛选
/// </summary>
public ModelTypeEnum? ModelType { get; set; }
/// <summary>
/// API类型筛选
/// </summary>
public ModelApiTypeEnum? ModelApiType { get; set; }
/// <summary>
/// 是否只显示尊享模型
/// </summary>
public bool? IsPremiumOnly { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
/// <summary>
/// 模型类型选项
/// </summary>
public class ModelTypeOption
{
/// <summary>
/// 显示名称
/// </summary>
public string Label { get; set; }
/// <summary>
/// 枚举值
/// </summary>
public int Value { get; set; }
}

View File

@@ -0,0 +1,35 @@
using Volo.Abp.Application.Dtos;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
/// <summary>
/// 模型服务接口
/// </summary>
public interface IModelService
{
/// <summary>
/// 获取模型库列表(公开接口,无需登录)
/// </summary>
/// <param name="input">查询参数</param>
/// <returns>分页模型列表</returns>
Task<PagedResultDto<ModelLibraryDto>> GetListAsync(ModelLibraryGetListInput input);
/// <summary>
/// 获取供应商列表(公开接口,无需登录)
/// </summary>
/// <returns>供应商列表</returns>
Task<List<string>> GetProviderListAsync();
/// <summary>
/// 获取模型类型选项列表(公开接口,无需登录)
/// </summary>
/// <returns>模型类型选项</returns>
Task<List<ModelTypeOption>> GetModelTypeOptionsAsync();
/// <summary>
/// 获取API类型选项列表公开接口无需登录
/// </summary>
/// <returns>API类型选项</returns>
Task<List<ModelApiTypeOption>> GetApiTypeOptionsAsync();
}

View File

@@ -0,0 +1,116 @@
using Mapster;
using SqlSugar;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.AiHub.Domain.Shared.Extensions;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services.Chat;
/// <summary>
/// 模型服务
/// </summary>
public class ModelService : ApplicationService, IModelService
{
private readonly ISqlSugarRepository<AiModelEntity, Guid> _modelRepository;
public ModelService(ISqlSugarRepository<AiModelEntity, Guid> modelRepository)
{
_modelRepository = modelRepository;
}
/// <summary>
/// 获取模型库列表(公开接口,无需登录)
/// </summary>
public async Task<PagedResultDto<ModelLibraryDto>> GetListAsync(ModelLibraryGetListInput input)
{
RefAsync<int> total = 0;
// 查询所有未删除的模型使用WhereIF动态添加筛选条件
var models = await _modelRepository._DbQueryable
.Where(x => !x.IsDeleted)
.WhereIF(!string.IsNullOrWhiteSpace(input.SearchKey), x =>
x.Name.Contains(input.SearchKey) || x.ModelId.Contains(input.SearchKey))
.WhereIF(!string.IsNullOrWhiteSpace(input.ProviderName), x =>
x.ProviderName == input.ProviderName)
.WhereIF(input.ModelType.HasValue, x =>
x.ModelType == input.ModelType.Value)
.WhereIF(input.ModelApiType.HasValue, x =>
x.ModelApiType == input.ModelApiType.Value)
.WhereIF(input.IsPremiumOnly == true, x =>
x.ModelType == ModelTypeEnum.PremiumChat)
.OrderBy(x => x.OrderNum)
.OrderBy(x => x.Name)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
// 转换为DTO
var result = models.Select(model => new ModelLibraryDto
{
ModelId = model.ModelId,
Name = model.Name,
Description = model.Description,
ModelType = model.ModelType,
ModelTypeName = model.ModelType.GetDescription(),
ModelApiType = model.ModelApiType,
ModelApiTypeName = model.ModelApiType.GetDescription(),
MultiplierShow = model.MultiplierShow,
ProviderName = model.ProviderName,
IconUrl = model.IconUrl,
IsPremium = model.ModelType == ModelTypeEnum.PremiumChat
}).ToList();
return new PagedResultDto<ModelLibraryDto>(total, result);
}
/// <summary>
/// 获取供应商列表(公开接口,无需登录)
/// </summary>
public async Task<List<string>> GetProviderListAsync()
{
var providers = await _modelRepository._DbQueryable
.Where(x => !x.IsDeleted)
.Where(x => !string.IsNullOrEmpty(x.ProviderName))
.GroupBy(x => x.ProviderName)
.OrderBy(x => x.ProviderName)
.Select(x => x.ProviderName)
.ToListAsync();
return providers;
}
/// <summary>
/// 获取模型类型选项列表(公开接口,无需登录)
/// </summary>
public Task<List<ModelTypeOption>> GetModelTypeOptionsAsync()
{
var options = Enum.GetValues<ModelTypeEnum>()
.Select(e => new ModelTypeOption
{
Label = e.GetDescription(),
Value = (int)e
})
.ToList();
return Task.FromResult(options);
}
/// <summary>
/// 获取API类型选项列表公开接口无需登录
/// </summary>
public Task<List<ModelApiTypeOption>> GetApiTypeOptionsAsync()
{
var options = Enum.GetValues<ModelApiTypeEnum>()
.Select(e => new ModelApiTypeOption
{
Label = e.GetDescription(),
Value = (int)e
})
.ToList();
return Task.FromResult(options);
}
}

View File

@@ -1,7 +1,12 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
using System.ComponentModel;
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
public enum ModelApiTypeEnum
{
[Description("OpenAI")]
OpenAi,
[Description("Claude")]
Claude
}

View File

@@ -1,9 +1,18 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
using System.ComponentModel;
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
public enum ModelTypeEnum
{
[Description("聊天")]
Chat = 0,
[Description("图片")]
Image = 1,
[Description("嵌入")]
Embedding = 2,
[Description("尊享包")]
PremiumChat = 3
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel;
using System.Reflection;
namespace Yi.Framework.AiHub.Domain.Shared.Extensions;
/// <summary>
/// 枚举扩展方法
/// </summary>
public static class EnumExtensions
{
/// <summary>
/// 获取枚举的Description特性值
/// </summary>
/// <param name="value">枚举值</param>
/// <returns>Description特性值如果没有则返回枚举名称</returns>
public static string GetDescription(this Enum value)
{
var field = value.GetType().GetField(value.ToString());
if (field == null)
{
return value.ToString();
}
var attribute = field.GetCustomAttribute<DescriptionAttribute>();
return attribute?.Description ?? value.ToString();
}
}

View File

@@ -65,4 +65,19 @@ public class AiModelEntity : Entity<Guid>, IOrderNum, ISoftDelete
/// 模型倍率
/// </summary>
public decimal Multiplier { get; set; } = 1;
/// <summary>
/// 模型显示倍率
/// </summary>
public decimal MultiplierShow { get; set; } = 1;
/// <summary>
/// 供应商分组名称(如OpenAI、Anthropic、Google等)
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// 模型图标URL
/// </summary>
public string? IconUrl { get; set; }
}

View File

@@ -32,6 +32,7 @@ using Yi.Framework.AiHub.Application;
using Yi.Framework.AiHub.Application.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AspNetCore;
using Yi.Framework.AspNetCore.Authentication.OAuth;
@@ -357,7 +358,7 @@ namespace Yi.Abp.Web
var app = context.GetApplicationBuilder();
app.UseRouting();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<MessageAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AiModelEntity>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<TokenAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();

View File

@@ -5,6 +5,8 @@
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ElMessage": true,
"ElMessageBox": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,

View File

@@ -1,10 +1,56 @@
import type { GetSessionListVO } from './types';
import type { GetSessionListVO, ModelApiTypeOption, ModelLibraryDto, ModelLibraryGetListInput, ModelTypeOption, PagedResultDto } from './types';
import { del, get, post, put } from '@/utils/request';
// 获取当前用户的模型列表
export function getModelList() {
return get<GetSessionListVO[]>('/ai-chat/model').json();
}
// 获取模型库列表(公开接口,无需登录)
export function getModelLibraryList(params?: ModelLibraryGetListInput) {
const queryParams = new URLSearchParams();
if (params?.searchKey) {
queryParams.append('SearchKey', params.searchKey);
}
if (params?.providerName) {
queryParams.append('ProviderName', params.providerName);
}
if (params?.modelType !== undefined) {
queryParams.append('ModelType', params.modelType.toString());
}
if (params?.modelApiType !== undefined) {
queryParams.append('ModelApiType', params.modelApiType.toString());
}
if (params?.isPremiumOnly !== undefined) {
queryParams.append('IsPremiumOnly', params.isPremiumOnly.toString());
}
if (params?.skipCount !== undefined) {
queryParams.append('SkipCount', params.skipCount.toString());
}
if (params?.maxResultCount !== undefined) {
queryParams.append('MaxResultCount', params.maxResultCount.toString());
}
const queryString = queryParams.toString();
const url = queryString ? `/model?${queryString}` : '/model';
return get<PagedResultDto<ModelLibraryDto>>(url).json();
}
// 获取供应商列表(公开接口,无需登录)
export function getProviderList() {
return get<string[]>('/model/provider-list').json();
}
// 获取模型类型选项列表(公开接口,无需登录)
export function getModelTypeOptions() {
return get<ModelTypeOption[]>('/model/model-type-options').json();
}
// 获取API类型选项列表公开接口无需登录
export function getApiTypeOptions() {
return get<ModelApiTypeOption[]>('/model/api-type-options').json();
}
// 申请ApiKey
export function applyApiKey() {
return post<any>('/token').json();

View File

@@ -13,3 +13,61 @@ export interface GetSessionListVO {
remark?: string;
modelId?: string;
}
// 模型类型枚举
export enum ModelTypeEnum {
Chat = 0,
Image = 1,
Embedding = 2,
PremiumChat = 3,
}
// 模型API类型枚举
export enum ModelApiTypeEnum {
OpenAi = 0,
Claude = 1,
}
// 模型库展示数据
export interface ModelLibraryDto {
modelId: string;
name: string;
description?: string;
modelType: ModelTypeEnum;
modelTypeName: string;
modelApiType: ModelApiTypeEnum;
modelApiTypeName: string;
multiplierShow: number;
providerName?: string;
iconUrl?: string;
isPremium: boolean;
}
// 获取模型库列表查询参数
export interface ModelLibraryGetListInput {
searchKey?: string;
providerName?: string;
modelType?: ModelTypeEnum;
modelApiType?: ModelApiTypeEnum;
isPremiumOnly?: boolean;
skipCount?: number;
maxResultCount?: number;
}
// 分页结果
export interface PagedResultDto<T> {
items: T[];
totalCount: number;
}
// 模型类型选项
export interface ModelTypeOption {
label: string;
value: number;
}
// API类型选项
export interface ModelApiTypeOption {
label: string;
value: number;
}

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goToModelLibrary() {
router.push('/model-library');
}
</script>
<template>
<div class="model-library-btn-container" @click="goToModelLibrary">
<el-tooltip content="查看模型库" placement="bottom">
<el-button link class="header-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<span class="btn-text">模型库</span>
</el-button>
</el-tooltip>
</div>
</template>
<style scoped lang="scss">
.model-library-btn-container {
display: flex;
align-items: center;
cursor: pointer;
.header-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
color: #606266;
transition: all 0.3s;
&:hover {
color: #409eff;
background-color: rgba(64, 158, 255, 0.1);
}
.btn-text {
font-size: 14px;
font-weight: 500;
}
svg {
flex-shrink: 0;
}
}
}
</style>

View File

@@ -10,6 +10,7 @@ import Avatar from './components/Avatar.vue';
import Collapse from './components/Collapse.vue';
import CreateChat from './components/CreateChat.vue';
import LoginBtn from './components/LoginBtn.vue';
import ModelLibraryBtn from './components/ModelLibraryBtn.vue';
import TitleEditing from './components/TitleEditing.vue';
const userStore = useUserStore();
@@ -72,6 +73,7 @@ onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtr
<!-- 右边 -->
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
<AnnouncementBtn />
<ModelLibraryBtn />
<AiTutorialBtn />
<Avatar v-show="userStore.userInfo" />
<LoginBtn v-show="!userStore.userInfo" />

View File

@@ -0,0 +1,804 @@
<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>

View File

@@ -37,12 +37,23 @@ export const layoutRouter: RouteRecordRaw[] = [
component: () => import('@/pages/products/index.vue'),
meta: {
title: '产品页面',
keepAlive: true, // 如果需要缓存
isDefaultChat: false, // 根据实际情况设置
layout: 'blankPage', // 如果需要自定义布局
keepAlive: true,
isDefaultChat: false,
layout: 'blankPage',
},
},
{
path: '/model-library',
name: 'modelLibrary',
component: () => import('@/pages/modelLibrary/index.vue'),
meta: {
title: '模型库',
keepAlive: true,
isDefaultChat: false,
layout: 'blankPage',
},
},
{
path: '/pay-result',
name: 'payResult',

View File

@@ -13,6 +13,43 @@ declare module 'vue' {
CardFlipActivity: typeof import('./../src/components/userPersonalCenter/components/CardFlipActivity.vue')['default']
DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default']
DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']
IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default']
Indexl: typeof import('./../src/components/SupportModelProducts/indexl.vue')['default']
@@ -39,4 +76,7 @@ declare module 'vue' {
VerificationCode: typeof import('./../src/components/LoginDialog/components/FormLogin/VerificationCode.vue')['default']
WelecomeText: typeof import('./../src/components/WelecomeText/index.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -6,7 +6,6 @@ interface ImportMetaEnv {
readonly VITE_WEB_ENV: string;
readonly VITE_WEB_BASE_API: string;
readonly VITE_API_URL: string;
readonly VITE_BUILD_COMPRESS: string;
readonly VITE_SSO_SEVER_URL: string;
readonly VITE_APP_VERSION: string;
}