Merge branch 'ai-hub' into ai-hub-dark
# Conflicts: # Yi.Ai.Vue3/.claude/settings.local.json
This commit is contained in:
@@ -4,7 +4,9 @@
|
||||
"Bash(npx vue-tsc --noEmit)",
|
||||
"Bash(timeout 60 npx vue-tsc:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(xargs:*)"
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(timeout /t 5 /nobreak)",
|
||||
"Bash(git checkout:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AiModelCreateInput,
|
||||
AiModelUpdateInput,
|
||||
AiModelGetListInput,
|
||||
AppShortcutDto,
|
||||
PagedResultDto,
|
||||
} from './types';
|
||||
|
||||
@@ -103,3 +104,10 @@ export function deleteModel(id: string) {
|
||||
export function clearPremiumModelCache() {
|
||||
return post('/model/clear-premium-cache').json();
|
||||
}
|
||||
|
||||
// ==================== 快捷渠道 ====================
|
||||
|
||||
// 获取快捷渠道列表
|
||||
export function getAppShortcutList() {
|
||||
return get<AppShortcutDto[]>('/channel/app-shortcut').json();
|
||||
}
|
||||
|
||||
@@ -117,6 +117,17 @@ export interface AiModelGetListInput {
|
||||
maxResultCount?: number;
|
||||
}
|
||||
|
||||
// 快捷渠道DTO
|
||||
export interface AppShortcutDto {
|
||||
id: string;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
extraUrl?: string;
|
||||
apiKey: string;
|
||||
orderNum: number;
|
||||
creationTime: string;
|
||||
}
|
||||
|
||||
// 分页结果
|
||||
export interface PagedResultDto<T> {
|
||||
items: T[];
|
||||
|
||||
@@ -11,6 +11,21 @@ const isLoadingData = ref(false);
|
||||
|
||||
const activeTab = ref('activity');
|
||||
|
||||
// 图片预览相关状态
|
||||
const isImageViewerVisible = ref(false);
|
||||
const currentPreviewUrl = ref('');
|
||||
|
||||
// 打开图片预览
|
||||
function openImagePreview(url: string) {
|
||||
currentPreviewUrl.value = url;
|
||||
isImageViewerVisible.value = true;
|
||||
}
|
||||
|
||||
// 关闭图片预览
|
||||
function closeImagePreview() {
|
||||
isImageViewerVisible.value = false;
|
||||
}
|
||||
|
||||
// 窗口宽度响应式状态
|
||||
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1920);
|
||||
|
||||
@@ -123,9 +138,18 @@ watch(isDialogVisible, async (newValue) => {
|
||||
:key="index"
|
||||
class="activity-item"
|
||||
>
|
||||
<!-- 活动图片 -->
|
||||
<div v-if="activity.imageUrl" class="activity-image-wrapper">
|
||||
<!-- 活动图片 - 浮动在右上方 -->
|
||||
<div v-if="activity.imageUrl" class="activity-image-wrapper" @click="openImagePreview(activity.imageUrl)">
|
||||
<img :src="activity.imageUrl" :alt="activity.title" class="activity-image">
|
||||
<!-- 放大图标提示 -->
|
||||
<div class="zoom-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="11" y1="8" x2="11" y2="14"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 图片上的状态标签 -->
|
||||
<div class="activity-status-badge">
|
||||
<el-tag
|
||||
@@ -233,6 +257,16 @@ watch(isDialogVisible, async (newValue) => {
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片预览组件 -->
|
||||
<teleport to="body">
|
||||
<el-image-viewer
|
||||
v-if="isImageViewerVisible"
|
||||
:url-list="[currentPreviewUrl]"
|
||||
:initial-index="0"
|
||||
@close="closeImagePreview"
|
||||
/>
|
||||
</teleport>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -489,15 +523,22 @@ watch(isDialogVisible, async (newValue) => {
|
||||
|
||||
.activity-item {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
|
||||
// 清除浮动,确保父容器高度正确
|
||||
&::after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -525,9 +566,8 @@ watch(isDialogVisible, async (newValue) => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.activity-image {
|
||||
transform: scale(1.1);
|
||||
filter: brightness(1.05);
|
||||
.activity-image-wrapper {
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
@@ -545,10 +585,31 @@ watch(isDialogVisible, async (newValue) => {
|
||||
|
||||
.activity-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
float: right;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
margin-left: 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||||
shape-outside: inset(0);
|
||||
transition: box-shadow 0.3s;
|
||||
cursor: pointer;
|
||||
z-index: 1; // 确保在内容之上
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
&:hover .activity-image {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:hover .zoom-icon {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
@@ -560,6 +621,7 @@ watch(isDialogVisible, async (newValue) => {
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
z-index: 1;
|
||||
animation: shine 3s infinite;
|
||||
pointer-events: none; // 确保不拦截鼠标事件
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -587,22 +649,22 @@ watch(isDialogVisible, async (newValue) => {
|
||||
.activity-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain; // 等比例缩放,完整显示图片,不裁剪
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.activity-status-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
animation: fadeInScale 0.5s ease-out 0.3s both;
|
||||
|
||||
:deep(.el-tag) {
|
||||
border-radius: 20px;
|
||||
padding: 7px 18px;
|
||||
border-radius: 16px;
|
||||
padding: 5px 14px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
@@ -648,24 +710,52 @@ watch(isDialogVisible, async (newValue) => {
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none; // 不拦截鼠标事件,让父容器接收hover
|
||||
z-index: 10; // 提高层级,确保在状态标签之上
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #fff;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-body {
|
||||
padding: 20px;
|
||||
// 内容会自动环绕浮动的图片
|
||||
position: relative;
|
||||
z-index: 0; // 确保在浮动图片之下
|
||||
}
|
||||
|
||||
.activity-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
line-height: 1.3;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #4a5568 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -679,23 +769,23 @@ watch(isDialogVisible, async (newValue) => {
|
||||
|
||||
.activity-content-list {
|
||||
padding: 0;
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0 0 12px 0;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
|
||||
.content-line {
|
||||
margin: 8px 0;
|
||||
padding-left: 18px;
|
||||
margin: 6px 0;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 11px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
top: 9px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -715,8 +805,9 @@ watch(isDialogVisible, async (newValue) => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e8e9eb;
|
||||
clear: both; // 清除浮动,始终在新行显示,占满100%宽度
|
||||
}
|
||||
|
||||
.activity-time-range {
|
||||
@@ -727,7 +818,7 @@ watch(isDialogVisible, async (newValue) => {
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -735,19 +826,19 @@ watch(isDialogVisible, async (newValue) => {
|
||||
|
||||
&:first-child::before {
|
||||
content: '🕐';
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #f0f1f3 0%, #e8eaed 100%);
|
||||
color: #667eea;
|
||||
border-radius: 24px;
|
||||
font-size: 13px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -1095,16 +1186,27 @@ watch(isDialogVisible, async (newValue) => {
|
||||
|
||||
.activity-item {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.activity-image-wrapper {
|
||||
height: 180px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.activity-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.activity-image-wrapper {
|
||||
float: none;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
min-height: 180px;
|
||||
margin: 0 0 16px 0;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.activity-status-badge {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.activity-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -1183,7 +1285,18 @@ watch(isDialogVisible, async (newValue) => {
|
||||
|
||||
.activity-content {
|
||||
.activity-image-wrapper {
|
||||
float: none;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.activity-body {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const PAGE_PERMISSIONS: PermissionConfig[] = [
|
||||
{
|
||||
path: '/console/channel',
|
||||
allowedUsers: ['cc', 'Guo'],
|
||||
description: '渠道商管理页面 - 仅限cc和Guo用户访问',
|
||||
description: '号池管理页面 - 仅限cc和Guo用户访问',
|
||||
},
|
||||
{
|
||||
path: '/console/system-statistics',
|
||||
|
||||
@@ -46,6 +46,8 @@ const generating = ref(false);
|
||||
const currentTaskId = ref('');
|
||||
const currentTask = ref<TaskStatusResponse | null>(null);
|
||||
const showViewer = ref(false);
|
||||
const referenceImageViewerVisible = ref(false);
|
||||
const referenceImagePreviewUrl = ref('');
|
||||
let pollTimer: any = null;
|
||||
let debounceTimer: any = null;
|
||||
|
||||
@@ -130,6 +132,17 @@ function handleRemove(file: UploadFile) {
|
||||
fileList.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function handlePreview(file: UploadFile) {
|
||||
if (file.url) {
|
||||
referenceImagePreviewUrl.value = file.url;
|
||||
referenceImageViewerVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeReferenceImageViewer() {
|
||||
referenceImageViewerVisible.value = false;
|
||||
}
|
||||
|
||||
// Handle paste event for reference images
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
@@ -510,6 +523,7 @@ onUnmounted(() => {
|
||||
:limit="2"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleRemove"
|
||||
:on-preview="handlePreview"
|
||||
accept=".jpg,.jpeg,.png,.bmp,.webp"
|
||||
:class="{ 'hide-upload-btn': fileList.length >= 2 }"
|
||||
>
|
||||
@@ -644,6 +658,15 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参考图预览组件 -->
|
||||
<teleport to="body">
|
||||
<el-image-viewer
|
||||
v-if="referenceImageViewerVisible && referenceImagePreviewUrl"
|
||||
:url-list="[referenceImagePreviewUrl]"
|
||||
@close="closeReferenceImageViewer"
|
||||
/>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||
import { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||
import WelecomeText from '@/components/WelecomeText/index.vue';
|
||||
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
|
||||
@@ -26,6 +26,10 @@ const senderValue = ref(''); // 输入框内容
|
||||
const senderRef = ref(); // Sender 组件引用
|
||||
const isSending = ref(false); // 发送状态标志
|
||||
|
||||
// 文件处理相关常量
|
||||
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
||||
const MAX_TOTAL_CONTENT_LENGTH = 150000;
|
||||
|
||||
/**
|
||||
* 防抖发送消息函数
|
||||
*/
|
||||
@@ -107,6 +111,181 @@ watch(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 压缩图片
|
||||
*/
|
||||
function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width = width * ratio;
|
||||
height = height * ratio;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
}
|
||||
else {
|
||||
reject(new Error('压缩失败'));
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality,
|
||||
);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Blob 转换为 base64
|
||||
*/
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
*/
|
||||
async function handlePaste(event: ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items)
|
||||
return;
|
||||
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 计算已有文件的总内容长度
|
||||
let totalContentLength = 0;
|
||||
filesStore.filesList.forEach((f) => {
|
||||
if (f.fileType === 'text' && f.fileContent) {
|
||||
totalContentLength += f.fileContent.length;
|
||||
}
|
||||
if (f.fileType === 'image' && f.base64) {
|
||||
totalContentLength += Math.floor(f.base64.length * 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
const arr: any[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 验证文件大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
|
||||
if (isImage) {
|
||||
try {
|
||||
const compressionLevels = [
|
||||
{ maxWidth: 800, maxHeight: 800, quality: 0.6 },
|
||||
{ maxWidth: 600, maxHeight: 600, quality: 0.5 },
|
||||
{ maxWidth: 400, maxHeight: 400, quality: 0.4 },
|
||||
];
|
||||
|
||||
let compressedBlob: Blob | null = null;
|
||||
let base64 = '';
|
||||
|
||||
for (const level of compressionLevels) {
|
||||
compressedBlob = await compressImage(file, level.maxWidth, level.maxHeight, level.quality);
|
||||
base64 = await blobToBase64(compressedBlob);
|
||||
|
||||
const estimatedLength = Math.floor(base64.length * 0.5);
|
||||
if (totalContentLength + estimatedLength <= MAX_TOTAL_CONTENT_LENGTH) {
|
||||
totalContentLength += estimatedLength;
|
||||
break;
|
||||
}
|
||||
|
||||
compressedBlob = null;
|
||||
}
|
||||
|
||||
if (!compressedBlob) {
|
||||
ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`);
|
||||
continue;
|
||||
}
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: true,
|
||||
imgVariant: 'square',
|
||||
url: base64,
|
||||
isUploaded: true,
|
||||
base64,
|
||||
fileType: 'image',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('处理图片失败:', error);
|
||||
ElMessage.error(`${file.name} 处理失败`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
ElMessage.warning(`${file.name} 不支持粘贴,请使用上传按钮`);
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.length > 0) {
|
||||
filesStore.setFilesList([...filesStore.filesList, ...arr]);
|
||||
ElMessage.success(`已添加 ${arr.length} 个文件`);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听粘贴事件
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -8,9 +8,12 @@ import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||
import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElMessage } from 'element-plus';
|
||||
import { useHookFetch } from 'hook-fetch/vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import mammoth from 'mammoth';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { Sender } from 'vue-element-plus-x';
|
||||
import { useRoute } from 'vue-router';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { unifiedSend } from '@/api';
|
||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
|
||||
@@ -64,6 +67,17 @@ const bubbleItems = ref<MessageItem[]>([]);
|
||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||||
const isSending = ref(false);
|
||||
|
||||
// 文件处理相关常量(与 FilesSelect 保持一致)
|
||||
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
||||
const MAX_TOTAL_CONTENT_LENGTH = 150000;
|
||||
const MAX_TEXT_FILE_LENGTH = 50000;
|
||||
const MAX_WORD_LENGTH = 30000;
|
||||
const MAX_EXCEL_ROWS = 100;
|
||||
const MAX_PDF_PAGES = 10;
|
||||
|
||||
// 配置 PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
// 记录当前请求使用的 API 格式类型,用于正确解析响应
|
||||
const currentRequestApiType = ref<string>('');
|
||||
|
||||
@@ -489,6 +503,327 @@ function copy(item: any) {
|
||||
function handleImagePreview(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩图片
|
||||
*/
|
||||
function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width = width * ratio;
|
||||
height = height * ratio;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
}
|
||||
else {
|
||||
reject(new Error('压缩失败'));
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality,
|
||||
);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Blob 转换为 base64
|
||||
*/
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为文本文件
|
||||
*/
|
||||
function isTextFile(file: File): boolean {
|
||||
if (file.type.startsWith('text/'))
|
||||
return true;
|
||||
|
||||
const textExtensions = [
|
||||
'txt', 'log', 'md', 'markdown', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'config',
|
||||
'js', 'jsx', 'ts', 'tsx', 'vue', 'html', 'htm', 'css', 'scss', 'sass', 'less',
|
||||
'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'py', 'rb', 'go', 'rs', 'swift', 'kt', 'php',
|
||||
'sh', 'bash', 'sql', 'csv', 'tsv',
|
||||
];
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
return ext ? textExtensions.includes(ext) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文本文件
|
||||
*/
|
||||
function readTextFile(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel 文件
|
||||
*/
|
||||
async function parseExcel(file: File): Promise<{ content: string; totalRows: number; extractedRows: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
let result = '';
|
||||
let totalRows = 0;
|
||||
let extractedRows = 0;
|
||||
|
||||
workbook.SheetNames.forEach((sheetName, index) => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
|
||||
const sheetTotalRows = range.e.r - range.s.r + 1;
|
||||
totalRows += sheetTotalRows;
|
||||
|
||||
const rowsToExtract = Math.min(sheetTotalRows, MAX_EXCEL_ROWS);
|
||||
extractedRows += rowsToExtract;
|
||||
|
||||
const limitedData: any[][] = [];
|
||||
for (let row = range.s.r; row < range.s.r + rowsToExtract; row++) {
|
||||
const rowData: any[] = [];
|
||||
for (let col = range.s.c; col <= range.e.c; col++) {
|
||||
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
||||
const cell = worksheet[cellAddress];
|
||||
rowData.push(cell ? cell.v : '');
|
||||
}
|
||||
limitedData.push(rowData);
|
||||
}
|
||||
|
||||
const csvData = limitedData.map(row => row.join(',')).join('\n');
|
||||
if (workbook.SheetNames.length > 1)
|
||||
result += `=== Sheet: ${sheetName} ===\n`;
|
||||
result += csvData;
|
||||
if (index < workbook.SheetNames.length - 1)
|
||||
result += '\n\n';
|
||||
});
|
||||
|
||||
resolve({ content: result, totalRows, extractedRows });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Word 文档
|
||||
*/
|
||||
async function parseWord(file: File): Promise<{ content: string; totalLength: number; extracted: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const arrayBuffer = e.target?.result as ArrayBuffer;
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
const fullText = result.value;
|
||||
const totalLength = fullText.length;
|
||||
|
||||
if (totalLength > MAX_WORD_LENGTH) {
|
||||
const truncated = fullText.substring(0, MAX_WORD_LENGTH);
|
||||
resolve({ content: truncated, totalLength, extracted: true });
|
||||
}
|
||||
else {
|
||||
resolve({ content: fullText, totalLength, extracted: false });
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 PDF 文件
|
||||
*/
|
||||
async function parsePDF(file: File): Promise<{ content: string; totalPages: number; extractedPages: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const typedArray = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const pdf = await pdfjsLib.getDocument(typedArray).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
const pagesToExtract = Math.min(totalPages, MAX_PDF_PAGES);
|
||||
|
||||
let fullText = '';
|
||||
for (let i = 1; i <= pagesToExtract; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items.map((item: any) => item.str).join(' ');
|
||||
fullText += `${pageText}\n`;
|
||||
}
|
||||
|
||||
resolve({ content: fullText, totalPages, extractedPages: pagesToExtract });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
*/
|
||||
async function handlePaste(event: ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items)
|
||||
return;
|
||||
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 计算已有文件的总内容长度
|
||||
let totalContentLength = 0;
|
||||
filesStore.filesList.forEach((f) => {
|
||||
if (f.fileType === 'text' && f.fileContent) {
|
||||
totalContentLength += f.fileContent.length;
|
||||
}
|
||||
if (f.fileType === 'image' && f.base64) {
|
||||
totalContentLength += Math.floor(f.base64.length * 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
const arr: any[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 验证文件大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
|
||||
if (isImage) {
|
||||
try {
|
||||
const compressionLevels = [
|
||||
{ maxWidth: 800, maxHeight: 800, quality: 0.6 },
|
||||
{ maxWidth: 600, maxHeight: 600, quality: 0.5 },
|
||||
{ maxWidth: 400, maxHeight: 400, quality: 0.4 },
|
||||
];
|
||||
|
||||
let compressedBlob: Blob | null = null;
|
||||
let base64 = '';
|
||||
|
||||
for (const level of compressionLevels) {
|
||||
compressedBlob = await compressImage(file, level.maxWidth, level.maxHeight, level.quality);
|
||||
base64 = await blobToBase64(compressedBlob);
|
||||
|
||||
const estimatedLength = Math.floor(base64.length * 0.5);
|
||||
if (totalContentLength + estimatedLength <= MAX_TOTAL_CONTENT_LENGTH) {
|
||||
totalContentLength += estimatedLength;
|
||||
break;
|
||||
}
|
||||
|
||||
compressedBlob = null;
|
||||
}
|
||||
|
||||
if (!compressedBlob) {
|
||||
ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`);
|
||||
continue;
|
||||
}
|
||||
|
||||
arr.push({
|
||||
uid: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
file,
|
||||
maxWidth: '200px',
|
||||
showDelIcon: true,
|
||||
imgPreview: true,
|
||||
imgVariant: 'square',
|
||||
url: base64,
|
||||
isUploaded: true,
|
||||
base64,
|
||||
fileType: 'image',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('处理图片失败:', error);
|
||||
ElMessage.error(`${file.name} 处理失败`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
ElMessage.warning(`${file.name} 不支持粘贴,请使用上传按钮`);
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.length > 0) {
|
||||
filesStore.setFilesList([...filesStore.filesList, ...arr]);
|
||||
ElMessage.success(`已添加 ${arr.length} 个文件`);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听粘贴事件
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,32 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { AiAppDto, AiModelDto, AppShortcutDto } from '@/api/channel/types';
|
||||
import { Delete, Edit, Plus, Refresh, View } from '@element-plus/icons-vue';
|
||||
import type { AiAppDto, AiModelDto } from '@/api/channel/types';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
getAppList,
|
||||
createApp,
|
||||
updateApp,
|
||||
deleteApp,
|
||||
getModelList,
|
||||
createModel,
|
||||
updateModel,
|
||||
deleteModel,
|
||||
clearPremiumModelCache,
|
||||
createApp,
|
||||
createModel,
|
||||
deleteApp,
|
||||
deleteModel,
|
||||
getAppList,
|
||||
getAppShortcutList,
|
||||
getModelList,
|
||||
updateApp,
|
||||
updateModel,
|
||||
} from '@/api/channel';
|
||||
|
||||
// 移动端检测
|
||||
const isMobile = ref(false);
|
||||
function checkMobile() {
|
||||
isMobile.value = window.innerWidth < 768;
|
||||
}
|
||||
|
||||
// ==================== 应用管理 ====================
|
||||
const appList = ref<AiAppDto[]>([]);
|
||||
const appLoading = ref(false);
|
||||
const selectedAppId = ref<string>('');
|
||||
const selectedAppIds = ref<string[]>([]);
|
||||
|
||||
// 快捷号池列表
|
||||
const shortcutList = ref<AppShortcutDto[]>([]);
|
||||
const shortcutLoading = ref(false);
|
||||
|
||||
// 应用对话框
|
||||
const appDialogVisible = ref(false);
|
||||
const appDialogTitle = ref('');
|
||||
const appForm = ref<Partial<AiAppDto>>({});
|
||||
const selectedShortcutId = ref<string>('');
|
||||
const appDetailDialogVisible = ref(false);
|
||||
const appDetailData = ref<AiAppDto | null>(null);
|
||||
|
||||
// 批量应用号池对话框
|
||||
const batchApplyDialogVisible = ref(false);
|
||||
const batchApplyShortcutId = ref<string>('');
|
||||
|
||||
// 号池列表对话框
|
||||
const poolListDialogVisible = ref(false);
|
||||
|
||||
// 获取应用列表
|
||||
async function fetchAppList() {
|
||||
appLoading.value = true;
|
||||
@@ -51,6 +71,21 @@ async function fetchAppList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取快捷号池列表
|
||||
async function fetchShortcutList() {
|
||||
shortcutLoading.value = true;
|
||||
try {
|
||||
const res = await getAppShortcutList();
|
||||
shortcutList.value = res.data;
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error.message || '获取快捷号池列表失败');
|
||||
}
|
||||
finally {
|
||||
shortcutLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选择应用
|
||||
function handleSelectApp(appId: string) {
|
||||
selectedAppId.value = appId;
|
||||
@@ -66,6 +101,7 @@ function handleViewAppDetail(app: AiAppDto) {
|
||||
// 打开应用对话框
|
||||
function openAppDialog(type: 'create' | 'edit', row?: AiAppDto) {
|
||||
appDialogTitle.value = type === 'create' ? '创建应用' : '编辑应用';
|
||||
selectedShortcutId.value = '';
|
||||
if (type === 'create') {
|
||||
appForm.value = {
|
||||
name: '',
|
||||
@@ -81,6 +117,86 @@ function openAppDialog(type: 'create' | 'edit', row?: AiAppDto) {
|
||||
appDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 选择快捷号池
|
||||
function handleSelectShortcut() {
|
||||
const shortcut = shortcutList.value.find(s => s.id === selectedShortcutId.value);
|
||||
if (shortcut) {
|
||||
appForm.value = {
|
||||
...appForm.value,
|
||||
endpoint: shortcut.endpoint,
|
||||
extraUrl: shortcut.extraUrl || '',
|
||||
apiKey: shortcut.apiKey,
|
||||
// 不修改名称和排序,保持应用原有的配置
|
||||
};
|
||||
ElMessage.success('已自动填入号池配置(终结点和API Key)');
|
||||
}
|
||||
}
|
||||
|
||||
// 打开批量应用号池对话框
|
||||
function openBatchApplyDialog() {
|
||||
if (selectedAppIds.value.length === 0) {
|
||||
ElMessage.warning('请先选择要批量操作的应用');
|
||||
return;
|
||||
}
|
||||
batchApplyShortcutId.value = '';
|
||||
batchApplyDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 批量应用号池
|
||||
async function handleBatchApply() {
|
||||
if (!batchApplyShortcutId.value) {
|
||||
ElMessage.warning('请选择号池');
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcutList.value.find(s => s.id === batchApplyShortcutId.value);
|
||||
if (!shortcut) {
|
||||
ElMessage.error('未找到选择的号池');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 批量更新应用(只更新终结点和API Key,不修改应用名称)
|
||||
const promises = selectedAppIds.value.map((appId) => {
|
||||
// 获取当前应用信息,保留原有名称和排序
|
||||
const currentApp = appList.value.find(a => a.id === appId);
|
||||
return updateApp({
|
||||
id: appId,
|
||||
name: currentApp?.name || '', // 保持原有应用名称
|
||||
endpoint: shortcut.endpoint,
|
||||
extraUrl: shortcut.extraUrl,
|
||||
apiKey: shortcut.apiKey,
|
||||
orderNum: currentApp?.orderNum || 0, // 保持原有排序
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
ElMessage.success(`成功应用到 ${selectedAppIds.value.length} 个应用(终结点和API Key已更新)`);
|
||||
batchApplyDialogVisible.value = false;
|
||||
selectedAppIds.value = [];
|
||||
fetchAppList();
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error.message || '批量应用失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理应用选择变化
|
||||
function handleSelectionChange(selection: AiAppDto[]) {
|
||||
selectedAppIds.value = selection.map(item => item.id);
|
||||
}
|
||||
|
||||
// 切换应用选择
|
||||
function toggleAppSelection(appId: string) {
|
||||
const index = selectedAppIds.value.indexOf(appId);
|
||||
if (index > -1) {
|
||||
selectedAppIds.value.splice(index, 1);
|
||||
}
|
||||
else {
|
||||
selectedAppIds.value.push(appId);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存应用
|
||||
async function saveApp() {
|
||||
try {
|
||||
@@ -243,20 +359,49 @@ async function handleClearCache() {
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
fetchAppList();
|
||||
fetchShortcutList();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channel-management">
|
||||
<div class="channel-container">
|
||||
<div class="channel-container" :class="{ 'mobile-view': isMobile }">
|
||||
<!-- 左侧应用列表 -->
|
||||
<div class="app-list-panel">
|
||||
<div class="panel-header">
|
||||
<h3>应用列表</h3>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openAppDialog('create')">
|
||||
新建
|
||||
</el-button>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
v-if="selectedAppIds.length > 0"
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="openBatchApplyDialog"
|
||||
>
|
||||
<span v-if="!isMobile">批量应用号池</span>
|
||||
<span v-else>批量</span>
|
||||
<template v-if="!isMobile">
|
||||
({{ selectedAppIds.length }})
|
||||
</template>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
size="small"
|
||||
@click="poolListDialogVisible = true"
|
||||
>
|
||||
<span v-if="!isMobile">查看号池列表</span>
|
||||
<span v-else>号池</span>
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openAppDialog('create')">
|
||||
新建
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="app-list-scrollbar">
|
||||
@@ -266,37 +411,43 @@ onMounted(() => {
|
||||
:key="app.id"
|
||||
class="app-item"
|
||||
:class="{ active: selectedAppId === app.id }"
|
||||
@click="handleSelectApp(app.id)"
|
||||
>
|
||||
<div class="app-item-content">
|
||||
<div class="app-name">{{ app.name }}</div>
|
||||
<el-checkbox
|
||||
:model-value="selectedAppIds.includes(app.id)"
|
||||
@change="() => toggleAppSelection(app.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="app-item-content" @click="handleSelectApp(app.id)">
|
||||
<div class="app-name">
|
||||
{{ app.name }}
|
||||
</div>
|
||||
<div class="app-actions">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="View"
|
||||
:icon="isMobile ? undefined : View"
|
||||
@click.stop="handleViewAppDetail(app)"
|
||||
>
|
||||
详情
|
||||
{{ isMobile ? '详情' : '' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="Edit"
|
||||
:icon="isMobile ? undefined : Edit"
|
||||
@click.stop="openAppDialog('edit', app)"
|
||||
>
|
||||
编辑
|
||||
{{ isMobile ? '编辑' : '' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:icon="Delete"
|
||||
:icon="isMobile ? undefined : Delete"
|
||||
@click.stop="handleDeleteApp(app)"
|
||||
>
|
||||
删除
|
||||
{{ isMobile ? '删除' : '' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,87 +465,122 @@ onMounted(() => {
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="modelSearchKey"
|
||||
placeholder="搜索模型"
|
||||
style="width: 200px; margin-right: 10px"
|
||||
:placeholder="isMobile ? '搜索' : '搜索模型'"
|
||||
:style="isMobile ? 'width: 120px; margin-right: 8px' : 'width: 200px; margin-right: 10px'"
|
||||
clearable
|
||||
@keyup.enter="fetchModelList"
|
||||
/>
|
||||
<el-button type="warning" size="small" @click="handleClearCache">
|
||||
清理缓存
|
||||
{{ isMobile ? '' : '清理缓存' }}
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openModelDialog('create')">
|
||||
新建
|
||||
{{ isMobile ? '' : '新建' }}
|
||||
</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="fetchModelList">
|
||||
刷新
|
||||
{{ isMobile ? '' : '刷新' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedAppId" class="empty-tip">
|
||||
<el-empty description="请先选择左侧的应用" />
|
||||
<el-empty :description="isMobile ? '请选择应用' : '请先选择左侧的应用'" />
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
v-loading="modelLoading"
|
||||
:data="modelList"
|
||||
border
|
||||
stripe
|
||||
height="calc(100vh - 220px)"
|
||||
>
|
||||
<el-table-column prop="name" label="模型名称" min-width="150" />
|
||||
<el-table-column prop="modelId" label="模型ID" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="handlerName" label="处理名" min-width="120" />
|
||||
<el-table-column prop="providerName" label="供应商" width="100" />
|
||||
<el-table-column label="是否尊享" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isPremium ? 'warning' : 'info'">
|
||||
{{ row.isPremium ? '尊享' : '普通' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否启用" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isEnabled ? 'success' : 'danger'">
|
||||
{{ row.isEnabled ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="multiplier" label="模型倍率" width="100" />
|
||||
<el-table-column prop="multiplierShow" label="显示倍率" width="100" />
|
||||
<el-table-column prop="orderNum" label="排序" width="80" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="Edit" @click="openModelDialog('edit', row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" :icon="Delete" @click="handleDeleteModel(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="table-wrapper">
|
||||
<el-table
|
||||
v-loading="modelLoading"
|
||||
:data="modelList"
|
||||
border
|
||||
stripe
|
||||
:height="isMobile ? 'calc(100vh - 300px)' : 'calc(100vh - 220px)'"
|
||||
>
|
||||
<el-table-column prop="name" :label="isMobile ? '名称' : '模型名称'" min-width="120" />
|
||||
<el-table-column v-if="!isMobile" prop="modelId" label="模型ID" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column v-if="!isMobile" prop="handlerName" label="处理名" min-width="100" />
|
||||
<el-table-column v-if="!isMobile" prop="providerName" label="供应商" width="90" />
|
||||
<el-table-column :label="isMobile ? '尊享' : '是否尊享'" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isPremium ? 'warning' : 'info'" size="small">
|
||||
{{ row.isPremium ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="isMobile ? '状态' : '是否启用'" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isEnabled ? 'success' : 'danger'" size="small">
|
||||
{{ row.isEnabled ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isMobile" prop="multiplier" label="倍率" width="80" />
|
||||
<el-table-column v-if="!isMobile" prop="orderNum" label="排序" width="70" />
|
||||
<el-table-column label="操作" :width="isMobile ? 120 : 150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openModelDialog('edit', row)">
|
||||
{{ isMobile ? '' : '编辑' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteModel(row)">
|
||||
{{ isMobile ? '' : '删除' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 应用详情对话框 -->
|
||||
<el-dialog v-model="appDetailDialogVisible" title="应用详情" width="600px">
|
||||
<el-descriptions v-if="appDetailData" :column="1" border>
|
||||
<el-descriptions-item label="应用名称">{{ appDetailData.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="终结点">{{ appDetailData.endpoint }}</el-descriptions-item>
|
||||
<el-descriptions-item label="额外URL">{{ appDetailData.extraUrl || '-' }}</el-descriptions-item>
|
||||
<el-dialog v-model="appDetailDialogVisible" title="应用详情" :width="isMobile ? '90%' : '600px'">
|
||||
<el-descriptions v-if="appDetailData" :column="isMobile ? 1 : 1" border>
|
||||
<el-descriptions-item label="应用名称">
|
||||
{{ appDetailData.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="终结点">
|
||||
{{ appDetailData.endpoint }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="额外URL">
|
||||
{{ appDetailData.extraUrl || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="API Key">
|
||||
<el-input :model-value="appDetailData.apiKey" type="textarea" readonly />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序">{{ appDetailData.orderNum }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ appDetailData.creationTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="排序">
|
||||
{{ appDetailData.orderNum }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ appDetailData.creationTime }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 应用编辑对话框 -->
|
||||
<el-dialog v-model="appDialogVisible" :title="appDialogTitle" width="600px">
|
||||
<el-form :model="appForm" label-width="120px">
|
||||
<el-dialog v-model="appDialogVisible" :title="appDialogTitle" :width="isMobile ? '90%' : '650px'">
|
||||
<el-form :model="appForm" :label-width="isMobile ? '80px' : '120px'">
|
||||
<el-form-item label="选择号池">
|
||||
<el-select
|
||||
v-model="selectedShortcutId"
|
||||
placeholder="选择号池自动填入配置"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
@change="handleSelectShortcut"
|
||||
>
|
||||
<el-option
|
||||
v-for="shortcut in shortcutList"
|
||||
:key="shortcut.id"
|
||||
:label="shortcut.name"
|
||||
:value="shortcut.id"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>{{ shortcut.name }}</span>
|
||||
<span style="font-size: 12px; color: #999; margin-left: 10px">{{ shortcut.endpoint }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 5px">
|
||||
选择号池可自动填入终结点和 API Key
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
<el-form-item label="应用名称" required>
|
||||
<el-input v-model="appForm.name" placeholder="请输入应用名称" />
|
||||
</el-form-item>
|
||||
@@ -412,14 +598,110 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="appDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveApp">保存</el-button>
|
||||
<el-button @click="appDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="saveApp">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量应用号池对话框 -->
|
||||
<el-dialog v-model="batchApplyDialogVisible" title="批量应用号池" :width="isMobile ? '90%' : '700px'">
|
||||
<el-alert
|
||||
:title="`已选择 ${selectedAppIds.length} 个应用,将统一应用以下号池配置`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 15px"
|
||||
/>
|
||||
<el-form :label-width="isMobile ? '80px' : '100px'">
|
||||
<el-form-item label="选中的应用">
|
||||
<div style="max-height: 100px; overflow-y: auto">
|
||||
<el-tag
|
||||
v-for="appId in selectedAppIds"
|
||||
:key="appId"
|
||||
style="margin: 4px"
|
||||
size="default"
|
||||
>
|
||||
{{ appList.find(a => a.id === appId)?.name || appId }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择号池" required>
|
||||
<el-select
|
||||
v-model="batchApplyShortcutId"
|
||||
placeholder="请选择号池"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="shortcut in shortcutList"
|
||||
:key="shortcut.id"
|
||||
:label="shortcut.name"
|
||||
:value="shortcut.id"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>{{ shortcut.name }}</span>
|
||||
<span style="font-size: 12px; color: #999; margin-left: 10px">{{ shortcut.endpoint }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="batchApplyShortcutId" label="号池详情">
|
||||
<div
|
||||
v-if="shortcutList.find(s => s.id === batchApplyShortcutId)"
|
||||
style="background: #f5f7fa; padding: 15px; border-radius: 4px"
|
||||
>
|
||||
<div><strong>名称:</strong>{{ shortcutList.find(s => s.id === batchApplyShortcutId)?.name }}</div>
|
||||
<div style="margin-top: 8px">
|
||||
<strong>终结点:</strong>{{ shortcutList.find(s => s.id === batchApplyShortcutId)?.endpoint }}
|
||||
</div>
|
||||
<div style="margin-top: 8px">
|
||||
<strong>API Key:</strong>{{ shortcutList.find(s => s.id === batchApplyShortcutId)?.apiKey?.substring(0, 20) }}***
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchApplyDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleBatchApply">
|
||||
确定应用
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 号池列表对话框 -->
|
||||
<el-dialog v-model="poolListDialogVisible" title="号池列表" :width="isMobile ? '95%' : '900px'">
|
||||
<div class="pool-table-wrapper">
|
||||
<el-table :data="shortcutList" border stripe>
|
||||
<el-table-column prop="name" label="号池名称" :min-width="isMobile ? 120 : 180" />
|
||||
<el-table-column prop="endpoint" label="终结点" :min-width="isMobile ? 180 : 250" show-overflow-tooltip />
|
||||
<el-table-column v-if="!isMobile" prop="extraUrl" label="额外URL" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.extraUrl || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="apiKey" label="API Key" :min-width="isMobile ? 150 : 200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.apiKey?.substring(0, isMobile ? 15 : 30) }}{{ row.apiKey?.length > (isMobile ? 15 : 30) ? '...' : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isMobile" prop="orderNum" label="排序" width="80" />
|
||||
<el-table-column v-if="!isMobile" prop="creationTime" label="创建时间" width="160" />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="poolListDialogVisible = false">
|
||||
关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 模型编辑对话框 -->
|
||||
<el-dialog v-model="modelDialogVisible" :title="modelDialogTitle" width="700px">
|
||||
<el-form :model="modelForm" label-width="120px">
|
||||
<el-dialog v-model="modelDialogVisible" :title="modelDialogTitle" :width="isMobile ? '90%' : '700px'">
|
||||
<el-form :model="modelForm" :label-width="isMobile ? '80px' : '120px'">
|
||||
<el-form-item label="模型名称" required>
|
||||
<el-input v-model="modelForm.name" placeholder="请输入模型名称" />
|
||||
</el-form-item>
|
||||
@@ -470,8 +752,12 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="modelDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveModel">保存</el-button>
|
||||
<el-button @click="modelDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="saveModel">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -480,88 +766,131 @@ onMounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.channel-management {
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
background: #f5f7fa;
|
||||
|
||||
.channel-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&.mobile-view {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.app-list-panel {
|
||||
width: 350px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 280px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.channel-container.mobile-view & {
|
||||
width: 100%;
|
||||
max-height: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.model-list-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.channel-container.mobile-view & {
|
||||
flex: none;
|
||||
height: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.app-list-scrollbar {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.app-list {
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
padding: 12px 16px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.25s ease;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
background: #f0f9ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.app-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,5 +901,98 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pool-table-wrapper {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.channel-management {
|
||||
padding: 8px;
|
||||
|
||||
.channel-container {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 6px;
|
||||
|
||||
.el-button {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.app-item-content {
|
||||
.app-name {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
.el-button {
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 小屏幕优化
|
||||
@media (max-width: 480px) {
|
||||
.channel-management {
|
||||
.channel-container {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-list-panel,
|
||||
.model-list-panel {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.app-list {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,10 +37,10 @@ const baseNavItems = [
|
||||
];
|
||||
|
||||
// 根据权限动态添加菜单项
|
||||
let navItems = [...baseNavItems];
|
||||
const navItems = [...baseNavItems];
|
||||
|
||||
if (hasChannelPermission) {
|
||||
navItems.push({ name: 'channel', label: '渠道商管理', icon: 'Setting', path: '/console/channel' });
|
||||
navItems.push({ name: 'channel', label: '号池管理', icon: 'Setting', path: '/console/channel' });
|
||||
}
|
||||
|
||||
if (hasSystemStatisticsPermission) {
|
||||
|
||||
@@ -220,7 +220,7 @@ export const layoutRouter: RouteRecordRaw[] = [
|
||||
name: 'consoleChannel',
|
||||
component: () => import('@/pages/console/channel/index.vue'),
|
||||
meta: {
|
||||
title: '意心Ai-渠道商管理',
|
||||
title: '意心Ai-号池管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user