Merge branch 'ai-hub' into ai-hub-dark

# Conflicts:
#	Yi.Ai.Vue3/.claude/settings.local.json
This commit is contained in:
ccnetcore
2026-01-21 21:49:47 +08:00
11 changed files with 1234 additions and 141 deletions

View File

@@ -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": []

View File

@@ -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();
}

View File

@@ -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[];

View File

@@ -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;
}
}

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -220,7 +220,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleChannel',
component: () => import('@/pages/console/channel/index.vue'),
meta: {
title: '意心Ai-渠道商管理',
title: '意心Ai-号池管理',
},
},
{