Files
Yi.Framework/Yi.Ai.Vue3/src/components/ModelSelect/index.vue
2026-01-11 19:21:48 +08:00

402 lines
14 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';
const modelStore = useModelStore();
const { isMobile } = useResponsive();
const dialogVisible = ref(false);
const activeTab = ref('provider'); // 'provider' | 'api'
const scrollbarRef = ref();
// 检查模型是否可用
function isModelAvailable(item: GetSessionListVO) {
return isUserVip() || item.isFree;
}
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,
);
// API 类型映射
const apiTypeNameMap: Record<string, string> = {
Completions: 'OpenAI Chat Completion',
Responses: 'OpenAI Responses API',
Messages: 'Anthropic Claude API',
GenerateContent: 'Google Gemini API',
};
// 按 API 类型分组
const groupedByApiType = computed(() => {
const groups: Record<string, GetSessionListVO[]> = {};
localModelList.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[]> = {};
localModelList.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();
});
}
// 监听 tab 切换,自动定位
watch(activeTab, () => {
nextTick(() => {
scrollToCurrentModel();
});
});
// 定位到当前模型
function scrollToCurrentModel() {
const currentId = modelStore.currentModelInfo?.modelId;
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() {
window.location.href = '/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金色光泽
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 = item.modelId === modelStore.currentModelInfo?.modelId;
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%' : '600px'"
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">
<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">
<el-scrollbar ref="scrollbarRef" height="400px">
<div class="px-2">
<template v-for="(models, provider) in groupedByProvider" :key="provider">
<div class="group-title text-gray-500 text-xs font-bold mb-2 mt-4 px-1">
{{ provider }}
</div>
<div
v-for="item in models"
:id="`provider-model-${item.modelId}`"
: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">
<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">VIP</span>
</div>
<div class="text-xs text-gray-400 truncate" :title="item.modelDescribe">
{{ item.modelDescribe }}
</div>
</div>
</div>
<!-- 选中/锁定图标 -->
<div class="flex items-center">
<el-icon v-if="item.modelId === modelStore.currentModelInfo?.modelId" 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>
</el-tab-pane>
<!-- API类型分类 Tab -->
<el-tab-pane label="API类型" name="api">
<el-scrollbar height="400px">
<div class="px-2">
<template v-for="(models, apiType) in groupedByApiType" :key="apiType">
<div class="group-title text-gray-500 text-xs font-bold mb-2 mt-4 px-1">
{{ apiTypeNameMap[apiType] || apiType }}
</div>
<div
v-for="item in models"
:id="`api-model-${item.modelId}`"
: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">
<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">VIP</span>
</div>
<div class="text-xs text-gray-400 truncate" :title="item.modelDescribe">
{{ item.modelDescribe }}
</div>
</div>
</div>
<!-- 选中/锁定图标 -->
<div class="flex items-center">
<el-icon v-if="item.modelId === modelStore.currentModelInfo?.modelId" 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>
</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);
}
</style>