Files
Yi.Admin/Yi.Ai.Vue3/src/components/ModelSelect/index.vue
2026-02-01 00:52:10 +08:00

573 lines
20 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 { GetSessionListVO } from '@/api/model/types';
import { Check, Lock, Right } from '@element-plus/icons-vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useResponsive } from '@/hooks/useResponsive';
import { useModelStore } from '@/stores/modules/model';
import { showProductPackage } from '@/utils/product-package.ts';
import { isUserVip } from '@/utils/user';
import { modelList as localModelList } from './modelData';
import { useRouter } from 'vue-router';
const modelStore = useModelStore();
const router = useRouter();
const { isMobile } = useResponsive();
const dialogVisible = ref(false);
const activeTab = ref('provider'); // 'provider' | 'api'
const scrollbarRef = ref();
const activeProviderGroup = ref('');
const activeApiGroup = ref('');
const isScrolling = ref(false);
const filterPremiumOnly = ref(false); // 是否只显示尊享模型
// 检查模型是否可用
function isModelAvailable(item: GetSessionListVO) {
return isUserVip() || item.isFree;
}
// 滚动到指定分组
function scrollToGroup(type: 'provider' | 'api', key: string) {
isScrolling.value = true;
const id = type === 'provider' ? `group-provider-${key}` : `group-api-${key}`;
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (type === 'provider') {
activeProviderGroup.value = key;
}
else {
activeApiGroup.value = key;
}
// 延迟重置滚动状态,防止触发 scroll 事件
setTimeout(() => {
isScrolling.value = false;
}, 1000);
}
}
// 监听滚动事件,更新侧边栏选中状态
function handleScroll({ scrollTop }: { scrollTop: number }) {
if (isScrolling.value)
return;
const type = activeTab.value;
const groups = type === 'provider' ? groupedByProvider.value : groupedByApiType.value;
const keys = Object.keys(groups);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const id = type === 'provider' ? `group-provider-${key}` : `group-api-${key}`;
const element = document.getElementById(id);
if (element) {
const { offsetTop, offsetHeight } = element;
// 这里的 50 是一个偏移量,可以根据实际情况调整
if (scrollTop >= offsetTop - 50 && scrollTop < offsetTop + offsetHeight) {
if (type === 'provider') {
activeProviderGroup.value = key;
}
else {
activeApiGroup.value = key;
}
break;
}
}
}
}
onMounted(async () => {
// 虽然使用了本地数据用于展示但可能仍需请求后端以保持某些状态同步或者直接使用本地数据初始化store
// 这里我们优先使用本地数据来填充store或者仅在UI上使用本地数据
// 为了兼容现有逻辑,我们尽量保持 modelStore 的使用,但列表展示主要依赖 localModelList
// 如果后端返回列表为空,可以用本地列表兜底
if (modelStore.modelList.length === 0) {
modelStore.modelList = localModelList;
}
// 设置默认模型
if (
(!modelStore.currentModelInfo || !modelStore.currentModelInfo.modelId)
&& localModelList.length > 0
) {
modelStore.setCurrentModelInfo(localModelList[0]);
}
});
const currentModelName = computed(
() => modelStore.currentModelInfo && modelStore.currentModelInfo.modelName,
);
// 过滤后的模型列表
const filteredModelList = computed(() => {
if (filterPremiumOnly.value) {
return modelStore.modelList.filter(model => model.isPremiumPackage);
}
return modelStore.modelList;
});
// 按 API 类型分组
const groupedByApiType = computed(() => {
const groups: Record<string, GetSessionListVO[]> = {};
filteredModelList.value.forEach((model) => {
const apiType = model.modelApiType || 'Completions';
if (!groups[apiType]) {
groups[apiType] = [];
}
groups[apiType].push(model);
});
return groups;
});
// 按 厂商 (Provider) 分组
const groupedByProvider = computed(() => {
const groups: Record<string, GetSessionListVO[]> = {};
filteredModelList.value.forEach((model) => {
const provider = model.providerName || 'Other';
if (!groups[provider]) {
groups[provider] = [];
}
groups[provider].push(model);
});
return groups;
});
// 打开弹窗
function openDialog() {
dialogVisible.value = true;
// 打开时定位到当前模型
nextTick(() => {
scrollToCurrentModel();
});
// 每次打开弹窗都重新请求模型列表
modelStore.requestModelList();
}
// 监听 tab 切换,自动定位
watch(activeTab, () => {
nextTick(() => {
scrollToCurrentModel();
});
});
// 检查是否为当前选中的模型
function isCurrentModel(item: GetSessionListVO) {
const current = modelStore.currentModelInfo;
if (!current)
return false;
// 优先使用唯一 ID 匹配
if (item.id && current.id) {
return item.id === current.id;
}
// 降级使用组合键匹配
return item.modelId === current.modelId
&& item.modelApiType === current.modelApiType
&& item.providerName === current.providerName;
}
// 定位到当前模型
function scrollToCurrentModel() {
const current = modelStore.currentModelInfo;
if (!current)
return;
// 使用 item.id 构建 ID (假设 modelData.ts 中的 id 是唯一的)
// 如果没有 id则无法精确定位这里假设都有 id
const currentId = current.id;
if (!currentId)
return;
// 根据当前 tab 构建对应的 ID
const elementId = activeTab.value === 'provider'
? `provider-model-${currentId}`
: `api-model-${currentId}`;
const element = document.getElementById(elementId);
if (element) {
element.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
// 处理模型点击
function handleModelClick(item: GetSessionListVO) {
if (!isModelAvailable(item)) {
ElMessageBox.confirm(
`
<div class="text-center leading-relaxed">
<h3 class="text-lg font-bold mb-3">${isUserVip() ? 'YiXinAI-VIP 会员' : '成为 YiXinAI-VIP'}</h3>
<p class="mb-2">
${
isUserVip()
? '您已是尊贵会员,享受全部 AI 模型与专属服务。感谢支持!'
: '解锁所有 AI 模型,无限加速,专属客服,尽享尊贵体验。'
}
</p>
${
isUserVip()
? '<p class="text-sm text-gray-500">您可随时访问产品页面查看更多特权内容。</p>'
: '<p class="text-sm text-gray-500">请点击右上角登录按钮,登录后进行购买!</p>'
}
</div>
`,
isUserVip() ? '会员状态' : '会员尊享',
{
confirmButtonText: '产品查看',
cancelButtonText: '关闭',
dangerouslyUseHTMLString: true,
type: 'info',
center: true,
roundButton: true,
},
)
.then(() => {
showProductPackage();
})
.catch(() => {
// 点击右上角关闭或“关闭”按钮,不执行任何操作
});
return;
}
modelStore.setCurrentModelInfo(item);
dialogVisible.value = false;
}
function goToModelLibrary() {
router.push('/model-library');
}
/* -------------------------------
模型样式规则
-------------------------------- */
function getModelStyleClass(mode: any) {
if (!mode)
return;
const isPremiumPackage = mode.isPremiumPackage;
// 规则3彩色流光 (尊享)
if (isPremiumPackage) {
return `
text-transparent bg-clip-text
bg-[linear-gradient(45deg,#ff0000,#ff8000,#ffff00,#00ff00,#00ffff,#0000ff,#8000ff,#ff0080)]
bg-[length:400%_400%] animate-gradientFlow
`;
}
// 规则2普通灰免费模型
if (mode.isFree) {
return 'text-gray-700';
}
// 规则1金色光泽 (VIP)
return `
text-[#B38728] font-semibold relative overflow-hidden
before:content-[''] before:absolute before:-inset-2 before:-z-10
before:animate-goldShine
`;
}
/* -------------------------------
外层卡片样式(选中态 + hover 动效)
-------------------------------- */
function getWrapperClass(item: GetSessionListVO) {
const isSelected = isCurrentModel(item);
const available = isModelAvailable(item);
return [
'p-3 rounded-lg text-sm transition-all duration-300 relative select-none flex items-center justify-between cursor-pointer mb-2',
available
? 'hover:bg-gray-50 hover:shadow-sm'
: 'opacity-60 cursor-not-allowed bg-gray-50',
isSelected
? 'border-2 border-primary bg-primary-light-9 shadow-md'
: 'border border-gray-200',
];
}
</script>
<template>
<div class="model-select" data-tour="model-select">
<!-- 触发按钮 -->
<div
class="model-select-box select-none flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-12px border-[rgba()] leading-snug"
@click="openDialog"
>
<div class="model-select-box-icon">
<SvgIcon name="models" size="12" />
</div>
<div :class="getModelStyleClass(modelStore.currentModelInfo)" class="model-select-box-text font-size-12px">
{{ currentModelName || '选择模型' }}
</div>
</div>
<!-- 模型选择弹窗 -->
<el-dialog
v-model="dialogVisible"
title="切换模型"
:width="isMobile ? '95%' : '900px'"
class="model-select-dialog"
append-to-body
destroy-on-close
align-center
>
<div class="model-list-container relative">
<!-- 右上角操作区域 -->
<div class="absolute right-0 top-1 z-10 flex items-center gap-3">
<!-- 尊享过滤标签 -->
<div
class="premium-filter-tag"
:class="{ 'premium-filter-tag--active': filterPremiumOnly }"
@click="filterPremiumOnly = !filterPremiumOnly"
>
<span class="premium-filter-tag__text">仅看尊享模型</span>
</div>
<el-button type="primary" link size="small" @click="goToModelLibrary">
前往模型库
<el-icon class="ml-1">
<Right />
</el-icon>
</el-button>
</div>
<el-tabs v-model="activeTab" class="model-tabs">
<!-- 厂商分类 Tab -->
<el-tab-pane label="厂商类型" name="provider">
<div class="flex h-[600px]">
<!-- 侧边导航 -->
<div class="w-28 flex-shrink-0 border-r border-gray-100 overflow-y-auto mr-2">
<div
v-for="(_, provider) in groupedByProvider"
:key="provider"
class="cursor-pointer px-2 py-2.5 text-xs hover:bg-gray-50 truncate transition-colors duration-200 border-l-2 border-transparent"
:class="{ 'text-primary font-bold bg-blue-50 border-primary': activeProviderGroup === provider, 'text-gray-600': activeProviderGroup !== provider }"
@click="scrollToGroup('provider', provider as string)"
>
{{ provider }}
</div>
</div>
<!-- 内容列表 -->
<div class="flex-1 min-w-0">
<el-scrollbar ref="scrollbarRef" height="100%" @scroll="handleScroll">
<div class="px-2 pb-4">
<template v-for="(models, provider) in groupedByProvider" :key="provider">
<div :id="`group-provider-${provider}`" class="group-title text-gray-500 text-xs font-bold mb-2 mt-4 px-1 pt-2">
{{ provider }}
</div>
<div
v-for="item in models"
:id="`provider-model-${item.id}`"
:key="item.id"
:class="getWrapperClass(item)"
@click="handleModelClick(item)"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<!-- 模型 Logo -->
<div class="w-8 h-8 flex-shrink-0 rounded-full bg-white border border-gray-100 flex items-center justify-center overflow-hidden p-1">
<img v-if="item.iconUrl" :src="item.iconUrl" class="w-full h-full object-contain" alt="icon">
<SvgIcon v-else name="models" size="16" />
</div>
<!-- 模型信息 -->
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span :class="getModelStyleClass(item)" class="font-medium truncate">
{{ item.modelName }}
</span>
<span v-if="item.isFree" class="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-600 rounded-full">免费</span>
<span v-if="item.isPremiumPackage" class="text-[10px] px-1.5 py-0.5 bg-orange-100 text-orange-600 rounded-full">尊享</span>
<span v-else-if="!item.isFree" class="text-[10px] px-1.5 py-0.5 bg-yellow-100 text-yellow-600 rounded-full">VIP</span>
<!-- 显示 API 类型 -->
<span class="text-[10px] px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded-full">{{ item.modelApiType }}</span>
</div>
<div class="text-xs text-gray-400 break-words whitespace-normal line-clamp-2" :title="item.modelDescribe">
{{ item.modelDescribe }}
</div>
</div>
</div>
<!-- 选中/锁定图标 -->
<div class="flex items-center">
<el-icon v-if="isCurrentModel(item)" class="text-primary mr-2" :size="18">
<Check />
</el-icon>
<el-icon v-if="!isModelAvailable(item)" class="text-gray-400">
<Lock />
</el-icon>
</div>
</div>
</template>
</div>
</el-scrollbar>
</div>
</div>
</el-tab-pane>
<!-- API类型分类 Tab -->
<el-tab-pane label="API类型" name="api">
<div class="flex h-[600px]">
<!-- 侧边导航 -->
<div class="w-28 flex-shrink-0 border-r border-gray-100 overflow-y-auto mr-2">
<div
v-for="(_, apiType) in groupedByApiType"
:key="apiType"
class="cursor-pointer px-1 py-2.5 text-xs hover:bg-gray-50 transition-colors duration-200 border-l-2 border-transparent leading-tight"
:class="{ 'text-primary font-bold bg-blue-50 border-primary': activeApiGroup === apiType, 'text-gray-600': activeApiGroup !== apiType }"
@click="scrollToGroup('api', apiType as string)"
>
<div v-html="(apiType).replace(/ /g, '<br/>')" />
</div>
</div>
<!-- 内容列表 -->
<div class="flex-1 min-w-0">
<el-scrollbar height="100%" @scroll="handleScroll">
<div class="px-2 pb-4">
<template v-for="(models, apiType) in groupedByApiType" :key="apiType">
<div :id="`group-api-${apiType}`" class="group-title text-gray-500 text-xs font-bold mb-2 mt-4 px-1 pt-2">
{{ apiType }}
</div>
<div
v-for="item in models"
:id="`api-model-${item.id}`"
:key="item.id"
:class="getWrapperClass(item)"
@click="handleModelClick(item)"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-8 h-8 flex-shrink-0 rounded-full bg-white border border-gray-100 flex items-center justify-center overflow-hidden p-1">
<img v-if="item.iconUrl" :src="item.iconUrl" class="w-full h-full object-contain" alt="icon">
<SvgIcon v-else name="models" size="16" />
</div>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span :class="getModelStyleClass(item)" class="font-medium truncate">
{{ item.modelName }}
</span>
<span v-if="item.isFree" class="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-600 rounded-full">免费</span>
<span v-if="item.isPremiumPackage" class="text-[10px] px-1.5 py-0.5 bg-orange-100 text-orange-600 rounded-full">尊享</span>
<span v-else-if="!item.isFree" class="text-[10px] px-1.5 py-0.5 bg-yellow-100 text-yellow-600 rounded-full">VIP</span>
<!-- 显示 厂商名称 -->
<span class="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full">{{ item.providerName }}</span>
</div>
<div class="text-xs text-gray-400 break-words whitespace-normal line-clamp-2" :title="item.modelDescribe">
{{ item.modelDescribe }}
</div>
</div>
</div>
<!-- 选中/锁定图标 -->
<div class="flex items-center">
<el-icon v-if="isCurrentModel(item)" class="text-primary mr-2" :size="18">
<Check />
</el-icon>
<el-icon v-if="!isModelAvailable(item)" class="text-gray-400">
<Lock />
</el-icon>
</div>
</div>
</template>
</div>
</el-scrollbar>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.model-select-box {
color: var(--el-color-primary, #409eff);
background: var(--el-color-primary-light-9, rgb(235.9 245.3 255));
border: 1px solid var(--el-color-primary, #409eff);
border-radius: 10px;
}
/* 移动端适配 */
@media (max-width: 768px) {
:deep(.model-select-dialog) {
max-width: 100% !important;
margin-top: 10vh !important;
.el-dialog__body {
padding: 10px;
}
}
}
/* 彩色流光动画 */
@keyframes gradientFlow {
0%, 100% { background-position: 0 50%; }
50% { background-position: 100% 50%; }
}
/* 金色光泽动画 */
@keyframes goldShine {
0% { transform: translateX(-100%) translateY(-100%); }
100% { transform: translateX(100%) translateY(100%); }
}
.animate-gradientFlow {
animation: gradientFlow 3s ease infinite;
}
.animate-goldShine {
animation: goldShine 4s linear infinite;
}
/* 定义一些颜色变量辅助类,如果项目没有定义的话 */
.text-primary {
color: var(--el-color-primary, #409eff);
}
.bg-primary-light-9 {
background-color: var(--el-color-primary-light-9, #ecf5ff);
}
.border-primary {
border-color: var(--el-color-primary, #409eff);
}
/* 尊享过滤标签 */
.premium-filter-tag {
padding: 4px 12px;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
border: 1.5px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3, #54a0ff, #5f27cd) border-box;
transition: all 0.3s ease;
user-select: none;
&__text {
background: linear-gradient(90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3, #54a0ff, #5f27cd);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gradientFlow 3s ease infinite;
}
&:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
}
&--active {
background: linear-gradient(90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3, #54a0ff, #5f27cd);
background-size: 200% 200%;
animation: gradientFlow 3s ease infinite;
.premium-filter-tag__text {
background: white;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
}
</style>