Compare commits

..

11 Commits

Author SHA1 Message Date
Gsh
ecf9902907 fix: 批量删除apikey 2026-02-14 23:34:17 +08:00
Gsh
3c3acba451 fix: 批量删除apikey 2026-02-14 23:12:18 +08:00
Gsh
6f04bbf1c9 fix: 批量创建apikey 2026-02-14 22:40:49 +08:00
Gsh
cb61d3b100 fix: apikey用量百分比保留两位小数 2026-02-14 22:04:43 +08:00
Gsh
962c0489a3 fix: 对话选中状态增强 2026-02-14 22:00:09 +08:00
Gsh
717d5ba876 fix: 对话选中状态增强 2026-02-14 20:47:31 +08:00
Gsh
993c0fa95d fix: 对话底部加载动画位置上移 2026-02-14 20:43:41 +08:00
Gsh
10d52c2b76 Merge remote-tracking branch 'origin/ai-hub' into ai-hub 2026-02-14 20:15:04 +08:00
ccnetcore
bd5cf30349 refactor: 统一模型前缀处理并规范网关层逻辑
- 抽取并统一使用 ModelConst 处理模型前缀,移除重复的 yi- 前缀判断代码
- 网关层模型 ID 规范化逻辑集中,提升可维护性
- 修复常量文件缺失换行问题
- 前端版本号调整为 3.7.1
2026-02-13 18:35:36 +08:00
ccnetcore
3df4060b20 fix: 优化展示 2026-02-12 21:40:51 +08:00
Gsh
e6d5c272aa fix: 滚动条优化 2026-02-07 16:42:08 +08:00
9 changed files with 1632 additions and 164 deletions

View File

@@ -3,4 +3,4 @@
public class AiHubConst
{
public const string VipRole = "YiXinAi-Vip";
}
}

View File

@@ -0,0 +1,47 @@
namespace Yi.Framework.AiHub.Domain.Shared.Consts;
public class ModelConst
{
/// <summary>
/// 需要移除的模型前缀列表
/// </summary>
private static readonly List<string> ModelPrefixesToRemove =
[
"yi-",
"ma-"
];
/// <summary>
/// 获取模型ID的前缀如果存在
/// </summary>
private static string? GetModelPrefix(string? modelId)
{
if (string.IsNullOrEmpty(modelId)) return null;
return ModelPrefixesToRemove.FirstOrDefault(prefix =>
modelId!.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 移除模型ID的前缀返回标准模型ID
/// </summary>
public static string RemoveModelPrefix(string? modelId)
{
if (string.IsNullOrEmpty(modelId)) return string.Empty;
var prefix = GetModelPrefix(modelId);
if (prefix != null)
{
return modelId[prefix.Length..];
}
return modelId;
}
/// <summary>
/// 处理模型ID如有前缀则移除并返回新字符串
/// </summary>
public static string ProcessModelId(string? modelId)
{
return RemoveModelPrefix(modelId);
}
}

View File

@@ -15,6 +15,7 @@ using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using ModelConst = Yi.Framework.AiHub.Domain.Shared.Consts.ModelConst;
using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic;
using Yi.Framework.AiHub.Domain.Shared.Dtos.Gemini;
@@ -97,12 +98,8 @@ public class AiGateWayManager : DomainService
throw new UserFriendlyException($"【{modelId}】模型当前版本【{modelApiType}】格式不支持");
}
// ✅ 统一处理 yi- 后缀(网关层模型规范化)
if (!string.IsNullOrEmpty(aiModelDescribe.ModelId) &&
aiModelDescribe.ModelId.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
aiModelDescribe.ModelId = aiModelDescribe.ModelId[3..];
}
// ✅ 统一处理模型前缀(网关层模型规范化)
aiModelDescribe.ModelId = ModelConst.RemoveModelPrefix(aiModelDescribe.ModelId);
return aiModelDescribe;
}
@@ -134,11 +131,7 @@ public class AiGateWayManager : DomainService
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
var sourceModelId = request.Model;
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
request.Model = ModelConst.ProcessModelId(request.Model);
var data = await chatService.CompleteChatAsync(modelDescribe, request, cancellationToken);
data.SupplementalMultiplier(modelDescribe.Multiplier);
@@ -208,11 +201,7 @@ public class AiGateWayManager : DomainService
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
var sourceModelId = request.Model;
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
request.Model = ModelConst.ProcessModelId(request.Model);
var completeChatResponse = chatService.CompleteChatStreamAsync(modelDescribe, request, cancellationToken);
var tokenUsage = new ThorUsageResponse();
@@ -540,11 +529,7 @@ public class AiGateWayManager : DomainService
var modelDescribe = await GetModelAsync(ModelApiTypeEnum.Messages, request.Model);
var sourceModelId = request.Model;
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
request.Model = ModelConst.ProcessModelId(request.Model);
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
@@ -620,11 +605,7 @@ public class AiGateWayManager : DomainService
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
var sourceModelId = request.Model;
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
request.Model = ModelConst.ProcessModelId(request.Model);
var completeChatResponse = chatService.StreamChatCompletionsAsync(modelDescribe, request, cancellationToken);
ThorUsageResponse? tokenUsage = new ThorUsageResponse();
@@ -744,11 +725,7 @@ public class AiGateWayManager : DomainService
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
var sourceModelId = request.Model;
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
request.Model = ModelConst.ProcessModelId(request.Model);
var data = await chatService.ResponsesAsync(modelDescribe, request, cancellationToken);
@@ -820,11 +797,7 @@ public class AiGateWayManager : DomainService
var chatService =
LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
var sourceModelId = request.Model;
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
request.Model = ModelConst.ProcessModelId(request.Model);
var completeChatResponse = chatService.ResponsesStreamAsync(modelDescribe, request, cancellationToken);
ThorUsageResponse? tokenUsage = null;
@@ -1164,12 +1137,8 @@ public class AiGateWayManager : DomainService
response.Headers.TryAdd("Connection", "keep-alive");
var sourceModelId = modelId;
// 处理 yi- 前缀
if (!string.IsNullOrEmpty(modelId) &&
modelId.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
modelId = modelId[3..];
}
// 处理模型前缀
modelId = ModelConst.RemoveModelPrefix(modelId);
var modelDescribe = await GetModelAsync(apiType, sourceModelId);
@@ -1302,12 +1271,8 @@ public class AiGateWayManager : DomainService
// 提取用户最后一条消息
var userContent = request.Messages?.LastOrDefault()?.MessagesStore ?? string.Empty;
// 处理 yi- 前缀
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
// 处理模型前缀
request.Model = ModelConst.ProcessModelId(request.Model);
var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
var completeChatResponse = chatService.CompleteChatStreamAsync(modelDescribe, request, cancellationToken);
@@ -1391,12 +1356,8 @@ public class AiGateWayManager : DomainService
userContent = textContent?.Text ?? System.Text.Json.JsonSerializer.Serialize(lastMessage.Contents);
}
// 处理 yi- 前缀
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
// 处理模型前缀
request.Model = ModelConst.ProcessModelId(request.Model);
var chatService = LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
var completeChatResponse = chatService.StreamChatCompletionsAsync(modelDescribe, request, cancellationToken);
@@ -1509,12 +1470,8 @@ public class AiGateWayManager : DomainService
}
}
// 处理 yi- 前缀
if (!string.IsNullOrEmpty(request.Model) &&
request.Model.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
request.Model = request.Model[3..];
}
// 处理模型前缀
request.Model = ModelConst.ProcessModelId(request.Model);
var chatService = LazyServiceProvider.GetRequiredKeyedService<IOpenAiResponseService>(modelDescribe.HandlerName);
var completeChatResponse = chatService.ResponsesStreamAsync(modelDescribe, request, cancellationToken);

View File

@@ -46,6 +46,20 @@ const currentFormData = ref<TokenFormData>({
});
const router = useRouter();
// TokenFormDialog 组件引用
const tokenFormDialogRef = ref<InstanceType<typeof TokenFormDialog> | null>(null);
// 批量选择相关
const isBatchDeleteMode = ref(false); // 批量删除模式
const selectedTokenIds = ref<string[]>([]);
const selectAll = ref(false);
// 批量删除预览对话框
const showDeletePreviewDialog = ref(false);
const deleteItems = ref<TokenItem[]>([]);
const deleteStatusList = ref<Array<{ id: string; name: string; status: 'pending' | 'deleting' | 'success' | 'failed'; error?: string }>>([]);
const isDeleting = ref(false);
// 移动端检测
const isMobile = ref(false);
@@ -165,6 +179,8 @@ async function handleFormSubmit(data: TokenFormData) {
try {
loading.value = true;
// 单个创建或编辑
const submitData = {
id: data.id,
name: data.name,
@@ -193,6 +209,37 @@ async function handleFormSubmit(data: TokenFormData) {
}
}
// 处理批量创建
async function handleBatchCreate(
items: TokenFormData[],
onProgress: (index: number, status: 'pending' | 'creating' | 'success' | 'failed', error?: string) => void
) {
// 并发创建所有 token
const promises = items.map(async (item, index) => {
try {
onProgress(index, 'creating');
await createToken({
name: item.name,
expireTime: item.expireTime || null,
premiumQuotaLimit: item.premiumQuotaLimit || null,
});
onProgress(index, 'success');
} catch (error: any) {
console.error(`创建 "${item.name}" 失败:`, error);
onProgress(index, 'failed', error?.message || '创建失败');
throw error;
}
});
await Promise.allSettled(promises);
// 创建完成后刷新列表
await fetchTokenList();
// 调用子组件的完成方法
tokenFormDialogRef.value?.completeBatchCreate();
}
// 删除Token带防抖
async function handleDelete(row: TokenItem) {
if (operatingTokenId.value === row.id)
@@ -225,6 +272,108 @@ async function handleDelete(row: TokenItem) {
}
}
// 批量删除Token
async function handleBatchDelete() {
if (isBatchDeleteMode.value) {
// 已选择项目,显示预览对话框
if (selectedTokenIds.value.length === 0) {
ElMessage.warning('请先选择要删除的API密钥');
return;
}
// 获取选中的 token
deleteItems.value = tokenList.value.filter(t => selectedTokenIds.value.includes(t.id));
// 初始化删除状态
deleteStatusList.value = deleteItems.value.map(item => ({
id: item.id,
name: item.name,
status: 'pending' as const,
}));
showDeletePreviewDialog.value = true;
} else {
// 进入批量删除模式
isBatchDeleteMode.value = true;
}
}
// 确认批量删除
async function confirmBatchDelete() {
isDeleting.value = true;
// 并发删除所有选中的 token
const promises = deleteItems.value.map(async (item, index) => {
try {
deleteStatusList.value[index].status = 'deleting';
await deleteToken(item.id);
deleteStatusList.value[index].status = 'success';
} catch (error: any) {
console.error(`删除 "${item.name}" 失败:`, error);
deleteStatusList.value[index].status = 'failed';
deleteStatusList.value[index].error = error?.message || '删除失败';
throw error;
}
});
await Promise.allSettled(promises);
// 删除完成后刷新列表
await fetchTokenList();
// 关闭对话框并重置状态
showDeletePreviewDialog.value = false;
isDeleting.value = false;
isBatchDeleteMode.value = false;
selectedTokenIds.value = [];
selectAll.value = false;
deleteItems.value = [];
deleteStatusList.value = [];
}
// 取消批量删除预览
function cancelBatchDeletePreview() {
if (isDeleting.value) return;
showDeletePreviewDialog.value = false;
deleteItems.value = [];
deleteStatusList.value = [];
}
// 退出批量删除模式
function exitBatchDeleteMode() {
isBatchDeleteMode.value = false;
selectedTokenIds.value = [];
selectAll.value = false;
}
// 处理复选框选择变化
function handleSelectionChange() {
// 如果所有项目都被选中,则全选按钮也被选中
selectAll.value = tokenList.value.length > 0
&& selectedTokenIds.value.length === tokenList.value.length;
}
// 处理全选/取消全选
function handleSelectAllChange() {
if (selectAll.value) {
selectedTokenIds.value = tokenList.value.map(t => t.id);
} else {
selectedTokenIds.value = [];
}
}
// 处理单个项目的选择变化
function handleItemSelectChange(id: string, checked: boolean) {
if (checked) {
if (!selectedTokenIds.value.includes(id)) {
selectedTokenIds.value.push(id);
}
} else {
selectedTokenIds.value = selectedTokenIds.value.filter(itemId => itemId !== id);
}
// 更新全选状态
selectAll.value = tokenList.value.length > 0
&& selectedTokenIds.value.length === tokenList.value.length;
}
// 启用/禁用Token带防抖
async function handleToggle(row: TokenItem) {
if (operatingTokenId.value === row.id)
@@ -297,7 +446,7 @@ function formatQuota(num: number | null | undefined): string {
function getQuotaPercentage(used: number | null | undefined, limit: number | null | undefined) {
if (limit == null || limit === 0 || used == null)
return 0;
return Math.min((used / limit) * 100, 100);
return Number(Math.min((used / limit) * 100, 100).toFixed(2));
}
// 获取配额状态颜色
@@ -347,6 +496,27 @@ function isOperating(tokenId: string) {
return operatingTokenId.value === tokenId;
}
// 删除进度统计
const deleteProgress = computed(() => {
const total = deleteStatusList.value.length;
const success = deleteStatusList.value.filter(s => s.status === 'success').length;
const failed = deleteStatusList.value.filter(s => s.status === 'failed').length;
const pending = deleteStatusList.value.filter(s => s.status === 'pending' || s.status === 'deleting').length;
return { total, success, failed, pending };
});
// 删除预览对话框标题
const deletePreviewDialogTitle = computed(() => {
if (isDeleting.value) {
const { success, failed, pending } = deleteProgress.value;
if (pending > 0) {
return `删除中... (${success}/${deleteProgress.value.total})`;
}
return `删除完成`;
}
return '批量删除预览';
});
onMounted(async () => {
checkMobile();
window.addEventListener('resize', checkMobile);
@@ -388,6 +558,36 @@ onUnmounted(() => {
<el-button type="primary" :icon="Plus" size="default" @click="showCreateDialog">
新增 API密钥
</el-button>
<!-- 非批量删除模式 -->
<el-button
v-if="!isBatchDeleteMode"
type="danger"
:icon="Delete"
size="default"
@click="handleBatchDelete"
>
批量删除
</el-button>
<!-- 批量删除模式 -->
<template v-else>
<el-button
v-if="selectedTokenIds.length > 0"
type="danger"
:icon="Delete"
size="default"
:disabled="loading"
@click="handleBatchDelete"
>
确认删除 ({{ selectedTokenIds.length }})
</el-button>
<el-button
:icon="Close"
size="default"
@click="exitBatchDeleteMode"
>
取消
</el-button>
</template>
</div>
<div class="toolbar-right">
<el-button :icon="Refresh" size="default" @click="fetchTokenList">
@@ -409,6 +609,29 @@ onUnmounted(() => {
:header-cell-style="{ background: '#fafafa', color: '#262626', fontWeight: '600' }"
@sort-change="handleSortChange"
>
<!-- 复选框列仅批量删除模式显示 -->
<el-table-column
v-if="isBatchDeleteMode"
type="default"
width="50"
align="center"
header-align="center"
>
<template #header>
<el-checkbox
v-model="selectAll"
:indeterminate="selectedTokenIds.length > 0 && selectedTokenIds.length < tokenList.length"
@change="handleSelectAllChange"
/>
</template>
<template #default="{ row }">
<el-checkbox
:model-value="selectedTokenIds.includes(row.id)"
@change="(checked: boolean) => handleItemSelectChange(row.id, checked)"
/>
</template>
</el-table-column>
<el-table-column
prop="name"
label="API密钥 名称"
@@ -587,16 +810,6 @@ onUnmounted(() => {
>
{{ row.isDisabled ? '启用' : '禁用' }}
</el-button>
<el-button
style="margin-left: 0px"
size="small"
type="danger"
:icon="Delete"
:loading="isOperating(row.id)"
@click="handleDelete(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
@@ -605,9 +818,36 @@ onUnmounted(() => {
<!-- 移动端卡片列表 -->
<div v-else class="mobile-token-list">
<!-- 移动端批量操作栏 -->
<div v-if="isBatchDeleteMode && selectedTokenIds.length > 0" class="mobile-batch-bar">
<span class="mobile-batch-text">已选 {{ selectedTokenIds.length }} </span>
<el-button
type="danger"
size="small"
:icon="Delete"
:disabled="loading"
@click="handleBatchDelete"
>
确认删除
</el-button>
<el-button
size="small"
:icon="Close"
@click="exitBatchDeleteMode"
>
取消
</el-button>
</div>
<div v-for="token in tokenList" :key="token.id" class="mobile-token-card">
<div class="mobile-card-header">
<div class="mobile-card-title">
<el-checkbox
v-if="isBatchDeleteMode"
:model-value="selectedTokenIds.includes(token.id)"
@change="(checked: boolean) => handleItemSelectChange(token.id, checked)"
class="mobile-card-checkbox"
/>
<el-icon class="title-icon"><PriceTag /></el-icon>
<span>{{ token.name }}</span>
</div>
@@ -684,15 +924,6 @@ onUnmounted(() => {
>
{{ token.isDisabled ? '启用' : '禁用' }}
</el-button>
<el-button
size="small"
type="danger"
:icon="Delete"
:loading="isOperating(token.id)"
@click="handleDelete(token)"
>
删除
</el-button>
</div>
</div>
</div>
@@ -752,11 +983,98 @@ onUnmounted(() => {
<!-- Token表单对话框 -->
<TokenFormDialog
ref="tokenFormDialogRef"
v-model:visible="showFormDialog"
:mode="formMode"
:form-data="currentFormData"
@confirm="handleFormSubmit"
@batch-create="handleBatchCreate"
/>
<!-- 批量删除预览对话框 -->
<el-dialog
v-model="showDeletePreviewDialog"
:title="deletePreviewDialogTitle"
:width="isMobile ? '95%' : '600px'"
:fullscreen="isMobile"
:close-on-click-modal="false"
:show-close="!isDeleting"
@close="cancelBatchDeletePreview"
>
<div class="delete-preview-container">
<!-- 删除进度提示 -->
<div v-if="isDeleting" class="delete-progress-header">
<div class="progress-summary">
<el-icon v-if="deleteProgress.pending > 0" class="is-loading"><i-ep-loading /></el-icon>
<el-icon v-else-if="deleteProgress.failed > 0"><i-ep-circle-close /></el-icon>
<el-icon v-else><i-ep-circle-check /></el-icon>
<span>
<template v-if="deleteProgress.pending > 0">正在删除中...</template>
<template v-else-if="deleteProgress.failed > 0">部分删除失败</template>
<template v-else>全部删除成功</template>
</span>
</div>
<el-progress
:percentage="deleteProgress.total > 0 ? Math.round((deleteProgress.success + deleteProgress.failed) / deleteProgress.total * 100) : 0"
:status="deleteProgress.pending > 0 ? undefined : (deleteProgress.failed > 0 ? 'exception' : 'success')"
:stroke-width="8"
/>
</div>
<!-- 删除提示信息 -->
<div v-if="!isDeleting" class="delete-header">
<el-icon><i-ep-warning /></el-icon>
<span>即将删除 {{ deleteItems.length }} API 密钥删除后将无法恢复请确认以下信息</span>
</div>
<!-- 密钥列表 -->
<div class="delete-names-list">
<div
v-for="(item, index) in deleteStatusList"
:key="item.id"
class="delete-name-item"
:class="`status-${item.status}`"
>
<span class="delete-name-index">{{ index + 1 }}</span>
<span class="delete-name-text">{{ item.name }}</span>
<span class="delete-name-status">
<el-icon v-if="item.status === 'deleting'" class="is-loading"><i-ep-loading /></el-icon>
<el-icon v-else-if="item.status === 'success'"><i-ep-circle-check /></el-icon>
<el-icon v-else-if="item.status === 'failed'"><i-ep-circle-close /></el-icon>
<el-icon v-else><i-ep-warning /></el-icon>
<span class="delete-name-status-text">
<template v-if="item.status === 'pending'">待删除</template>
<template v-else-if="item.status === 'deleting'">删除中</template>
<template v-else-if="item.status === 'success'">已删除</template>
<template v-else-if="item.status === 'failed'">失败</template>
</span>
</span>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button :disabled="isDeleting" @click="cancelBatchDeletePreview">
取消
</el-button>
<el-button
v-if="!isDeleting"
type="danger"
@click="confirmBatchDelete"
>
确认删除
</el-button>
<el-button
v-else
type="success"
@click="cancelBatchDeletePreview"
>
完成
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
@@ -1288,6 +1606,22 @@ onUnmounted(() => {
gap: 12px;
}
.mobile-batch-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 8px;
}
.mobile-batch-text {
font-size: 14px;
font-weight: 500;
color: #fa8c16;
}
.mobile-token-card {
background: #fff;
border: 1px solid #e8e8e8;
@@ -1308,11 +1642,15 @@ onUnmounted(() => {
.mobile-card-title {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #333;
.mobile-card-checkbox {
margin-left: 0;
}
.title-icon {
color: #409eff;
}
@@ -1390,4 +1728,144 @@ onUnmounted(() => {
}
}
}
// 批量删除预览对话框样式
.delete-preview-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.delete-progress-header {
padding: 16px;
background: #fff1f0;
border: 1px solid #ffccc7;
border-radius: 8px;
}
.delete-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 8px;
color: #fa8c16;
font-size: 14px;
font-weight: 500;
.el-icon {
font-size: 20px;
flex-shrink: 0;
}
}
.delete-names-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 400px;
overflow-y: auto;
}
.delete-name-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
transition: all 0.2s;
&.status-pending {
border-color: #d9d9d9;
background: #fafafa;
.delete-name-status .el-icon {
color: #fa8c16;
}
}
&.status-deleting {
border-color: #1890ff;
background: #e6f7ff;
.delete-name-status .el-icon {
color: #1890ff;
}
}
&.status-success {
border-color: #52c41a;
background: #f6ffed;
.delete-name-status .el-icon {
color: #52c41a;
}
}
&.status-failed {
border-color: #ff4d4f;
background: #fff1f0;
.delete-name-status .el-icon {
color: #ff4d4f;
}
}
}
.delete-name-index {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #ff4d4f;
color: #fff;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.delete-name-text {
flex: 1;
font-size: 14px;
font-weight: 500;
color: #262626;
}
.delete-name-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
.el-icon {
color: #8c8c8c;
}
}
.delete-name-status-text {
font-size: 13px;
font-weight: 500;
.status-pending & {
color: #fa8c16;
}
.status-deleting & {
color: #1890ff;
}
.status-success & {
color: #52c41a;
}
.status-failed & {
color: #ff4d4f;
}
}
</style>

View File

@@ -2,6 +2,35 @@
import { ElMessage } from 'element-plus';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = withDefaults(defineProps<Props>(), {
visible: false,
mode: 'create',
formData: () => ({
name: '',
expireTime: '',
premiumQuotaLimit: 0,
quotaUnit: '万',
}),
});
const emit = defineEmits<{
'update:visible': [value: boolean];
'confirm': [data: TokenFormData | TokenFormData[]];
'batchCreate': [items: TokenFormData[], onProgress: (index: number, status: CreateStatus, error?: string) => void];
}>();
// 最大批量创建数量
const MAX_BATCH_CREATE_COUNT = 10;
// 创建状态类型
type CreateStatus = 'pending' | 'creating' | 'success' | 'failed';
interface CreateItemStatus {
name: string;
status: CreateStatus;
error?: string;
}
interface TokenFormData {
id?: string;
name: string;
@@ -17,22 +46,6 @@ interface Props {
formData?: TokenFormData;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
mode: 'create',
formData: () => ({
name: '',
expireTime: '',
premiumQuotaLimit: 0,
quotaUnit: '万',
}),
});
const emit = defineEmits<{
'update:visible': [value: boolean];
'confirm': [data: TokenFormData];
}>();
const localFormData = ref<TokenFormData>({
name: '',
expireTime: '',
@@ -45,6 +58,85 @@ const neverExpire = ref(false); // 永不过期开关
const unlimitedQuota = ref(false); // 无限制额度开关
const isEnableLog = ref(false); // 是否启用请求日志(只读)
// 批量创建相关
const batchCount = ref(1); // 批量创建数量默认1
const batchNamesText = ref(''); // 批量创建的名称文本(换行分隔)
const showPreviewDialog = ref(false); // 是否显示预览对话框
const previewNames = ref<string[]>([]); // 预览的名称列表
const createStatusList = ref<CreateItemStatus[]>([]); // 创建状态列表
const isCreating = ref(false); // 是否正在创建
// 解析批量名称文本,返回有效的名称数组
function parseBatchNames(): string[] {
return batchNamesText.value
.split('\n')
.map(line => line.trim())
.filter(name => name.length > 0);
}
// 生成默认名称列表
function generateDefaultNames(count: number): string[] {
const baseName = localFormData.value.name.trim() || 'API密钥';
const names: string[] = [];
for (let i = 1; i <= count; i++) {
names.push(`${baseName}${i}`);
}
return names;
}
// 构建最终的名称列表(以用户选择的数量为准)
function buildFinalNames(): string[] {
const targetCount = batchCount.value;
const parsedNames = parseBatchNames();
const result: string[] = [];
// 先使用用户粘贴的名称
for (let i = 0; i < Math.min(parsedNames.length, targetCount); i++) {
result.push(parsedNames[i]);
}
// 如果不够,用默认规则补齐
if (result.length < targetCount) {
const baseName = localFormData.value.name.trim() || 'API密钥';
for (let i = result.length + 1; i <= targetCount; i++) {
result.push(`${baseName}${i}`);
}
}
return result;
}
// 监听 batchCount 变化,自动生成名称
watch(batchCount, (count) => {
if (count > 1 && props.mode === 'create') {
// 如果当前文本框的名称数量小于新数量,自动补齐
const parsedNames = parseBatchNames();
if (parsedNames.length < count) {
const baseName = localFormData.value.name.trim() || 'API密钥';
const names: string[] = [...parsedNames];
for (let i = names.length + 1; i <= count; i++) {
names.push(`${baseName}${i}`);
}
batchNamesText.value = names.join('\n');
}
}
else {
batchNamesText.value = '';
}
});
// 监听 baseName 变化,当批量创建时自动更新名称
watch(() => localFormData.value.name, (newName) => {
if (batchCount.value > 1 && props.mode === 'create') {
const baseName = newName.trim() || 'API密钥';
const names: string[] = [];
for (let i = 1; i <= batchCount.value; i++) {
names.push(`${baseName}${i}`);
}
batchNamesText.value = names.join('\n');
}
});
// 移动端检测
const isMobile = ref(false);
@@ -117,6 +209,8 @@ watch(() => props.visible, (newVal) => {
premiumQuotaLimit: displayValue,
quotaUnit: unit,
};
// 编辑模式禁用批量创建
batchCount.value = 1;
}
else {
// 新增模式:重置表单
@@ -128,6 +222,7 @@ watch(() => props.visible, (newVal) => {
};
neverExpire.value = false;
unlimitedQuota.value = false;
batchCount.value = 1;
}
submitting.value = false;
}
@@ -156,6 +251,40 @@ function handleClose() {
// 确认提交
async function handleConfirm() {
// 批量创建模式
if (props.mode === 'create' && batchCount.value > 1) {
if (!neverExpire.value && !localFormData.value.expireTime) {
ElMessage.warning('请选择过期时间');
return;
}
if (!unlimitedQuota.value && localFormData.value.premiumQuotaLimit <= 0) {
ElMessage.warning('请输入有效的配额限制');
return;
}
// 构建最终的名称列表
const finalNames = buildFinalNames();
// 检查是否有重复名称
const uniqueNames = new Set(finalNames);
if (uniqueNames.size !== finalNames.length) {
ElMessage.warning('存在重复的API密钥名称请检查');
return;
}
// 显示预览对话框
previewNames.value = finalNames;
// 初始化状态列表为 pending显示在预览对话框中
createStatusList.value = finalNames.map(name => ({
name,
status: 'pending' as CreateStatus,
}));
showPreviewDialog.value = true;
return;
}
// 单个创建或编辑模式
if (!localFormData.value.name.trim()) {
ElMessage.warning('请输入API密钥名称');
return;
@@ -194,7 +323,116 @@ async function handleConfirm() {
}
}
// 预览对话框确认提交
async function handlePreviewConfirm() {
if (!neverExpire.value && !localFormData.value.expireTime) {
ElMessage.warning('请选择过期时间');
return;
}
if (!unlimitedQuota.value && localFormData.value.premiumQuotaLimit <= 0) {
ElMessage.warning('请输入有效的配额限制');
return;
}
// 先初始化创建状态为 pending
createStatusList.value = previewNames.value.map(name => ({
name,
status: 'pending' as CreateStatus,
}));
// 然后设置为创建中状态,触发 UI 更新显示状态列
isCreating.value = true;
// 将展示值转换为实际值
let actualQuota = null;
if (!unlimitedQuota.value) {
const unit = quotaUnitOptions.find(u => u.value === localFormData.value.quotaUnit);
actualQuota = localFormData.value.premiumQuotaLimit * (unit?.multiplier || 1);
}
// 生成多个提交数据
const submitDataList: TokenFormData[] = previewNames.value.map(name => ({
...localFormData.value,
name,
expireTime: neverExpire.value ? '' : localFormData.value.expireTime,
premiumQuotaLimit: actualQuota,
}));
// 进度回调函数
const onProgress = (index: number, status: CreateStatus, error?: string) => {
if (createStatusList.value[index]) {
createStatusList.value[index].status = status;
if (error) {
createStatusList.value[index].error = error;
}
}
};
emit('batchCreate', submitDataList, onProgress);
}
// 预览对话框取消
function handlePreviewCancel() {
if (isCreating.value)
return;
showPreviewDialog.value = false;
previewNames.value = [];
createStatusList.value = [];
// 同时关闭主表单对话框
emit('update:visible', false);
}
// 完成批量创建(由父组件调用)
function completeBatchCreate() {
isCreating.value = false;
showPreviewDialog.value = false;
previewNames.value = [];
createStatusList.value = [];
submitting.value = false;
}
// 暴露给父组件的方法
defineExpose({
completeBatchCreate,
});
// 获取创建进度统计
const createProgress = computed(() => {
const total = createStatusList.value.length;
const success = createStatusList.value.filter(s => s.status === 'success').length;
const failed = createStatusList.value.filter(s => s.status === 'failed').length;
const pending = createStatusList.value.filter(s => s.status === 'pending' || s.status === 'creating').length;
return { total, success, failed, pending };
});
// 获取预览对话框标题
const previewDialogTitle = computed(() => {
if (isCreating.value) {
const { success, failed, pending } = createProgress.value;
if (pending > 0) {
return `创建中... (${success}/${createProgress.value.total})`;
}
return `创建完成`;
}
return '批量创建预览';
});
const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥' : '编辑 API密钥');
// 格式化日期时间
function formatDateTime(dateStr: string | null | undefined) {
if (!dateStr)
return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<template>
@@ -223,7 +461,42 @@ const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥'
</el-input>
</el-form-item>
<el-form-item label="过期时间">
<!-- 批量创建功能仅新增模式 -->
<el-form-item v-if="mode === 'create'" label="批量创建">
<el-input-number
v-model="batchCount"
:min="1"
:max="MAX_BATCH_CREATE_COUNT"
:precision="0"
controls-position="right"
placeholder="数量"
:disabled="submitting"
/>
<div class="form-hint">
<el-icon><i-ep-info-filled /></el-icon>
选择创建数量最多支持 {{ MAX_BATCH_CREATE_COUNT }}
</div>
</el-form-item>
<!-- 批量名称输入当数量 > 1 时显示 -->
<el-form-item v-if="mode === 'create' && batchCount > 1" label="批量名称列表" required>
<el-input
v-model="batchNamesText"
type="textarea"
:rows="Math.min(batchCount + 1, 10)"
placeholder="每行一个API密钥名称以换行分隔"
maxlength="1000"
show-word-limit
clearable
:disabled="submitting"
/>
<div class="form-hint">
<el-icon><i-ep-info-filled /></el-icon>
<span>按换行符识别名称自动去除空行"创建数量"为准不足时自动补齐多余时取前 {{ batchCount }} </span>
</div>
</el-form-item>
<el-form-item label="过期时间" :required="!neverExpire">
<div class="form-item-with-switch">
<el-switch
v-model="neverExpire"
@@ -326,6 +599,146 @@ const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥'
</div>
</template>
</el-dialog>
<!-- 批量创建预览对话框 -->
<el-dialog
v-model="showPreviewDialog"
:title="previewDialogTitle"
:width="isMobile ? '95%' : '600px'"
:fullscreen="isMobile"
:close-on-click-modal="false"
:show-close="!isCreating"
@close="handlePreviewCancel"
>
<div class="preview-container">
<!-- 创建进度提示 -->
<div v-if="isCreating" class="preview-progress-header">
<div class="progress-summary">
<el-icon v-if="createProgress.pending > 0" class="is-loading">
<i-ep-loading />
</el-icon>
<el-icon v-else-if="createProgress.failed > 0">
<i-ep-circle-close />
</el-icon>
<el-icon v-else>
<i-ep-circle-check />
</el-icon>
<span>
<template v-if="createProgress.pending > 0">正在创建中...</template>
<template v-else-if="createProgress.failed > 0">部分创建失败</template>
<template v-else>全部创建成功</template>
</span>
</div>
<el-progress
:percentage="createProgress.total > 0 ? Math.round((createProgress.success + createProgress.failed) / createProgress.total * 100) : 0"
:status="createProgress.pending > 0 ? undefined : (createProgress.failed > 0 ? 'exception' : 'success')"
:stroke-width="8"
/>
</div>
<!-- 创建中时的简化显示 -->
<div v-if="isCreating" class="preview-names-list preview-names-list-compact">
<div
v-for="(item, index) in createStatusList"
:key="index"
class="preview-name-item preview-name-item-compact"
:class="`status-${item.status}`"
>
<span class="preview-name-index">{{ index + 1 }}</span>
<span class="preview-name-text">{{ item.name }}</span>
<span class="preview-name-status">
<el-icon v-if="item.status === 'creating'" class="is-loading"><i-ep-loading /></el-icon>
<el-icon v-else-if="item.status === 'success'"><i-ep-circle-check /></el-icon>
<el-icon v-else-if="item.status === 'failed'"><i-ep-circle-close /></el-icon>
<el-icon v-else><i-ep-clock /></el-icon>
<span class="preview-name-status-text">
<template v-if="item.status === 'pending'">待创建</template>
<template v-else-if="item.status === 'creating'">创建中</template>
<template v-else-if="item.status === 'success'">成功</template>
<template v-else-if="item.status === 'failed'">失败</template>
</span>
</span>
</div>
</div>
<!-- 预览确认时的完整显示 -->
<template v-else>
<div class="preview-header">
<el-icon><i-ep-warning /></el-icon>
<span>即将创建 {{ previewNames.length }} 个 API 密钥,请确认以下信息:</span>
</div>
<!-- 配额信息 -->
<div class="preview-section">
<div class="preview-section-title">
配额设置
</div>
<div class="preview-section-content">
<span v-if="unlimitedQuota" class="preview-tag preview-tag-success">无限制</span>
<span v-else class="preview-tag preview-tag-info">
{{ localFormData.premiumQuotaLimit }} {{ localFormData.quotaUnit }}
</span>
<span v-if="neverExpire" class="preview-tag preview-tag-success">永不过期</span>
<span v-else class="preview-tag preview-tag-warning">
过期时间:{{ formatDateTime(localFormData.expireTime) }}
</span>
</div>
</div>
<!-- 名称列表 -->
<div class="preview-section">
<div class="preview-section-title">
API 密钥名称列表
</div>
<div class="preview-names-list">
<div
v-for="(item, index) in createStatusList"
:key="index"
class="preview-name-item"
:class="`status-${item.status}`"
>
<span class="preview-name-index">{{ index + 1 }}</span>
<span class="preview-name-text">{{ item.name }}</span>
<span class="preview-name-status">
<el-icon v-if="item.status === 'creating'" class="is-loading"><i-ep-loading /></el-icon>
<el-icon v-else-if="item.status === 'success'"><i-ep-circle-check /></el-icon>
<el-icon v-else-if="item.status === 'failed'"><i-ep-circle-close /></el-icon>
<el-icon v-else><i-ep-clock /></el-icon>
<span class="preview-name-status-text">
<template v-if="item.status === 'pending'">待创建</template>
<template v-else-if="item.status === 'creating'">创建中</template>
<template v-else-if="item.status === 'success'">成功</template>
<template v-else-if="item.status === 'failed'">失败</template>
</span>
</span>
</div>
</div>
</div>
</template>
</div>
<template #footer>
<div class="dialog-footer">
<el-button :disabled="isCreating" @click="handlePreviewCancel">
取消
</el-button>
<el-button
v-if="!isCreating"
type="primary"
@click="handlePreviewConfirm"
>
确认创建
</el-button>
<el-button
v-else
type="success"
@click="handlePreviewCancel"
>
完成
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
@@ -362,9 +775,8 @@ const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥'
.form-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
//gap: 6px;
margin: 2px 2px;
font-size: 13px;
color: #606266;
background: #f4f4f5;
@@ -413,4 +825,254 @@ const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥'
:deep(.el-input__prefix) {
color: #909399;
}
// 预览对话框样式
.preview-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.preview-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 8px;
color: #fa8c16;
font-size: 14px;
font-weight: 500;
.el-icon {
font-size: 20px;
flex-shrink: 0;
}
}
.preview-section {
background: #fafafa;
border-radius: 8px;
padding: 16px;
}
.preview-section-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin-bottom: 12px;
}
.preview-section-content {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.preview-tag {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
&.preview-tag-success {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
&.preview-tag-info {
background: #e6f7ff;
color: #1890ff;
border: 1px solid #91d5ff;
}
&.preview-tag-warning {
background: #fff7e6;
color: #fa8c16;
border: 1px solid #ffd591;
}
}
.preview-names-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.preview-name-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
transition: all 0.2s;
&:hover {
border-color: #1890ff;
background: #f0f7ff;
}
// 状态样式
&.status-pending {
border-color: #d9d9d9;
background: #fafafa;
.preview-name-status .el-icon {
color: #8c8c8c;
}
}
&.status-creating {
border-color: #1890ff;
background: #e6f7ff;
.preview-name-status .el-icon {
color: #1890ff;
}
}
&.status-success {
border-color: #52c41a;
background: #f6ffed;
.preview-name-status .el-icon {
color: #52c41a;
}
}
&.status-failed {
border-color: #ff4d4f;
background: #fff1f0;
.preview-name-status .el-icon {
color: #ff4d4f;
}
}
}
.preview-name-index {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #1890ff;
color: #fff;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.preview-name-text {
font-size: 14px;
color: #262626;
font-weight: 500;
flex: 1;
}
// 进度相关样式
.preview-progress-header {
padding: 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 8px;
}
.progress-summary {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
color: #52c41a;
margin-bottom: 12px;
.el-icon {
font-size: 18px;
}
}
.preview-names-list-compact {
max-height: 400px;
}
.preview-name-item-compact {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
&.status-creating {
border-color: #1890ff;
background: #e6f7ff;
}
&.status-success {
border-color: #52c41a;
background: #f6ffed;
}
&.status-failed {
border-color: #ff4d4f;
background: #fff1f0;
}
}
.preview-name-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
.el-icon {
color: #8c8c8c;
&:is(.is-loading) {
color: #1890ff;
}
}
.status-creating & .el-icon {
color: #1890ff;
}
.status-success & .el-icon {
color: #52c41a;
}
.status-failed & .el-icon {
color: #ff4d4f;
}
}
.preview-name-status-text {
font-size: 13px;
color: #8c8c8c;
font-weight: 500;
.status-pending & {
color: #8c8c8c;
}
.status-creating & {
color: #1890ff;
}
.status-success & {
color: #52c41a;
}
.status-failed & {
color: #ff4d4f;
}
}
</style>

View File

@@ -6,7 +6,7 @@
*/
// 主版本号 - 修改此处即可同步更新所有地方的版本显示
export const APP_VERSION = '3.7.0';
export const APP_VERSION = '3.7.1';
// 应用名称
export const APP_NAME = '意心AI';

View File

@@ -33,7 +33,8 @@ onMounted(() => {
const currentSessionRes = await get_session(`${sessionId.value}`);
sessionStore.setCurrentSession(currentSessionRes.data);
}
} finally {
}
finally {
isLoading.value = false;
}
};
@@ -41,7 +42,8 @@ onMounted(() => {
// 优先使用 requestIdleCallback如果不支持则使用 setTimeout
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => loadData(), { timeout: 1000 });
} else {
}
else {
setTimeout(loadData, 100);
}
});
@@ -234,7 +236,7 @@ function toggleSidebar() {
:load-more="handleLoadMore"
:load-more-loading="loadMoreLoading"
:items-style="{
marginLeft: '8px',
marginLeft: isCollapsed ? '8px' : '5px',
marginRight: '8px',
userSelect: 'none',
borderRadius: isCollapsed ? '12px' : '10px',
@@ -246,14 +248,18 @@ function toggleSidebar() {
flexDirection: isCollapsed ? 'column' : 'row',
position: 'relative',
overflow: 'hidden',
borderLeft: '3px solid transparent',
}"
:items-active-style="{
backgroundColor: '#fff',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
color: 'rgba(0, 0, 0, 0.85)',
backgroundColor: 'rgba(0, 87, 255, 0.08)',
boxShadow: '0 2px 8px rgba(0, 87, 255, 0.12)',
color: '#0057ff',
borderLeft: '3px solid #0057ff',
fontWeight: '500',
}"
:items-hover-style="{
backgroundColor: 'rgba(0, 0, 0, 0.04)',
borderLeft: '3px solid transparent',
}"
@menu-command="handleMenuCommand"
@change="handleChange"
@@ -552,8 +558,9 @@ function toggleSidebar() {
.collapsed-menu-trigger {
position: absolute;
bottom: 4px;
right: 4px;
top: 50%;
right: 2px;
transform: translateY(-50%);
width: 20px;
height: 20px;
border-radius: 50%;
@@ -589,6 +596,9 @@ function toggleSidebar() {
min-width: 0;
.conversation-info {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
min-width: 0;
margin-right: 8px;
@@ -656,14 +666,24 @@ function toggleSidebar() {
align-items: center !important;
}
& {
display: flex !important;
align-items: center !important;
}
&-content {
flex: 1 !important;
min-width: 0 !important;
max-width: calc(100% - 32px) !important;
width: 100% !important;
overflow: hidden !important;
box-sizing: border-box !important;
}
.conversation-content {
width: 100% !important;
}
// 确保操作按钮区域在展开状态下正常显示
&-actions {
flex-shrink: 0 !important;
@@ -718,6 +738,36 @@ function toggleSidebar() {
&-content {
max-width: 100% !important;
}
.conversation-info {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: 0 36px;
.conversation-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
line-height: 1.4;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.conversation-preview {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
}
}
}

View File

@@ -2,20 +2,22 @@
<script setup lang="ts">
import type { AnyObject } from 'typescript-api-pro';
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
import { Loading } from '@element-plus/icons-vue';
import type { MessageItem as MessageItemType } from '@/composables/chat';
import type { UnifiedMessage } from '@/utils/apiFormatConverter';
import { Bottom, Loading } from '@element-plus/icons-vue';
import { ElIcon, ElMessage } from 'element-plus';
import { useHookFetch } from 'hook-fetch/vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { Sender } from 'vue-element-plus-x';
import { useRoute } from 'vue-router';
import { deleteMessages, unifiedSend } from '@/api';
import { useFilePaste } from '@/composables/chat';
import { ChatHeader, DeleteModeToolbar, MessageItem } from '@/pages/chat/components';
import { useChatStore, useUserStore } from '@/stores';
import { useFilesStore } from '@/stores/modules/files';
import { useModelStore } from '@/stores/modules/model';
import { useSessionStore } from '@/stores/modules/session';
import { convertToApiFormat, parseStreamChunk, type UnifiedMessage } from '@/utils/apiFormatConverter';
import { useFilePaste, type MessageItem as MessageItemType } from '@/composables/chat';
import { convertToApiFormat, parseStreamChunk } from '@/utils/apiFormatConverter';
import '@/styles/github-markdown.css';
import '@/styles/yixin-markdown.scss';
@@ -34,6 +36,12 @@ const bubbleItems = ref<MessageItemType[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null);
const isSending = ref(false);
// 智能滚动相关状态
const isUserAtBottom = ref(true);
const scrollContainer = ref<HTMLElement | null>(null);
const SCROLL_THRESHOLD = 150; // 距离底部多少像素视为"在底部"
let scrollUpdateTimer: number | null = null;
// 删除模式相关状态
const isDeleteMode = ref(false);
const selectedMessageIds = ref<(number | string)[]>([]);
@@ -71,7 +79,7 @@ const { handlePaste } = useFilePaste({
});
return total;
},
addFiles: (files) => filesStore.setFilesList([...filesStore.filesList, ...files]),
addFiles: files => filesStore.setFilesList([...filesStore.filesList, ...files]),
});
// 创建统一发送请求的包装函数
@@ -129,7 +137,58 @@ watch(
);
function scrollToBottom() {
setTimeout(() => bubbleListRef.value?.scrollToBottom(), 350);
setTimeout(() => {
if (bubbleListRef.value) {
bubbleListRef.value.scrollToBottom();
checkIfAtBottom();
}
}, 350);
}
/**
* 检测用户是否在底部(智能滚动核心)
*/
function checkIfAtBottom() {
const container = scrollContainer.value;
if (!container) {
isUserAtBottom.value = true;
return;
}
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
isUserAtBottom.value = distanceFromBottom < SCROLL_THRESHOLD;
}
/**
* 智能滚动到底部(仅在用户已在底部时滚动)
*/
function smartScrollToBottom(force = false) {
if (!scrollContainer.value)
return;
// 强制滚动或用户已在底部时才滚动
if (force || isUserAtBottom.value) {
const targetScroll = scrollContainer.value.scrollHeight - scrollContainer.value.clientHeight;
scrollContainer.value.scrollTo({
top: targetScroll,
behavior: 'smooth',
});
isUserAtBottom.value = true;
}
}
/**
* 处理滚动事件,检测用户位置
*/
function handleScroll() {
// 使用节流优化性能
if (scrollUpdateTimer !== null) {
clearTimeout(scrollUpdateTimer);
}
scrollUpdateTimer = window.setTimeout(() => {
checkIfAtBottom();
}, 100);
}
/**
@@ -177,6 +236,8 @@ function handleDataChunk(chunk: AnyObject) {
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += parsed.reasoning_content;
// 流式更新时智能滚动
nextTick(() => smartScrollToBottom());
}
// 处理普通内容
@@ -184,21 +245,28 @@ function handleDataChunk(chunk: AnyObject) {
const thinkStart = parsed.content.includes('<think>');
const thinkEnd = parsed.content.includes('</think>');
if (thinkStart) isThinking = true;
if (thinkEnd) isThinking = false;
if (thinkStart)
isThinking = true;
if (thinkEnd)
isThinking = false;
if (isThinking) {
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += parsed.content.replace('<think>', '').replace('</think>', '');
} else {
nextTick(() => smartScrollToBottom());
}
else {
latest.thinkingStatus = 'end';
latest.loading = false;
latest.content += parsed.content;
// 流式更新时智能滚动
nextTick(() => smartScrollToBottom());
}
}
} catch (err) {
}
catch (err) {
console.error('解析数据时出错:', err);
}
}
@@ -207,7 +275,8 @@ function handleDataChunk(chunk: AnyObject) {
* 发送消息并处理流式响应
*/
async function startSSE(chatContent: string) {
if (isSending.value) return;
if (isSending.value)
return;
// 检查是否有未上传完成的文件
const hasUnuploadedFiles = filesStore.filesList.some(f => !f.isUploaded);
@@ -254,13 +323,16 @@ async function startSSE(chatContent: string) {
modelId: modelStore.currentModelInfo.modelId ?? '',
sessionId,
})) {
console.log('111');
handleDataChunk(chunk.result as AnyObject);
}
} catch (err: any) {
}
catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Fetch error:', err);
}
} finally {
}
finally {
finishSending();
}
}
@@ -289,7 +361,8 @@ function buildMessagesContent(imageFiles: any[], textFiles: any[]) {
fileContent += `<FILE_NAME>${fileItem.name}</FILE_NAME>\n`;
fileContent += `<FILE_CONTENT>\n${fileItem.fileContent}\n</FILE_CONTENT>\n`;
fileContent += `</ATTACHMENT_FILE>\n`;
if (idx < textFiles.length - 1) fileContent += '\n';
if (idx < textFiles.length - 1)
fileContent += '\n';
});
contentArray.push({ type: 'text', text: fileContent });
}
@@ -307,7 +380,8 @@ function buildMessagesContent(imageFiles: any[], textFiles: any[]) {
baseMessage.content = contentArray.length > 1 || imageFiles.length > 0 || textFiles.length > 0
? contentArray
: item.content;
} else {
}
else {
baseMessage.content = (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
: item.content;
@@ -390,11 +464,13 @@ function exitDeleteMode() {
}
function toggleMessageSelection(item: MessageItemType) {
if (!item.id) return;
if (!item.id)
return;
const index = selectedMessageIds.value.indexOf(item.id);
if (index > -1) {
selectedMessageIds.value.splice(index, 1);
} else {
}
else {
selectedMessageIds.value.push(item.id);
}
}
@@ -414,7 +490,8 @@ async function confirmDelete() {
}
bubbleItems.value = bubbleItems.value.filter((item) => {
if (item.id === undefined) return true;
if (item.id === undefined)
return true;
return !savedIds.includes(item.id) && !tempIds.includes(item.id as number);
});
@@ -425,7 +502,8 @@ async function confirmDelete() {
ElMessage.success('删除成功');
exitDeleteMode();
} catch (error) {
}
catch (error) {
ElMessage.error('删除失败');
}
}
@@ -443,10 +521,12 @@ function cancelEdit() {
}
async function submitEditMessage(item: MessageItemType) {
if (isSending.value || !editingContent.value.trim()) return;
if (isSending.value || !editingContent.value.trim())
return;
const itemIndex = bubbleItems.value.findIndex(msg => msg.key === item.key);
if (itemIndex === -1) return;
if (itemIndex === -1)
return;
const messageId = item.id;
const newContent = editingContent.value.trim();
@@ -459,7 +539,8 @@ async function submitEditMessage(item: MessageItemType) {
bubbleItems.value = bubbleItems.value.slice(0, itemIndex);
await startSSE(newContent);
} catch (error) {
}
catch (error) {
ElMessage.error('编辑失败');
}
}
@@ -473,10 +554,12 @@ function copy(item: MessageItemType) {
}
async function regenerateMessage(item: MessageItemType) {
if (isSending.value) return;
if (isSending.value)
return;
const itemIndex = bubbleItems.value.findIndex(msg => msg.key === item.key);
if (itemIndex === -1) return;
if (itemIndex === -1)
return;
// 找到对应的用户消息
let targetUserMessageIndex = -1;
@@ -502,7 +585,8 @@ async function regenerateMessage(item: MessageItemType) {
bubbleItems.value = bubbleItems.value.slice(0, targetUserMessageIndex);
await startSSE(targetUserMessage.content || '');
} catch (error) {
}
catch (error) {
ElMessage.error('重新生成失败');
}
}
@@ -518,7 +602,8 @@ watch(
nextTick(() => {
if (val > 0) {
senderRef.value?.openHeader();
} else {
}
else {
senderRef.value?.closeHeader();
}
});
@@ -528,10 +613,35 @@ watch(
// 生命周期
onMounted(() => {
document.addEventListener('paste', handlePaste);
// 获取 BubbleList 内部的滚动容器
nextTick(() => {
if (bubbleListRef.value?.$el) {
// 尝试获取 BubbleList 内部的滚动容器
const innerContainer = bubbleListRef.value.$el.querySelector('[style*="overflow"]');
if (innerContainer) {
scrollContainer.value = innerContainer;
scrollContainer.value.addEventListener('scroll', handleScroll);
}
else {
// 如果找不到内部容器,使用 BubbleList 的根元素
scrollContainer.value = bubbleListRef.value.$el;
scrollContainer.value.addEventListener('scroll', handleScroll);
}
}
});
});
onUnmounted(() => {
document.removeEventListener('paste', handlePaste);
// 清理滚动定时器
if (scrollUpdateTimer !== null) {
clearTimeout(scrollUpdateTimer);
}
// 移除滚动事件监听
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll);
}
});
</script>
@@ -551,32 +661,45 @@ onUnmounted(() => {
/>
<!-- 消息列表 -->
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
max-height="calc(100vh - 240px)"
:class="{ 'delete-mode': isDeleteMode }"
>
<template #content="{ item }">
<MessageItem
:item="item"
:is-delete-mode="isDeleteMode"
:is-editing="editingMessageKey === item.key"
:edit-content="editingContent"
:is-sending="isSending"
:is-selected="item.id ? selectedMessageIds.includes(item.id) : false"
@toggle-selection="toggleMessageSelection"
@edit="startEditMessage"
@cancel-edit="cancelEdit"
@submit-edit="submitEditMessage"
@update:edit-content="editingContent = $event"
@copy="copy"
@regenerate="regenerateMessage"
@delete="enterDeleteMode"
@image-preview="handleImagePreview"
/>
</template>
</BubbleList>
<div class="chat-with-id__messages-wrapper">
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
max-height="calc(100vh - 240px)"
:class="{ 'delete-mode': isDeleteMode }"
>
<template #content="{ item }">
<MessageItem
:item="item"
:is-delete-mode="isDeleteMode"
:is-editing="editingMessageKey === item.key"
:edit-content="editingContent"
:is-sending="isSending"
:is-selected="item.id ? selectedMessageIds.includes(item.id) : false"
@toggle-selection="toggleMessageSelection"
@edit="startEditMessage"
@cancel-edit="cancelEdit"
@submit-edit="submitEditMessage"
@update:edit-content="editingContent = $event"
@copy="copy"
@regenerate="regenerateMessage"
@delete="enterDeleteMode"
@image-preview="handleImagePreview"
/>
</template>
</BubbleList>
<!-- 滚动到底部按钮 -->
<Transition name="scroll-btn-fade">
<button
v-show="!isUserAtBottom"
class="chat-with-id__scroll-bottom-btn"
@click="smartScrollToBottom(true)"
>
<ElIcon><Bottom /></ElIcon>
</button>
</Transition>
</div>
<!-- 发送器 -->
<Sender
@@ -689,13 +812,148 @@ onUnmounted(() => {
color: var(--el-color-primary);
animation: rotating 2s linear infinite;
}
// ==================== 消息包装器样式 ====================
&__messages-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden; // 移除滚动,让 BubbleList 内部处理
}
// ==================== 滚动到底部按钮 ====================
&__scroll-bottom-btn {
position: absolute;
bottom: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
font-size: 18px;
transition: all 0.3s ease;
z-index: 10;
&:hover {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
color: #fff;
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
&:active {
transform: scale(0.95);
}
@media (max-width: 768px) {
width: 36px;
height: 36px;
bottom: 16px;
right: 16px;
font-size: 16px;
}
}
}
// 气泡列表基础样式覆盖
// 气泡列表基础样式覆盖 - 细滚动条一直显示
:deep(.el-bubble-list) {
padding-top: 0;
}
// ==================== 细滚动条样式 ====================
// 对所有可能的滚动容器应用细滚动条样式
:deep(*) {
// 匹配具有overflow样式的元素
&[style*="overflow"],
&[style*="overflow: auto"],
&[style*="overflow-y: auto"],
&[style*="overflow: scroll"],
&[style*="overflow-y: scroll"] {
// Webkit 浏览器滚动条样式 (Chrome, Safari, Edge)
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(144, 147, 153, 0.3);
border-radius: 3px;
transition: background 0.2s ease;
&:hover {
background: rgba(144, 147, 153, 0.5);
}
}
// Firefox 滚动条样式
scrollbar-width: thin;
scrollbar-color: rgba(144, 147, 153, 0.3) transparent;
}
}
// 同时对 .el-bubble-list 的子元素应用滚动条样式
:deep(.el-bubble-list) {
// Webkit 浏览器滚动条样式 (Chrome, Safari, Edge)
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(144, 147, 153, 0.3);
border-radius: 3px;
transition: background 0.2s ease;
&:hover {
background: rgba(144, 147, 153, 0.5);
}
}
// Firefox 滚动条样式
scrollbar-width: thin;
scrollbar-color: rgba(144, 147, 153, 0.3) transparent;
// 对所有子元素也应用滚动条样式
* {
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(144, 147, 153, 0.3);
border-radius: 3px;
transition: background 0.2s ease;
&:hover {
background: rgba(144, 147, 153, 0.5);
}
}
scrollbar-width: thin;
scrollbar-color: rgba(144, 147, 153, 0.3) transparent;
}
}
:deep(.el-bubble) {
padding: 0;
width: 100% !important;
@@ -752,4 +1010,20 @@ onUnmounted(() => {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// ==================== 滚动按钮过渡动画 ====================
.scroll-btn-fade-enter-active,
.scroll-btn-fade-leave-active {
transition: all 0.3s ease;
}
.scroll-btn-fade-enter-from,
.scroll-btn-fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
// 对话列表底部三个点加载动画
:deep(.el-bubble-loading-wrap ){
height: 50px;
}
</style>

View File

@@ -592,7 +592,7 @@ onMounted(() => {
{{ model.modelTypeName }}
</el-tag>
<el-tag v-for="item in model.modelApiTypes" :key="item" size="small">
{{ item.modelApiTypeName }}
API类型 {{ item.modelApiTypeName }}
</el-tag>
</div>
<div class="model-pricing">