Merge branch 'ai-agent' into ai-hub

# Conflicts:
#	Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs
This commit is contained in:
ccnetcore
2026-01-03 21:31:09 +08:00
126 changed files with 11429 additions and 1639 deletions

View File

@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// 创建AI应用输入
/// </summary>
public class AiAppCreateInput
{
/// <summary>
/// 应用名称
/// </summary>
[Required(ErrorMessage = "应用名称不能为空")]
[StringLength(100, ErrorMessage = "应用名称不能超过100个字符")]
public string Name { get; set; }
/// <summary>
/// 应用终结点
/// </summary>
[Required(ErrorMessage = "应用终结点不能为空")]
[StringLength(500, ErrorMessage = "应用终结点不能超过500个字符")]
public string Endpoint { get; set; }
/// <summary>
/// 额外URL
/// </summary>
[StringLength(500, ErrorMessage = "额外URL不能超过500个字符")]
public string? ExtraUrl { get; set; }
/// <summary>
/// 应用Key
/// </summary>
[Required(ErrorMessage = "应用Key不能为空")]
[StringLength(500, ErrorMessage = "应用Key不能超过500个字符")]
public string ApiKey { get; set; }
/// <summary>
/// 排序
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "排序必须大于等于0")]
public int OrderNum { get; set; }
}

View File

@@ -0,0 +1,42 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// AI应用DTO
/// </summary>
public class AiAppDto
{
/// <summary>
/// 应用ID
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 应用名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 应用终结点
/// </summary>
public string Endpoint { get; set; }
/// <summary>
/// 额外URL
/// </summary>
public string? ExtraUrl { get; set; }
/// <summary>
/// 应用Key
/// </summary>
public string ApiKey { get; set; }
/// <summary>
/// 排序
/// </summary>
public int OrderNum { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreationTime { get; set; }
}

View File

@@ -0,0 +1,14 @@
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// 获取AI应用列表输入
/// </summary>
public class AiAppGetListInput : PagedAllResultRequestDto
{
/// <summary>
/// 搜索关键词(搜索应用名称)
/// </summary>
public string? SearchKey { get; set; }
}

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// 更新AI应用输入
/// </summary>
public class AiAppUpdateInput
{
/// <summary>
/// 应用ID
/// </summary>
[Required(ErrorMessage = "应用ID不能为空")]
public Guid Id { get; set; }
/// <summary>
/// 应用名称
/// </summary>
[Required(ErrorMessage = "应用名称不能为空")]
[StringLength(100, ErrorMessage = "应用名称不能超过100个字符")]
public string Name { get; set; }
/// <summary>
/// 应用终结点
/// </summary>
[Required(ErrorMessage = "应用终结点不能为空")]
[StringLength(500, ErrorMessage = "应用终结点不能超过500个字符")]
public string Endpoint { get; set; }
/// <summary>
/// 额外URL
/// </summary>
[StringLength(500, ErrorMessage = "额外URL不能超过500个字符")]
public string? ExtraUrl { get; set; }
/// <summary>
/// 应用Key
/// </summary>
[Required(ErrorMessage = "应用Key不能为空")]
[StringLength(500, ErrorMessage = "应用Key不能超过500个字符")]
public string ApiKey { get; set; }
/// <summary>
/// 排序
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "排序必须大于等于0")]
public int OrderNum { get; set; }
}

View File

@@ -0,0 +1,96 @@
using System.ComponentModel.DataAnnotations;
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// 创建AI模型输入
/// </summary>
public class AiModelCreateInput
{
/// <summary>
/// 处理名
/// </summary>
[Required(ErrorMessage = "处理名不能为空")]
[StringLength(100, ErrorMessage = "处理名不能超过100个字符")]
public string HandlerName { get; set; }
/// <summary>
/// 模型ID
/// </summary>
[Required(ErrorMessage = "模型ID不能为空")]
[StringLength(200, ErrorMessage = "模型ID不能超过200个字符")]
public string ModelId { get; set; }
/// <summary>
/// 模型名称
/// </summary>
[Required(ErrorMessage = "模型名称不能为空")]
[StringLength(200, ErrorMessage = "模型名称不能超过200个字符")]
public string Name { get; set; }
/// <summary>
/// 模型描述
/// </summary>
[StringLength(1000, ErrorMessage = "模型描述不能超过1000个字符")]
public string? Description { get; set; }
/// <summary>
/// 排序
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "排序必须大于等于0")]
public int OrderNum { get; set; }
/// <summary>
/// AI应用ID
/// </summary>
[Required(ErrorMessage = "AI应用ID不能为空")]
public Guid AiAppId { get; set; }
/// <summary>
/// 额外信息
/// </summary>
[StringLength(2000, ErrorMessage = "额外信息不能超过2000个字符")]
public string? ExtraInfo { get; set; }
/// <summary>
/// 模型类型
/// </summary>
[Required(ErrorMessage = "模型类型不能为空")]
public ModelTypeEnum ModelType { get; set; }
/// <summary>
/// 模型API类型
/// </summary>
[Required(ErrorMessage = "模型API类型不能为空")]
public ModelApiTypeEnum ModelApiType { get; set; }
/// <summary>
/// 模型倍率
/// </summary>
[Range(0.01, double.MaxValue, ErrorMessage = "模型倍率必须大于0")]
public decimal Multiplier { get; set; } = 1;
/// <summary>
/// 模型显示倍率
/// </summary>
[Range(0.01, double.MaxValue, ErrorMessage = "模型显示倍率必须大于0")]
public decimal MultiplierShow { get; set; } = 1;
/// <summary>
/// 供应商分组名称
/// </summary>
[StringLength(100, ErrorMessage = "供应商分组名称不能超过100个字符")]
public string? ProviderName { get; set; }
/// <summary>
/// 模型图标URL
/// </summary>
[StringLength(500, ErrorMessage = "模型图标URL不能超过500个字符")]
public string? IconUrl { get; set; }
/// <summary>
/// 是否为尊享模型
/// </summary>
public bool IsPremium { get; set; }
}

View File

@@ -0,0 +1,84 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// AI模型DTO
/// </summary>
public class AiModelDto
{
/// <summary>
/// 模型ID
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 处理名
/// </summary>
public string HandlerName { get; set; }
/// <summary>
/// 模型ID
/// </summary>
public string ModelId { get; set; }
/// <summary>
/// 模型名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 模型描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 排序
/// </summary>
public int OrderNum { get; set; }
/// <summary>
/// AI应用ID
/// </summary>
public Guid AiAppId { get; set; }
/// <summary>
/// 额外信息
/// </summary>
public string? ExtraInfo { get; set; }
/// <summary>
/// 模型类型
/// </summary>
public ModelTypeEnum ModelType { get; set; }
/// <summary>
/// 模型API类型
/// </summary>
public ModelApiTypeEnum ModelApiType { get; set; }
/// <summary>
/// 模型倍率
/// </summary>
public decimal Multiplier { get; set; }
/// <summary>
/// 模型显示倍率
/// </summary>
public decimal MultiplierShow { get; set; }
/// <summary>
/// 供应商分组名称
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// 模型图标URL
/// </summary>
public string? IconUrl { get; set; }
/// <summary>
/// 是否为尊享模型
/// </summary>
public bool IsPremium { get; set; }
}

View File

@@ -0,0 +1,24 @@
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// 获取AI模型列表输入
/// </summary>
public class AiModelGetListInput : PagedAllResultRequestDto
{
/// <summary>
/// 搜索关键词(搜索模型名称、模型ID)
/// </summary>
public string? SearchKey { get; set; }
/// <summary>
/// AI应用ID筛选
/// </summary>
public Guid? AiAppId { get; set; }
/// <summary>
/// 是否只显示尊享模型
/// </summary>
public bool? IsPremiumOnly { get; set; }
}

View File

@@ -0,0 +1,102 @@
using System.ComponentModel.DataAnnotations;
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
/// <summary>
/// 更新AI模型输入
/// </summary>
public class AiModelUpdateInput
{
/// <summary>
/// 模型ID
/// </summary>
[Required(ErrorMessage = "模型ID不能为空")]
public Guid Id { get; set; }
/// <summary>
/// 处理名
/// </summary>
[Required(ErrorMessage = "处理名不能为空")]
[StringLength(100, ErrorMessage = "处理名不能超过100个字符")]
public string HandlerName { get; set; }
/// <summary>
/// 模型ID
/// </summary>
[Required(ErrorMessage = "模型ID不能为空")]
[StringLength(200, ErrorMessage = "模型ID不能超过200个字符")]
public string ModelId { get; set; }
/// <summary>
/// 模型名称
/// </summary>
[Required(ErrorMessage = "模型名称不能为空")]
[StringLength(200, ErrorMessage = "模型名称不能超过200个字符")]
public string Name { get; set; }
/// <summary>
/// 模型描述
/// </summary>
[StringLength(1000, ErrorMessage = "模型描述不能超过1000个字符")]
public string? Description { get; set; }
/// <summary>
/// 排序
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "排序必须大于等于0")]
public int OrderNum { get; set; }
/// <summary>
/// AI应用ID
/// </summary>
[Required(ErrorMessage = "AI应用ID不能为空")]
public Guid AiAppId { get; set; }
/// <summary>
/// 额外信息
/// </summary>
[StringLength(2000, ErrorMessage = "额外信息不能超过2000个字符")]
public string? ExtraInfo { get; set; }
/// <summary>
/// 模型类型
/// </summary>
[Required(ErrorMessage = "模型类型不能为空")]
public ModelTypeEnum ModelType { get; set; }
/// <summary>
/// 模型API类型
/// </summary>
[Required(ErrorMessage = "模型API类型不能为空")]
public ModelApiTypeEnum ModelApiType { get; set; }
/// <summary>
/// 模型倍率
/// </summary>
[Range(0.01, double.MaxValue, ErrorMessage = "模型倍率必须大于0")]
public decimal Multiplier { get; set; }
/// <summary>
/// 模型显示倍率
/// </summary>
[Range(0.01, double.MaxValue, ErrorMessage = "模型显示倍率必须大于0")]
public decimal MultiplierShow { get; set; }
/// <summary>
/// 供应商分组名称
/// </summary>
[StringLength(100, ErrorMessage = "供应商分组名称不能超过100个字符")]
public string? ProviderName { get; set; }
/// <summary>
/// 模型图标URL
/// </summary>
[StringLength(500, ErrorMessage = "模型图标URL不能超过500个字符")]
public string? IconUrl { get; set; }
/// <summary>
/// 是否为尊享模型
/// </summary>
public bool IsPremium { get; set; }
}

View File

@@ -15,7 +15,7 @@ public class AgentSendInput
/// <summary>
/// api密钥Id
/// </summary>
public string Token { get; set; }
public Guid TokenId { get; set; }
/// <summary>
/// 模型id

View File

@@ -5,6 +5,11 @@ namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// </summary>
public class ImageGenerationInput
{
/// <summary>
/// 密钥id
/// </summary>
public Guid? TokenId { get; set; }
/// <summary>
/// 提示词
/// </summary>
@@ -16,7 +21,7 @@ public class ImageGenerationInput
public string ModelId { get; set; } = string.Empty;
/// <summary>
/// 参考图Base64列表可选包含前缀如 data:image/png;base64,...
/// 参考图PrefixBase64列表可选包含前缀如 data:image/png;base64,...
/// </summary>
public List<string>? ReferenceImagesBase64 { get; set; }
public List<string>? ReferenceImagesPrefixBase64 { get; set; }
}

View File

@@ -1,24 +1,26 @@
using Volo.Abp.Application.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary>
/// 图片任务分页查询输入
/// </summary>
public class ImageTaskPageInput
public class ImageMyTaskPageInput: PagedAllResultRequestDto
{
/// <summary>
/// 页码从1开始
/// 提示词
/// </summary>
public int PageIndex { get; set; } = 1;
/// <summary>
/// 每页数量
/// </summary>
public int PageSize { get; set; } = 10;
public string? Prompt { get; set; }
/// <summary>
/// 任务状态筛选(可选)
/// </summary>
public TaskStatusEnum? TaskStatus { get; set; }
/// <summary>
/// 发布状态
/// </summary>
public PublishStatusEnum? PublishStatus { get; set; }
}

View File

@@ -0,0 +1,31 @@
using Volo.Abp.Application.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary>
/// 图片任务分页查询输入
/// </summary>
public class ImagePlazaPageInput: PagedAllResultRequestDto
{
/// <summary>
/// 分类
/// </summary>
public string? Categories { get; set; }
/// <summary>
/// 提示词
/// </summary>
public string? Prompt { get; set; }
/// <summary>
/// 任务状态筛选(可选)
/// </summary>
public TaskStatusEnum? TaskStatus { get; set; }
/// <summary>
/// 用户名
/// </summary>
public string? UserName{ get; set; }
}

View File

@@ -18,19 +18,9 @@ public class ImageTaskOutput
public string Prompt { get; set; } = string.Empty;
/// <summary>
/// 参考图Base64列表
/// 是否匿名
/// </summary>
public List<string>? ReferenceImagesBase64 { get; set; }
/// <summary>
/// 参考图URL列表
/// </summary>
public List<string>? ReferenceImagesUrl { get; set; }
/// <summary>
/// 生成图片Base64包含前缀
/// </summary>
public string? StoreBase64 { get; set; }
public bool IsAnonymous { get; set; }
/// <summary>
/// 生成图片URL
@@ -42,8 +32,35 @@ public class ImageTaskOutput
/// </summary>
public TaskStatusEnum TaskStatus { get; set; }
/// <summary>
/// 发布状态
/// </summary>
public PublishStatusEnum PublishStatus { get; set; }
/// <summary>
/// 分类标签
/// </summary>
[SqlSugar.SugarColumn( IsJson = true)]
public List<string> Categories { get; set; } = new();
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreationTime { get; set; }
/// <summary>
/// 错误信息
/// </summary>
public string? ErrorInfo { get; set; }
/// <summary>
/// 用户名称
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// 用户名称Id
/// </summary>
public Guid? UserId { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary>
/// 发布图片输入
/// </summary>
public class PublishImageInput
{
/// <summary>
/// 是否匿名
/// </summary>
public bool IsAnonymous { get; set; } = false;
/// <summary>
/// 任务ID
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 分类标签
/// </summary>
public List<string> Categories { get; set; } = new();
}

View File

@@ -6,13 +6,7 @@ public class ModelGetListOutput
/// 模型ID
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 模型分类
/// </summary>
public string Category { get; set; }
/// <summary>
/// 模型id
/// </summary>
@@ -28,36 +22,6 @@ public class ModelGetListOutput
/// </summary>
public string? ModelDescribe { get; set; }
/// <summary>
/// 模型价格
/// </summary>
public double ModelPrice { get; set; }
/// <summary>
/// 模型类型
/// </summary>
public string ModelType { get; set; }
/// <summary>
/// 模型展示状态
/// </summary>
public string ModelShow { get; set; }
/// <summary>
/// 系统提示
/// </summary>
public string SystemPrompt { get; set; }
/// <summary>
/// API 主机地址
/// </summary>
public string ApiHost { get; set; }
/// <summary>
/// API 密钥
/// </summary>
public string ApiKey { get; set; }
/// <summary>
/// 备注信息
/// </summary>

View File

@@ -0,0 +1,86 @@
using Volo.Abp.Application.Dtos;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
/// <summary>
/// 渠道商管理服务接口
/// </summary>
public interface IChannelService
{
#region AI应用管理
/// <summary>
/// 获取AI应用列表
/// </summary>
/// <param name="input">查询参数</param>
/// <returns>分页应用列表</returns>
Task<PagedResultDto<AiAppDto>> GetAppListAsync(AiAppGetListInput input);
/// <summary>
/// 根据ID获取AI应用
/// </summary>
/// <param name="id">应用ID</param>
/// <returns>应用详情</returns>
Task<AiAppDto> GetAppByIdAsync(Guid id);
/// <summary>
/// 创建AI应用
/// </summary>
/// <param name="input">创建输入</param>
/// <returns>创建的应用</returns>
Task<AiAppDto> CreateAppAsync(AiAppCreateInput input);
/// <summary>
/// 更新AI应用
/// </summary>
/// <param name="input">更新输入</param>
/// <returns>更新后的应用</returns>
Task<AiAppDto> UpdateAppAsync(AiAppUpdateInput input);
/// <summary>
/// 删除AI应用
/// </summary>
/// <param name="id">应用ID</param>
Task DeleteAppAsync(Guid id);
#endregion
#region AI模型管理
/// <summary>
/// 获取AI模型列表
/// </summary>
/// <param name="input">查询参数</param>
/// <returns>分页模型列表</returns>
Task<PagedResultDto<AiModelDto>> GetModelListAsync(AiModelGetListInput input);
/// <summary>
/// 根据ID获取AI模型
/// </summary>
/// <param name="id">模型ID</param>
/// <returns>模型详情</returns>
Task<AiModelDto> GetModelByIdAsync(Guid id);
/// <summary>
/// 创建AI模型
/// </summary>
/// <param name="input">创建输入</param>
/// <returns>创建的模型</returns>
Task<AiModelDto> CreateModelAsync(AiModelCreateInput input);
/// <summary>
/// 更新AI模型
/// </summary>
/// <param name="input">更新输入</param>
/// <returns>更新后的模型</returns>
Task<AiModelDto> UpdateModelAsync(AiModelUpdateInput input);
/// <summary>
/// 删除AI模型(软删除)
/// </summary>
/// <param name="id">模型ID</param>
Task DeleteModelAsync(Guid id);
#endregion
}

View File

@@ -30,32 +30,94 @@ public class ImageGenerationJob : AsyncBackgroundJob<ImageGenerationJobArgs>, IT
public override async Task ExecuteAsync(ImageGenerationJobArgs args)
{
_logger.LogInformation("开始执行图片生成任务TaskId: {TaskId}, ModelId: {ModelId}, UserId: {UserId}",
args.TaskId, args.ModelId, args.UserId);
var task = await _imageStoreTaskRepository.GetFirstAsync(x => x.Id == args.TaskId);
if (task is null)
{
throw new UserFriendlyException($"{args.TaskId} 图片生成任务不存在");
}
_logger.LogInformation("开始执行图片生成任务TaskId: {TaskId}, ModelId: {ModelId}, UserId: {UserId}",
task.Id, task.ModelId, task.UserId);
try
{
var request = JsonSerializer.Deserialize<JsonElement>(args.RequestJson);
// 构建 Gemini API 请求对象
var parts = new List<object>
{
new { text = task.Prompt }
};
// 添加参考图(如果有)
foreach (var prefixBase64 in task.ReferenceImagesPrefixBase64)
{
var (mimeType, base64Data) = ParsePrefixBase64(prefixBase64);
parts.Add(new
{
inline_data = new
{
mime_type = mimeType,
data = base64Data
}
});
}
var requestObj = new
{
contents = new[]
{
new { role = "user", parts }
}
};
var request = JsonSerializer.Deserialize<JsonElement>(
JsonSerializer.Serialize(requestObj));
//里面生成成功已经包含扣款了
await _aiGateWayManager.GeminiGenerateContentImageForStatisticsAsync(
args.TaskId,
args.ModelId,
task.Id,
task.ModelId,
request,
args.UserId);
task.UserId,
tokenId: task.TokenId);
_logger.LogInformation("图片生成任务完成TaskId: {TaskId}", args.TaskId);
}
catch (Exception ex)
{
_logger.LogError(ex, "图片生成任务失败TaskId: {TaskId}, Error: {Error}", args.TaskId, ex.Message);
var error = $"图片任务失败TaskId: {args.TaskId},错误信息: {ex.Message},错误堆栈:{ex.StackTrace}";
_logger.LogError(ex, error);
// 更新任务状态为失败
var task = await _imageStoreTaskRepository.GetFirstAsync(x => x.Id == args.TaskId);
if (task != null)
{
task.TaskStatus = TaskStatusEnum.Fail;
await _imageStoreTaskRepository.UpdateAsync(task);
}
task.TaskStatus = TaskStatusEnum.Fail;
task.ErrorInfo = error;
await _imageStoreTaskRepository.UpdateAsync(task);
}
}
}
/// <summary>
/// 解析带前缀的 Base64 字符串,提取 mimeType 和纯 base64 数据
/// </summary>
private static (string mimeType, string base64Data) ParsePrefixBase64(string prefixBase64)
{
// 默认值
var mimeType = "image/png";
var base64Data = prefixBase64;
if (prefixBase64.Contains(","))
{
var parts = prefixBase64.Split(',');
if (parts.Length == 2)
{
var header = parts[0];
if (header.Contains(":") && header.Contains(";"))
{
mimeType = header.Split(':')[1].Split(';')[0];
}
base64Data = parts[1];
}
}
return (mimeType, base64Data);
}
}

View File

@@ -9,19 +9,4 @@ public class ImageGenerationJobArgs
/// 图片任务ID
/// </summary>
public Guid TaskId { get; set; }
/// <summary>
/// 模型ID
/// </summary>
public string ModelId { get; set; } = string.Empty;
/// <summary>
/// 请求JSON字符串
/// </summary>
public string RequestJson { get; set; } = string.Empty;
/// <summary>
/// 用户ID
/// </summary>
public Guid UserId { get; set; }
}

View File

@@ -7,6 +7,7 @@ using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.DailyTask;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
@@ -25,6 +26,7 @@ public class DailyTaskService : ApplicationService
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
private readonly ILogger<DailyTaskService> _logger;
private IDistributedLockProvider DistributedLock => LazyServiceProvider.LazyGetRequiredService<IDistributedLockProvider>();
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
// 任务配置
private readonly Dictionary<int, (long RequiredTokens, long RewardTokens, string Name, string Description)>
_taskConfigs = new()
@@ -37,12 +39,13 @@ public class DailyTaskService : ApplicationService
ISqlSugarRepository<DailyTaskRewardRecordAggregateRoot> dailyTaskRepository,
ISqlSugarRepository<MessageAggregateRoot> messageRepository,
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository,
ILogger<DailyTaskService> logger)
ILogger<DailyTaskService> logger, ISqlSugarRepository<AiModelEntity> aiModelRepository)
{
_dailyTaskRepository = dailyTaskRepository;
_messageRepository = messageRepository;
_premiumPackageRepository = premiumPackageRepository;
_logger = logger;
_aiModelRepository = aiModelRepository;
}
/// <summary>
@@ -179,10 +182,16 @@ public class DailyTaskService : ApplicationService
var tomorrow = today.AddDays(1);
// 查询今日所有使用尊享包模型的消息role=system 表示消耗)
// 先获取所有尊享模型的ModelId列表
var premiumModelIds = await _aiModelRepository._DbQueryable
.Where(x => x.IsPremium)
.Select(x => x.ModelId)
.ToListAsync();
var totalTokens = await _messageRepository._DbQueryable
.Where(x => x.UserId == userId)
.Where(x => x.Role == "system") // system角色表示实际消耗
.Where(x => PremiumPackageConst.ModeIds.Contains(x.ModelId)) // 尊享包模型
.Where(x => premiumModelIds.Contains(x.ModelId)) // 尊享包模型
.Where(x => x.CreationTime >= today && x.CreationTime < tomorrow)
.SumAsync(x => x.TokenUsage.TotalTokenCount);

View File

@@ -0,0 +1,240 @@
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SqlSugar;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Channel;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services;
/// <summary>
/// 渠道商管理服务实现
/// </summary>
[Authorize(Roles = "admin")]
public class ChannelService : ApplicationService, IChannelService
{
private readonly ISqlSugarRepository<AiAppAggregateRoot, Guid> _appRepository;
private readonly ISqlSugarRepository<AiModelEntity, Guid> _modelRepository;
public ChannelService(
ISqlSugarRepository<AiAppAggregateRoot, Guid> appRepository,
ISqlSugarRepository<AiModelEntity, Guid> modelRepository)
{
_appRepository = appRepository;
_modelRepository = modelRepository;
}
#region AI应用管理
/// <summary>
/// 获取AI应用列表
/// </summary>
[HttpGet("channel/app")]
public async Task<PagedResultDto<AiAppDto>> GetAppListAsync(AiAppGetListInput input)
{
RefAsync<int> total = 0;
var entities = await _appRepository._DbQueryable
.WhereIF(!string.IsNullOrWhiteSpace(input.SearchKey), x => x.Name.Contains(input.SearchKey))
.OrderByDescending(x => x.OrderNum)
.OrderByDescending(x => x.CreationTime)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
var output = entities.Adapt<List<AiAppDto>>();
return new PagedResultDto<AiAppDto>(total, output);
}
/// <summary>
/// 根据ID获取AI应用
/// </summary>
[HttpGet("channel/app/{id}")]
public async Task<AiAppDto> GetAppByIdAsync([FromRoute]Guid id)
{
var entity = await _appRepository.GetByIdAsync(id);
return entity.Adapt<AiAppDto>();
}
/// <summary>
/// 创建AI应用
/// </summary>
public async Task<AiAppDto> CreateAppAsync(AiAppCreateInput input)
{
var entity = new AiAppAggregateRoot
{
Name = input.Name,
Endpoint = input.Endpoint,
ExtraUrl = input.ExtraUrl,
ApiKey = input.ApiKey,
OrderNum = input.OrderNum
};
await _appRepository.InsertAsync(entity);
return entity.Adapt<AiAppDto>();
}
/// <summary>
/// 更新AI应用
/// </summary>
public async Task<AiAppDto> UpdateAppAsync(AiAppUpdateInput input)
{
var entity = await _appRepository.GetByIdAsync(input.Id);
entity.Name = input.Name;
entity.Endpoint = input.Endpoint;
entity.ExtraUrl = input.ExtraUrl;
entity.ApiKey = input.ApiKey;
entity.OrderNum = input.OrderNum;
await _appRepository.UpdateAsync(entity);
return entity.Adapt<AiAppDto>();
}
/// <summary>
/// 删除AI应用
/// </summary>
[HttpDelete("channel/app/{id}")]
public async Task DeleteAppAsync([FromRoute]Guid id)
{
// 检查是否有关联的模型
var hasModels = await _modelRepository._DbQueryable
.Where(x => x.AiAppId == id && !x.IsDeleted)
.AnyAsync();
if (hasModels)
{
throw new Volo.Abp.UserFriendlyException("该应用下存在模型,无法删除");
}
await _appRepository.DeleteAsync(id);
}
#endregion
#region AI模型管理
/// <summary>
/// 获取AI模型列表
/// </summary>
[HttpGet("channel/model")]
public async Task<PagedResultDto<AiModelDto>> GetModelListAsync(AiModelGetListInput input)
{
RefAsync<int> total = 0;
var query = _modelRepository._DbQueryable
.Where(x => !x.IsDeleted)
.WhereIF(!string.IsNullOrWhiteSpace(input.SearchKey), x =>
x.Name.Contains(input.SearchKey) || x.ModelId.Contains(input.SearchKey))
.WhereIF(input.AiAppId.HasValue, x => x.AiAppId == input.AiAppId.Value)
.WhereIF(input.IsPremiumOnly == true, x => x.IsPremium);
var entities = await query
.OrderBy(x => x.OrderNum)
.OrderByDescending(x => x.Id)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
var output = entities.Adapt<List<AiModelDto>>();
return new PagedResultDto<AiModelDto>(total, output);
}
/// <summary>
/// 根据ID获取AI模型
/// </summary>
[HttpGet("channel/model/{id}")]
public async Task<AiModelDto> GetModelByIdAsync([FromRoute]Guid id)
{
var entity = await _modelRepository.GetByIdAsync(id);
return entity.Adapt<AiModelDto>();
}
/// <summary>
/// 创建AI模型
/// </summary>
public async Task<AiModelDto> CreateModelAsync(AiModelCreateInput input)
{
// 验证应用是否存在
var appExists = await _appRepository._DbQueryable
.Where(x => x.Id == input.AiAppId)
.AnyAsync();
if (!appExists)
{
throw new Volo.Abp.UserFriendlyException("指定的AI应用不存在");
}
var entity = new AiModelEntity
{
HandlerName = input.HandlerName,
ModelId = input.ModelId,
Name = input.Name,
Description = input.Description,
OrderNum = input.OrderNum,
AiAppId = input.AiAppId,
ExtraInfo = input.ExtraInfo,
ModelType = input.ModelType,
ModelApiType = input.ModelApiType,
Multiplier = input.Multiplier,
MultiplierShow = input.MultiplierShow,
ProviderName = input.ProviderName,
IconUrl = input.IconUrl,
IsPremium = input.IsPremium,
IsDeleted = false
};
await _modelRepository.InsertAsync(entity);
return entity.Adapt<AiModelDto>();
}
/// <summary>
/// 更新AI模型
/// </summary>
public async Task<AiModelDto> UpdateModelAsync(AiModelUpdateInput input)
{
var entity = await _modelRepository.GetByIdAsync(input.Id);
// 验证应用是否存在
if (entity.AiAppId != input.AiAppId)
{
var appExists = await _appRepository._DbQueryable
.Where(x => x.Id == input.AiAppId)
.AnyAsync();
if (!appExists)
{
throw new Volo.Abp.UserFriendlyException("指定的AI应用不存在");
}
}
entity.HandlerName = input.HandlerName;
entity.ModelId = input.ModelId;
entity.Name = input.Name;
entity.Description = input.Description;
entity.OrderNum = input.OrderNum;
entity.AiAppId = input.AiAppId;
entity.ExtraInfo = input.ExtraInfo;
entity.ModelType = input.ModelType;
entity.ModelApiType = input.ModelApiType;
entity.Multiplier = input.Multiplier;
entity.MultiplierShow = input.MultiplierShow;
entity.ProviderName = input.ProviderName;
entity.IconUrl = input.IconUrl;
entity.IsPremium = input.IsPremium;
await _modelRepository.UpdateAsync(entity);
return entity.Adapt<AiModelDto>();
}
/// <summary>
/// 删除AI模型(软删除)
/// </summary>
[HttpDelete("channel/model/{id}")]
public async Task DeleteModelAsync(Guid id)
{
await _modelRepository.DeleteByIdAsync(id);
}
#endregion
}

View File

@@ -40,35 +40,37 @@ namespace Yi.Framework.AiHub.Application.Services;
public class AiChatService : ApplicationService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
private readonly AiBlacklistManager _aiBlacklistManager;
private readonly ILogger<AiChatService> _logger;
private readonly AiGateWayManager _aiGateWayManager;
private readonly ModelManager _modelManager;
private readonly PremiumPackageManager _premiumPackageManager;
private readonly ChatManager _chatManager;
private readonly TokenManager _tokenManager;
private readonly IAccountService _accountService;
private readonly ISqlSugarRepository<AgentStoreAggregateRoot> _agentStoreRepository;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
public AiChatService(IHttpContextAccessor httpContextAccessor,
AiBlacklistManager aiBlacklistManager,
ISqlSugarRepository<AiModelEntity> aiModelRepository,
ILogger<AiChatService> logger,
AiGateWayManager aiGateWayManager,
ModelManager modelManager,
PremiumPackageManager premiumPackageManager,
ChatManager chatManager, TokenManager tokenManager, IAccountService accountService,
ISqlSugarRepository<AgentStoreAggregateRoot> agentStoreRepository)
ISqlSugarRepository<AgentStoreAggregateRoot> agentStoreRepository, ISqlSugarRepository<AiModelEntity> aiModelRepository)
{
_httpContextAccessor = httpContextAccessor;
_aiBlacklistManager = aiBlacklistManager;
_aiModelRepository = aiModelRepository;
_logger = logger;
_aiGateWayManager = aiGateWayManager;
_modelManager = modelManager;
_premiumPackageManager = premiumPackageManager;
_chatManager = chatManager;
_tokenManager = tokenManager;
_accountService = accountService;
_agentStoreRepository = agentStoreRepository;
_aiModelRepository = aiModelRepository;
}
@@ -86,7 +88,7 @@ public class AiChatService : ApplicationService
}
/// <summary>
/// 获取模型列表
/// 获取对话模型列表
/// </summary>
/// <returns></returns>
public async Task<List<ModelGetListOutput>> GetModelAsync()
@@ -98,18 +100,11 @@ public class AiChatService : ApplicationService
.Select(x => new ModelGetListOutput
{
Id = x.Id,
Category = "chat",
ModelId = x.ModelId,
ModelName = x.Name,
ModelDescribe = x.Description,
ModelPrice = 0,
ModelType = "1",
ModelShow = "0",
SystemPrompt = null,
ApiHost = null,
ApiKey = null,
Remark = x.Description,
IsPremiumPackage = PremiumPackageConst.ModeIds.Contains(x.ModelId)
IsPremiumPackage = x.IsPremium
}).ToListAsync();
return output;
}
@@ -144,19 +139,24 @@ public class AiChatService : ApplicationService
}
//如果是尊享包服务,需要校验是是否尊享包足够
if (CurrentUser.IsAuthenticated && PremiumPackageConst.ModeIds.Contains(input.Model))
if (CurrentUser.IsAuthenticated)
{
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(CurrentUser.GetId());
if (availableTokens <= 0)
var isPremium = await _modelManager.IsPremiumModelAsync(input.Model);
if (isPremium)
{
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(CurrentUser.GetId());
if (availableTokens <= 0)
{
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
}
}
}
//ai网关代理httpcontext
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
CurrentUser.Id, sessionId, null, cancellationToken);
CurrentUser.Id, sessionId, null, CancellationToken.None);
}
/// <summary>
@@ -192,7 +192,7 @@ public class AiChatService : ApplicationService
//ai网关代理httpcontext
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
CurrentUser.Id, null, null, cancellationToken);
CurrentUser.Id, null, null, CancellationToken.None);
}
@@ -202,7 +202,7 @@ public class AiChatService : ApplicationService
[HttpPost("ai-chat/agent/send")]
public async Task PostAgentSendAsync([FromBody] AgentSendInput input, CancellationToken cancellationToken)
{
var tokenValidation = await _tokenManager.ValidateTokenAsync(input.Token, input.ModelId);
var tokenValidation = await _tokenManager.ValidateTokenAsync(input.TokenId, input.ModelId);
await _aiBlacklistManager.VerifiyAiBlacklist(tokenValidation.UserId);
// 验证用户是否为VIP
@@ -219,7 +219,9 @@ public class AiChatService : ApplicationService
}
//如果是尊享包服务,需要校验是是否尊享包足够
if (PremiumPackageConst.ModeIds.Contains(input.ModelId))
var isPremium = await _modelManager.IsPremiumModelAsync(input.ModelId);
if (isPremium)
{
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(tokenValidation.UserId);
@@ -232,12 +234,12 @@ public class AiChatService : ApplicationService
await _chatManager.AgentCompleteChatStreamAsync(_httpContextAccessor.HttpContext,
input.SessionId,
input.Content,
input.Token,
tokenValidation.Token,
tokenValidation.TokenId,
input.ModelId,
tokenValidation.UserId,
input.Tools,
cancellationToken);
CancellationToken.None);
}
/// <summary>

View File

@@ -3,14 +3,17 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.Guids;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
using Yi.Framework.AiHub.Application.Jobs;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
@@ -29,23 +32,31 @@ public class AiImageService : ApplicationService
private readonly IBackgroundJobManager _backgroundJobManager;
private readonly AiBlacklistManager _aiBlacklistManager;
private readonly PremiumPackageManager _premiumPackageManager;
private readonly ModelManager _modelManager;
private readonly IGuidGenerator _guidGenerator;
private readonly IWebHostEnvironment _webHostEnvironment;
private readonly TokenManager _tokenManager;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
public AiImageService(
ISqlSugarRepository<ImageStoreTaskAggregateRoot> imageTaskRepository,
IBackgroundJobManager backgroundJobManager,
AiBlacklistManager aiBlacklistManager,
PremiumPackageManager premiumPackageManager,
ModelManager modelManager,
IGuidGenerator guidGenerator,
IWebHostEnvironment webHostEnvironment)
IWebHostEnvironment webHostEnvironment, TokenManager tokenManager,
ISqlSugarRepository<AiModelEntity> aiModelRepository)
{
_imageTaskRepository = imageTaskRepository;
_backgroundJobManager = backgroundJobManager;
_aiBlacklistManager = aiBlacklistManager;
_premiumPackageManager = premiumPackageManager;
_modelManager = modelManager;
_guidGenerator = guidGenerator;
_webHostEnvironment = webHostEnvironment;
_tokenManager = tokenManager;
_aiModelRepository = aiModelRepository;
}
/// <summary>
@@ -62,14 +73,22 @@ public class AiImageService : ApplicationService
// 黑名单校验
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
//校验token
if (input.TokenId is not null)
{
await _tokenManager.ValidateTokenAsync(input.TokenId, input.ModelId);
}
// VIP校验
if (!CurrentUser.IsAiVip())
{
throw new UserFriendlyException("图片生成功能需要VIP用户才能使用请购买VIP后重新登录重试");
}
// 尊享包校验
if (PremiumPackageConst.ModeIds.Contains(input.ModelId))
// 尊享包校验 - 使用ModelManager统一判断
var isPremium = await _modelManager.IsPremiumModelAsync(input.ModelId);
if (isPremium)
{
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId);
if (availableTokens <= 0)
@@ -82,32 +101,23 @@ public class AiImageService : ApplicationService
var task = new ImageStoreTaskAggregateRoot
{
Prompt = input.Prompt,
ReferenceImagesBase64 = input.ReferenceImagesBase64 ?? new List<string>(),
ReferenceImagesPrefixBase64 = input.ReferenceImagesPrefixBase64 ?? new List<string>(),
ReferenceImagesUrl = new List<string>(),
TaskStatus = TaskStatusEnum.Processing,
UserId = userId
UserId = userId,
UserName = CurrentUser.UserName,
TokenId = input.TokenId,
ModelId = input.ModelId
};
await _imageTaskRepository.InsertAsync(task);
var taskId = task.Id;
// 构建请求JSON
var requestJson = JsonSerializer.Serialize(new
{
prompt = input.Prompt,
referenceImages = input.ReferenceImagesBase64
});
// 入队后台任务
await _backgroundJobManager.EnqueueAsync(new ImageGenerationJobArgs
{
TaskId = taskId,
ModelId = input.ModelId,
RequestJson = requestJson,
UserId = userId
TaskId = task.Id,
});
return taskId;
return task.Id;
}
/// <summary>
@@ -130,12 +140,15 @@ public class AiImageService : ApplicationService
{
Id = task.Id,
Prompt = task.Prompt,
ReferenceImagesBase64 = task.ReferenceImagesBase64,
ReferenceImagesUrl = task.ReferenceImagesUrl,
StoreBase64 = task.StoreBase64,
// ReferenceImagesBase64 = task.ReferenceImagesBase64,
// ReferenceImagesUrl = task.ReferenceImagesUrl,
// StoreBase64 = task.StoreBase64,
StoreUrl = task.StoreUrl,
TaskStatus = task.TaskStatus,
CreationTime = task.CreationTime
PublishStatus = task.PublishStatus,
Categories = task.Categories,
CreationTime = task.CreationTime,
ErrorInfo = task.ErrorInfo,
};
}
@@ -145,6 +158,7 @@ public class AiImageService : ApplicationService
/// <param name="base64Data">Base64图片数据包含前缀如 data:image/png;base64,</param>
/// <returns>图片访问URL</returns>
[HttpPost("ai-image/upload-base64")]
[AllowAnonymous]
public async Task<string> UploadBase64ToUrlAsync([FromBody] string base64Data)
{
if (string.IsNullOrWhiteSpace(base64Data))
@@ -167,6 +181,7 @@ public class AiImageService : ApplicationService
{
mimeType = header.Split(':')[1].Split(';')[0];
}
base64Content = parts[1];
}
}
@@ -193,57 +208,166 @@ public class AiImageService : ApplicationService
throw new UserFriendlyException("Base64格式无效");
}
// 创建存储目录
var uploadPath = Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot", "ai-images");
// ==============================
// ✅ 按日期创建目录yyyyMMdd
// ==============================
var dateFolder = DateTime.Now.ToString("yyyyMMdd");
var uploadPath = Path.Combine(
_webHostEnvironment.ContentRootPath,
"wwwroot",
"ai-images",
dateFolder
);
if (!Directory.Exists(uploadPath))
{
Directory.CreateDirectory(uploadPath);
}
// 生成文件名并保存
// 保存文件
var fileId = _guidGenerator.Create();
var fileName = $"{fileId}{extension}";
var filePath = Path.Combine(uploadPath, fileName);
await File.WriteAllBytesAsync(filePath, imageBytes);
// 返回访问URL
return $"/ai-images/{fileName}";
// 返回包含日期目录的访问URL
return $"/wwwroot/ai-images/{dateFolder}/{fileName}";
}
/// <summary>
/// 分页查询任务列表
/// 分页查询我的任务列表
/// </summary>
/// <param name="input">分页查询参数</param>
/// <returns>任务列表</returns>
[HttpGet("ai-image/tasks")]
public async Task<PagedResult<ImageTaskOutput>> GetTaskPageAsync([FromQuery] ImageTaskPageInput input)
[HttpGet("ai-image/my-tasks")]
public async Task<PagedResult<ImageTaskOutput>> GetMyTaskPageAsync([FromQuery] ImageMyTaskPageInput input)
{
var userId = CurrentUser.GetId();
var query = _imageTaskRepository._DbQueryable
RefAsync<int> total = 0;
var output = await _imageTaskRepository._DbQueryable
.Where(x => x.UserId == userId)
.WhereIF(input.TaskStatus.HasValue, x => x.TaskStatus == input.TaskStatus!.Value)
.OrderByDescending(x => x.CreationTime);
var total = await query.CountAsync();
var items = await query
.Skip((input.PageIndex - 1) * input.PageSize)
.Take(input.PageSize)
.WhereIF(input.TaskStatus is not null, x => x.TaskStatus == input.TaskStatus)
.WhereIF(!string.IsNullOrWhiteSpace(input.Prompt), x => x.Prompt.Contains(input.Prompt))
.WhereIF(input.PublishStatus is not null, x => x.PublishStatus == input.PublishStatus)
.WhereIF(input.StartTime is not null && input.EndTime is not null,
x => x.CreationTime >= input.StartTime && x.CreationTime <= input.EndTime)
.OrderByDescending(x => x.CreationTime)
.Select(x => new ImageTaskOutput
{
Id = x.Id,
Prompt = x.Prompt,
ReferenceImagesBase64 = x.ReferenceImagesBase64,
ReferenceImagesUrl = x.ReferenceImagesUrl,
StoreBase64 = x.StoreBase64,
StoreUrl = x.StoreUrl,
TaskStatus = x.TaskStatus,
CreationTime = x.CreationTime
PublishStatus = x.PublishStatus,
Categories = x.Categories,
CreationTime = x.CreationTime,
ErrorInfo = x.ErrorInfo
})
.ToListAsync();
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
return new PagedResult<ImageTaskOutput>(total, items);
return new PagedResult<ImageTaskOutput>(total, output);
}
/// <summary>
/// 分页查询图片广场(已发布的图片)
/// </summary>
[HttpGet("ai-image/plaza")]
[AllowAnonymous]
public async Task<PagedResult<ImageTaskOutput>> GetPlazaPageAsync([FromQuery] ImagePlazaPageInput input)
{
RefAsync<int> total = 0;
var output = await _imageTaskRepository._DbQueryable
.Where(x => x.PublishStatus == PublishStatusEnum.Published)
.Where(x => x.TaskStatus == TaskStatusEnum.Success)
.WhereIF(input.TaskStatus is not null, x => x.TaskStatus == input.TaskStatus)
.WhereIF(!string.IsNullOrWhiteSpace(input.Prompt), x => x.Prompt.Contains(input.Prompt))
.WhereIF(!string.IsNullOrWhiteSpace(input.Categories), x => SqlFunc.JsonLike(x.Categories, input.Categories))
.WhereIF(!string.IsNullOrWhiteSpace(input.UserName),x=>x.UserName.Contains(input.UserName) )
.WhereIF(input.StartTime is not null && input.EndTime is not null,
x => x.CreationTime >= input.StartTime && x.CreationTime <= input.EndTime)
.OrderByDescending(x => x.CreationTime)
.Select(x => new ImageTaskOutput
{
Id = x.Id,
Prompt = x.Prompt,
IsAnonymous = x.IsAnonymous,
StoreUrl = x.StoreUrl,
TaskStatus = x.TaskStatus,
PublishStatus = x.PublishStatus,
Categories = x.Categories,
CreationTime = x.CreationTime,
ErrorInfo = null,
UserName = x.UserName,
UserId = x.UserId,
})
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); ;
output.ForEach(x =>
{
if (x.IsAnonymous)
{
x.UserName = null;
x.UserId = null;
}
});
return new PagedResult<ImageTaskOutput>(total, output);
}
/// <summary>
/// 发布图片到广场
/// </summary>
[HttpPost("ai-image/publish")]
public async Task PublishAsync([FromBody] PublishImageInput input)
{
var userId = CurrentUser.GetId();
var task = await _imageTaskRepository.GetFirstAsync(x => x.Id == input.TaskId && x.UserId == userId);
if (task == null)
{
throw new UserFriendlyException("任务不存在或无权访问");
}
if (task.TaskStatus != TaskStatusEnum.Success)
{
throw new UserFriendlyException("只有已完成的任务才能发布");
}
if (task.PublishStatus == PublishStatusEnum.Published)
{
throw new UserFriendlyException("该任务已发布");
}
//设置发布
task.SetPublish(input.IsAnonymous,input.Categories);
await _imageTaskRepository.UpdateAsync(task);
}
/// <summary>
/// 获取图片模型列表
/// </summary>
/// <returns></returns>
[HttpPost("ai-image/model")]
[AllowAnonymous]
public async Task<List<ModelGetListOutput>> GetModelAsync()
{
var output = await _aiModelRepository._DbQueryable
.Where(x => x.ModelType == ModelTypeEnum.Image)
.Where(x => x.ModelApiType == ModelApiTypeEnum.GenerateContent)
.OrderByDescending(x => x.OrderNum)
.Select(x => new ModelGetListOutput
{
Id = x.Id,
ModelId = x.ModelId,
ModelName = x.Name,
ModelDescribe = x.Description,
Remark = x.Description,
IsPremiumPackage = x.IsPremium
}).ToListAsync();
return output;
}
}
@@ -268,4 +392,4 @@ public class PagedResult<T>
Total = total;
Items = items;
}
}
}

View File

@@ -1,10 +1,12 @@
using Mapster;
using Microsoft.AspNetCore.Mvc;
using SqlSugar;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Model;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.AiHub.Domain.Shared.Extensions;
@@ -18,10 +20,12 @@ namespace Yi.Framework.AiHub.Application.Services.Chat;
public class ModelService : ApplicationService, IModelService
{
private readonly ISqlSugarRepository<AiModelEntity, Guid> _modelRepository;
private readonly ModelManager _modelManager;
public ModelService(ISqlSugarRepository<AiModelEntity, Guid> modelRepository)
public ModelService(ISqlSugarRepository<AiModelEntity, Guid> modelRepository, ModelManager modelManager)
{
_modelRepository = modelRepository;
_modelManager = modelManager;
}
/// <summary>
@@ -41,8 +45,7 @@ public class ModelService : ApplicationService, IModelService
input.ModelTypes.Contains(x.ModelType))
.WhereIF(input.ModelApiTypes is not null, x =>
input.ModelApiTypes.Contains(x.ModelApiType))
.WhereIF(input.IsPremiumOnly == true, x =>
PremiumPackageConst.ModeIds.Contains(x.ModelId))
.WhereIF(input.IsPremiumOnly == true, x => x.IsPremium)
.GroupBy(x => x.ModelId)
.Select(x => x.ModelId)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total));
@@ -61,7 +64,7 @@ public class ModelService : ApplicationService, IModelService
MultiplierShow = x.First().MultiplierShow,
ProviderName = x.First().ProviderName,
IconUrl = x.First().IconUrl,
IsPremium = PremiumPackageConst.ModeIds.Contains(x.First().ModelId),
IsPremium = x.First().IsPremium,
OrderNum = x.First().OrderNum
}).ToList();
@@ -77,11 +80,11 @@ public class ModelService : ApplicationService, IModelService
.Where(x => !x.IsDeleted)
.Where(x => !string.IsNullOrEmpty(x.ProviderName))
.GroupBy(x => x.ProviderName)
.OrderBy(x => x.ProviderName)
.OrderBy(x => x.OrderNum)
.Select(x => x.ProviderName)
.ToListAsync();
return providers;
return providers!;
}
/// <summary>
@@ -115,4 +118,13 @@ public class ModelService : ApplicationService, IModelService
return Task.FromResult(options);
}
/// <summary>
/// 清除尊享模型ID缓存
/// </summary>
[HttpPost("model/clear-premium-cache")]
public async Task ClearPremiumModelCacheAsync()
{
await _modelManager.ClearPremiumModelIdsCacheAsync();
}
}

View File

@@ -7,8 +7,10 @@ using Volo.Abp.Application.Services;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.Ddd.Application.Contracts;
using Yi.Framework.SqlSugarCore.Abstractions;
@@ -23,13 +25,16 @@ public class TokenService : ApplicationService
{
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
private readonly ModelManager _modelManager;
public TokenService(
ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository)
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository,
ModelManager modelManager)
{
_tokenRepository = tokenRepository;
_usageStatisticsRepository = usageStatisticsRepository;
_modelManager = modelManager;
}
/// <summary>
@@ -51,8 +56,8 @@ public class TokenService : ApplicationService
return new PagedResultDto<TokenGetListOutputDto>();
}
// 获取尊享包模型ID列表
var premiumModelIds = PremiumPackageConst.ModeIds;
// 通过ModelManager获取尊享包模型ID列表
var premiumModelIds = await _modelManager.GetPremiumModelIdsAsync();
// 批量查询所有Token的尊享包已使用额度
var tokenIds = tokens.Select(t => t.Id).ToList();
@@ -86,7 +91,7 @@ public class TokenService : ApplicationService
}
[HttpGet("token/select-list")]
public async Task<List<TokenSelectListOutputDto>> GetSelectListAsync()
public async Task<List<TokenSelectListOutputDto>> GetSelectListAsync([FromQuery] bool? includeDefault = true)
{
var userId = CurrentUser.GetId();
var tokens = await _tokenRepository._DbQueryable
@@ -99,13 +104,17 @@ public class TokenService : ApplicationService
Name = x.Name,
IsDisabled = x.IsDisabled
}).ToListAsync();
tokens.Insert(0,new TokenSelectListOutputDto
if (includeDefault == true)
{
TokenId = Guid.Empty,
Name = "默认",
IsDisabled = false
});
tokens.Insert(0, new TokenSelectListOutputDto
{
TokenId = Guid.Empty,
Name = "默认",
IsDisabled = false
});
}
return tokens;
}

View File

@@ -27,25 +27,27 @@ public class OpenApiService : ApplicationService
private readonly ILogger<OpenApiService> _logger;
private readonly TokenManager _tokenManager;
private readonly AiGateWayManager _aiGateWayManager;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
private readonly ModelManager _modelManager;
private readonly AiBlacklistManager _aiBlacklistManager;
private readonly IAccountService _accountService;
private readonly PremiumPackageManager _premiumPackageManager;
private readonly ISqlSugarRepository<ImageStoreTaskAggregateRoot> _imageStoreRepository;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
public OpenApiService(IHttpContextAccessor httpContextAccessor, ILogger<OpenApiService> logger,
TokenManager tokenManager, AiGateWayManager aiGateWayManager,
ISqlSugarRepository<AiModelEntity> aiModelRepository, AiBlacklistManager aiBlacklistManager,
IAccountService accountService, PremiumPackageManager premiumPackageManager, ISqlSugarRepository<ImageStoreTaskAggregateRoot> imageStoreRepository)
ModelManager modelManager, AiBlacklistManager aiBlacklistManager,
IAccountService accountService, PremiumPackageManager premiumPackageManager, ISqlSugarRepository<ImageStoreTaskAggregateRoot> imageStoreRepository, ISqlSugarRepository<AiModelEntity> aiModelRepository)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_tokenManager = tokenManager;
_aiGateWayManager = aiGateWayManager;
_aiModelRepository = aiModelRepository;
_modelManager = modelManager;
_aiBlacklistManager = aiBlacklistManager;
_accountService = accountService;
_premiumPackageManager = premiumPackageManager;
_imageStoreRepository = imageStoreRepository;
_aiModelRepository = aiModelRepository;
}
/// <summary>
@@ -65,7 +67,9 @@ public class OpenApiService : ApplicationService
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
//如果是尊享包服务,需要校验是是否尊享包足够
if (PremiumPackageConst.ModeIds.Contains(input.Model))
var isPremium = await _modelManager.IsPremiumModelAsync(input.Model);
if (isPremium)
{
// 检查尊享token包用量
var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(userId);
@@ -79,13 +83,13 @@ public class OpenApiService : ApplicationService
if (input.Stream == true)
{
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId, null, tokenId, cancellationToken);
userId, null, tokenId,CancellationToken.None);
}
else
{
await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
null, tokenId,
cancellationToken);
CancellationToken.None);
}
}
@@ -193,14 +197,14 @@ public class OpenApiService : ApplicationService
{
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext,
input,
userId, null, tokenId, cancellationToken);
userId, null, tokenId, CancellationToken.None);
}
else
{
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId,
null, tokenId,
cancellationToken);
CancellationToken.None);
}
}
@@ -245,14 +249,14 @@ public class OpenApiService : ApplicationService
{
await _aiGateWayManager.OpenAiResponsesStreamForStatisticsAsync(_httpContextAccessor.HttpContext,
input,
userId, null, tokenId, cancellationToken);
userId, null, tokenId, CancellationToken.None);
}
else
{
await _aiGateWayManager.OpenAiResponsesAsyncForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId,
null, tokenId,
cancellationToken);
CancellationToken.None);
}
}
@@ -304,7 +308,7 @@ public class OpenApiService : ApplicationService
modelId, input,
userId,
null, tokenId,
cancellationToken);
CancellationToken.None);
}
else
{
@@ -312,7 +316,7 @@ public class OpenApiService : ApplicationService
modelId, input,
userId,
null, tokenId,
cancellationToken);
CancellationToken.None);
}
}

View File

@@ -9,8 +9,10 @@ using Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.Ddd.Application.Contracts;
using Yi.Framework.SqlSugarCore.Abstractions;
@@ -27,17 +29,19 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
private readonly ModelManager _modelManager;
public UsageStatisticsService(
ISqlSugarRepository<MessageAggregateRoot> messageRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository,
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository,
ISqlSugarRepository<TokenAggregateRoot> tokenRepository)
ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
ModelManager modelManager)
{
_messageRepository = messageRepository;
_usageStatisticsRepository = usageStatisticsRepository;
_premiumPackageRepository = premiumPackageRepository;
_tokenRepository = tokenRepository;
_modelManager = modelManager;
}
/// <summary>
@@ -181,7 +185,9 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
public async Task<List<TokenPremiumUsageDto>> GetPremiumTokenUsageByTokenAsync()
{
var userId = CurrentUser.GetId();
var premiumModelIds = PremiumPackageConst.ModeIds;
// 通过ModelManager获取所有尊享模型的ModelId列表
var premiumModelIds = await _modelManager.GetPremiumModelIdsAsync();
// 从UsageStatistics表获取尊享模型的token消耗统计按TokenId聚合
var tokenUsages = await _usageStatisticsRepository._DbQueryable

View File

@@ -20,7 +20,6 @@ public static class GeminiGenerateContentAcquirer
+ usage.Value.GetPath("thoughtsTokenCount").GetInt()
+ usage.Value.GetPath("toolUsePromptTokenCount").GetInt();
return new ThorUsageResponse
{
PromptTokens = inputTokens,
@@ -32,14 +31,47 @@ public static class GeminiGenerateContentAcquirer
}
/// <summary>
/// 获取图片url包含前缀
/// 获取图片 base64包含 data:image 前缀
/// 优先从 inlineData.data 中获取,其次从 markdown text 中解析
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
public static string GetImageBase64(JsonElement response)
public static string GetImagePrefixBase64(JsonElement response)
{
//todo
//获取他的base64字符串
return string.Empty;
// Step 1: 优先尝试从 candidates[0].content.parts[0].inlineData.data 获取
var inlineBase64 = response
.GetPath("candidates", 0, "content", "parts", 0, "inlineData", "data")
.GetString();
if (!string.IsNullOrEmpty(inlineBase64))
{
// 默认按 png 格式拼接前缀
return $"data:image/png;base64,{inlineBase64}";
}
// Step 2: fallback从 candidates[0].content.parts[0].text 中解析 markdown 图片
var text = response
.GetPath("candidates", 0, "content", "parts", 0, "text")
.GetString();
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
// markdown 图片格式: ![image](data:image/png;base64,xxx)
var startMarker = "(data:image/";
var startIndex = text.IndexOf(startMarker, StringComparison.Ordinal);
if (startIndex < 0)
{
return string.Empty;
}
startIndex += 1; // 跳过 "("
var endIndex = text.IndexOf(')', startIndex);
if (endIndex <= startIndex)
{
return string.Empty;
}
return text.Substring(startIndex, endIndex - startIndex);
}
}

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
/// <summary>
/// 发布状态枚举
/// </summary>
public enum PublishStatusEnum
{
/// <summary>
/// 未发布
/// </summary>
Unpublished = 0,
/// <summary>
/// 已发布
/// </summary>
Published = 1
}

View File

@@ -107,35 +107,6 @@ public class AzureDatabricksChatCompletionsService(ILogger<AzureDatabricksChatCo
{
continue;
}
// var content = result?.Choices?.FirstOrDefault()?.Delta;
//
// if (first && content?.Content == OpenAIConstant.ThinkStart)
// {
// isThink = true;
// continue;
// // 需要将content的内容转换到其他字段
// }
//
// if (isThink && content?.Content?.Contains(OpenAIConstant.ThinkEnd) == true)
// {
// isThink = false;
// // 需要将content的内容转换到其他字段
// continue;
// }
//
// if (isThink && result?.Choices != null)
// {
// // 需要将content的内容转换到其他字段
// foreach (var choice in result.Choices)
// {
// choice.Delta.ReasoningContent = choice.Delta.Content;
// choice.Delta.Content = string.Empty;
// }
// }
//
// first = false;
yield return result;
}
}

View File

@@ -9,7 +9,9 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi;
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats;
public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsService> logger,IHttpClientFactory httpClientFactory)
public sealed class OpenAiChatCompletionsService(
ILogger<OpenAiChatCompletionsService> logger,
IHttpClientFactory httpClientFactory)
: IChatCompletionService
{
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe options,
@@ -19,8 +21,18 @@ public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsSe
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话流式补全");
var endpoint = options?.Endpoint.TrimEnd('/');
//兼容 v1结尾
if (endpoint != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
endpoint = endpoint.Substring(0, endpoint.Length - "/v1".Length);
}
var requestUri = endpoint + "/v1/chat/completions";
var response = await httpClientFactory.CreateClient().HttpRequestRaw(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
requestUri,
chatCompletionCreate, options.ApiKey);
openai?.SetTag("Model", chatCompletionCreate.Model);
@@ -130,8 +142,16 @@ public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsSe
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话补全");
var endpoint = options?.Endpoint.TrimEnd('/');
//兼容 v1结尾
if (endpoint != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
endpoint = endpoint.Substring(0, endpoint.Length - "/v1".Length);
}
var requestUri = endpoint + "/v1/chat/completions";
var response = await httpClientFactory.CreateClient().PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
requestUri,
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
openai?.SetTag("Model", chatCompletionCreate.Model);
@@ -152,7 +172,8 @@ public sealed class OpenAiChatCompletionsService(ILogger<OpenAiChatCompletionsSe
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}", options.Endpoint,
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}",
options.Endpoint,
response.StatusCode, error);
throw new BusinessException("OpenAI对话异常", response.StatusCode.ToString());

View File

@@ -22,7 +22,16 @@ public class OpenAiResponseService(ILogger<OpenAiResponseService> logger,IHttpCl
var client = httpClientFactory.CreateClient();
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/responses", input, options.ApiKey);
var endpoint = options?.Endpoint.TrimEnd('/');
//兼容 v1结尾
if (endpoint != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
endpoint = endpoint.Substring(0, endpoint.Length - "/v1".Length);
}
var requestUri = endpoint + "/v1/responses";
var response = await client.HttpRequestRaw(requestUri, input, options.ApiKey);
openai?.SetTag("Model", input.Model);
openai?.SetTag("Response", response.StatusCode.ToString());
@@ -86,8 +95,17 @@ public class OpenAiResponseService(ILogger<OpenAiResponseService> logger,IHttpCl
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 响应");
var endpoint = options?.Endpoint.TrimEnd('/');
//兼容 v1结尾
if (endpoint != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
endpoint = endpoint.Substring(0, endpoint.Length - "/v1".Length);
}
var requestUri = endpoint + "/v1/responses";
var response = await httpClientFactory.CreateClient().PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/responses",
requestUri,
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
openai?.SetTag("Model", chatCompletionCreate.Model);

View File

@@ -23,9 +23,17 @@ public sealed class DeepSeekChatCompletionsService(ILogger<DeepSeekChatCompletio
using var openai =
Activity.Current?.Source.StartActivity("OpenAI 对话流式补全");
var endpoint = options?.Endpoint.TrimEnd('/');
//兼容 v1结尾
if (endpoint != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
endpoint = endpoint.Substring(0, endpoint.Length - "/v1".Length);
}
var requestUri = endpoint + "/v1/chat/completions";
var response = await httpClientFactory.CreateClient().HttpRequestRaw(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
requestUri,
chatCompletionCreate, options.ApiKey);
openai?.SetTag("Model", chatCompletionCreate.Model);
@@ -92,40 +100,6 @@ public sealed class DeepSeekChatCompletionsService(ILogger<DeepSeekChatCompletio
var result = JsonSerializer.Deserialize<ThorChatCompletionsResponse>(line,
ThorJsonSerializer.DefaultOptions);
// var content = result?.Choices?.FirstOrDefault()?.Delta;
//
// // if (first && string.IsNullOrWhiteSpace(content?.Content) && string.IsNullOrEmpty(content?.ReasoningContent))
// // {
// // continue;
// // }
//
// if (first && content.Content == OpenAIConstant.ThinkStart)
// {
// isThink = true;
// //continue;
// // 需要将content的内容转换到其他字段
// }
//
// if (isThink && content.Content.Contains(OpenAIConstant.ThinkEnd))
// {
// isThink = false;
// // 需要将content的内容转换到其他字段
// //continue;
// }
//
// if (isThink)
// {
// // 需要将content的内容转换到其他字段
// foreach (var choice in result.Choices)
// {
// //choice.Delta.ReasoningContent = choice.Delta.Content;
// //choice.Delta.Content = string.Empty;
// }
// }
// first = false;
yield return result;
}
}
@@ -142,8 +116,16 @@ public sealed class DeepSeekChatCompletionsService(ILogger<DeepSeekChatCompletio
options.Endpoint = "https://api.deepseek.com/v1";
}
var endpoint = options?.Endpoint.TrimEnd('/');
//兼容 v1结尾
if (endpoint != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
endpoint = endpoint.Substring(0, endpoint.Length - "/v1".Length);
}
var requestUri = endpoint + "/v1/chat/completions";
var response = await httpClientFactory.CreateClient().PostJsonAsync(
options?.Endpoint.TrimEnd('/') + "/chat/completions",
requestUri,
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
openai?.SetTag("Model", chatCompletionCreate.Model);

View File

@@ -14,23 +14,18 @@ public class ImageStoreTaskAggregateRoot : FullAuditedAggregateRoot<Guid>
public string Prompt { get; set; }
/// <summary>
/// 参考图Base64
/// 参考图PrefixBase64带前缀如 data:image/png;base64,xxx
/// </summary>
[SugarColumn(IsJson = true)]
public List<string> ReferenceImagesBase64 { get; set; }
[SugarColumn(IsJson = true, ColumnDataType = StaticConfig.CodeFirst_BigString)]
public List<string> ReferenceImagesPrefixBase64 { get; set; }
/// <summary>
/// 参考图url
/// </summary>
[SugarColumn(IsJson = true)]
public List<string> ReferenceImagesUrl { get; set; }
/// <summary>
/// 图片base64
/// </summary>
public string? StoreBase64 { get; set; }
/// <summary>
/// 图片绝对路径
/// </summary>
@@ -46,6 +41,43 @@ public class ImageStoreTaskAggregateRoot : FullAuditedAggregateRoot<Guid>
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 用户名称
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// 模型id
/// </summary>
public string ModelId { get; set; }
/// <summary>
/// 错误信息
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString)]
public string? ErrorInfo { get; set; }
/// <summary>
/// 发布状态
/// </summary>
public PublishStatusEnum PublishStatus { get; set; } = PublishStatusEnum.Unpublished;
/// <summary>
/// 分类标签
/// </summary>
[SugarColumn(IsJson = true)]
public List<string> Categories { get; set; } = new();
/// <summary>
/// 是否匿名
/// </summary>
public bool IsAnonymous { get; set; } = false;
/// <summary>
/// 密钥id
/// </summary>
public Guid? TokenId { get; set; }
/// <summary>
/// 设置成功
/// </summary>
@@ -55,4 +87,18 @@ public class ImageStoreTaskAggregateRoot : FullAuditedAggregateRoot<Guid>
TaskStatus = TaskStatusEnum.Success;
StoreUrl = storeUrl;
}
/// <summary>
/// 设置发布
/// </summary>
/// <param name="isAnonymous"></param>
/// <param name="categories"></param>
public void SetPublish(bool isAnonymous,List<string> categories)
{
this.PublishStatus = PublishStatusEnum.Published;
this.IsAnonymous = isAnonymous;
this.Categories = categories;
}
}

View File

@@ -80,4 +80,9 @@ public class AiModelEntity : Entity<Guid>, IOrderNum, ISoftDelete
/// 模型图标URL
/// </summary>
public string? IconUrl { get; set; }
/// <summary>
/// 是否为尊享模型
/// </summary>
public bool IsPremium { get; set; }
}

View File

@@ -92,7 +92,7 @@ public class AiGateWayManager : DomainService
{
throw new UserFriendlyException($"【{modelId}】模型当前版本【{modelApiType}】格式不支持");
}
// ✅ 统一处理 -nx 后缀(网关层模型规范化)
// ✅ 统一处理 yi- 后缀(网关层模型规范化)
if (!string.IsNullOrEmpty(aiModelDescribe.ModelId) &&
aiModelDescribe.ModelId.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{
@@ -158,7 +158,12 @@ public class AiGateWayManager : DomainService
await _usageStatisticsManager.SetUsageAsync(userId.Value, sourceModelId, data.Usage, tokenId);
// 扣减尊享token包用量
if (PremiumPackageConst.ModeIds.Contains(sourceModelId))
var isPremium = await _aiModelRepository._DbQueryable
.Where(x => x.ModelId == request.Model)
.Select(x => x.IsPremium)
.FirstAsync();
if (isPremium)
{
var totalTokens = data.Usage?.TotalTokens ?? 0;
if (totalTokens > 0)
@@ -315,12 +320,20 @@ public class AiGateWayManager : DomainService
await _usageStatisticsManager.SetUsageAsync(userId, sourceModelId, tokenUsage, tokenId);
// 扣减尊享token包用量
if (userId is not null && PremiumPackageConst.ModeIds.Contains(sourceModelId))
if (userId is not null)
{
var totalTokens = tokenUsage.TotalTokens ?? 0;
if (totalTokens > 0)
var isPremium = await _aiModelRepository._DbQueryable
.Where(x => x.ModelId == request.Model)
.Select(x => x.IsPremium)
.FirstAsync();
if (isPremium)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
var totalTokens = tokenUsage.TotalTokens ?? 0;
if (totalTokens > 0)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
}
}
}
}
@@ -378,12 +391,20 @@ public class AiGateWayManager : DomainService
await _usageStatisticsManager.SetUsageAsync(userId, model, response.Usage, tokenId);
// 扣减尊享token包用量
if (userId is not null && PremiumPackageConst.ModeIds.Contains(request.Model))
if (userId is not null)
{
var totalTokens = response.Usage.TotalTokens ?? 0;
if (totalTokens > 0)
var isPremium = await _aiModelRepository._DbQueryable
.Where(x => x.ModelId == request.Model)
.Select(x => x.IsPremium)
.FirstAsync();
if (isPremium)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
var totalTokens = response.Usage.TotalTokens ?? 0;
if (totalTokens > 0)
{
await PremiumPackageManager.TryConsumeTokensAsync(userId.Value, totalTokens);
}
}
}
}
@@ -982,7 +1003,7 @@ public class AiGateWayManager : DomainService
}
}
private const string ImageStoreHost = "http://localhost:19001/api/app";
/// <summary>
/// Gemini 生成(Image)-非流式-缓存处理
/// 返回图片绝对路径
@@ -1011,16 +1032,16 @@ public class AiGateWayManager : DomainService
var data = await chatService.GenerateContentAsync(modelDescribe, request, cancellationToken);
//解析json获取base64字符串
var imageBase64 = GeminiGenerateContentAcquirer.GetImageBase64(data);
var imagePrefixBase64 = GeminiGenerateContentAcquirer.GetImagePrefixBase64(data);
//远程调用上传接口将base64转换为URL
var httpClient = LazyServiceProvider.LazyGetRequiredService<IHttpClientFactory>().CreateClient();
var uploadUrl = $"https://ccnetcore.com/prod-api/ai-hub/ai-image/upload-base64";
var content = new StringContent(JsonSerializer.Serialize(imageBase64), Encoding.UTF8, "application/json");
// var uploadUrl = $"https://ccnetcore.com/prod-api/ai-hub/ai-image/upload-base64";
var uploadUrl = $"{ImageStoreHost}/ai-image/upload-base64";
var content = new StringContent(JsonSerializer.Serialize(imagePrefixBase64), Encoding.UTF8, "application/json");
var uploadResponse = await httpClient.PostAsync(uploadUrl, content, cancellationToken);
uploadResponse.EnsureSuccessStatusCode();
var storeUrl = await uploadResponse.Content.ReadAsStringAsync(cancellationToken);
storeUrl = storeUrl.Trim('"'); // 移除JSON字符串的引号
var tokenUsage = new ThorUsageResponse
{
@@ -1047,8 +1068,7 @@ public class AiGateWayManager : DomainService
}
//设置存储base64和url
imageStoreTask.StoreBase64 = imageBase64;
imageStoreTask.SetSuccess(storeUrl);
imageStoreTask.SetSuccess($"{ImageStoreHost}{storeUrl}");
await _imageStoreTaskRepository.UpdateAsync(imageStoreTask);
}

View File

@@ -15,6 +15,7 @@ using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
using Yi.Framework.AiHub.Domain.AiGateWay;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Shared.Attributes;
using Yi.Framework.AiHub.Domain.Shared.Consts;
@@ -34,12 +35,13 @@ public class ChatManager : DomainService
private readonly UsageStatisticsManager _usageStatisticsManager;
private readonly PremiumPackageManager _premiumPackageManager;
private readonly AiGateWayManager _aiGateWayManager;
private readonly ISqlSugarRepository<AiModelEntity, Guid> _aiModelRepository;
public ChatManager(ILoggerFactory loggerFactory,
ISqlSugarRepository<MessageAggregateRoot> messageRepository,
ISqlSugarRepository<AgentStoreAggregateRoot> agentStoreRepository, AiMessageManager aiMessageManager,
UsageStatisticsManager usageStatisticsManager, PremiumPackageManager premiumPackageManager,
AiGateWayManager aiGateWayManager)
AiGateWayManager aiGateWayManager, ISqlSugarRepository<AiModelEntity, Guid> aiModelRepository)
{
_loggerFactory = loggerFactory;
_messageRepository = messageRepository;
@@ -48,6 +50,7 @@ public class ChatManager : DomainService
_usageStatisticsManager = usageStatisticsManager;
_premiumPackageManager = premiumPackageManager;
_aiGateWayManager = aiGateWayManager;
_aiModelRepository = aiModelRepository;
}
/// <summary>
@@ -207,7 +210,12 @@ public class ChatManager : DomainService
await _usageStatisticsManager.SetUsageAsync(userId, modelId, usage, tokenId);
//扣减尊享token包用量
if (PremiumPackageConst.ModeIds.Contains(modelId))
var isPremium = await _aiModelRepository._DbQueryable
.Where(x => x.ModelId == modelId)
.Select(x => x.IsPremium)
.FirstAsync();
if (isPremium)
{
var totalTokens = usage?.TotalTokens ?? 0;
if (totalTokens > 0)

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Logging;
using Volo.Abp.Caching;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers;
/// <summary>
/// 模型管理器
/// </summary>
public class ModelManager : DomainService
{
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
private readonly IDistributedCache<List<string>, string> _distributedCache;
private readonly ILogger<ModelManager> _logger;
private const string PREMIUM_MODEL_IDS_CACHE_KEY = "PremiumModelIds";
public ModelManager(
ISqlSugarRepository<AiModelEntity> aiModelRepository,
IDistributedCache<List<string>, string> distributedCache,
ILogger<ModelManager> logger)
{
_aiModelRepository = aiModelRepository;
_distributedCache = distributedCache;
_logger = logger;
}
/// <summary>
/// 获取所有尊享模型ID列表(使用分布式缓存,10分钟过期)
/// </summary>
/// <returns>尊享模型ID列表</returns>
public async Task<List<string>> GetPremiumModelIdsAsync()
{
var output = await _distributedCache.GetOrAddAsync(
PREMIUM_MODEL_IDS_CACHE_KEY,
async () =>
{
// 从数据库查询
var premiumModelIds = await _aiModelRepository._DbQueryable
.Where(x => x.IsPremium)
.Select(x => x.ModelId)
.ToListAsync();
return premiumModelIds;
},
() => new Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
}
);
return output ?? new List<string>();
}
/// <summary>
/// 判断指定模型是否为尊享模型
/// </summary>
/// <param name="modelId">模型ID</param>
/// <returns>是否为尊享模型</returns>
public async Task<bool> IsPremiumModelAsync(string modelId)
{
if (string.IsNullOrWhiteSpace(modelId))
{
return false;
}
var premiumModelIds = await GetPremiumModelIdsAsync();
return premiumModelIds.Contains(modelId);
}
/// <summary>
/// 清除尊享模型ID缓存
/// </summary>
public async Task ClearPremiumModelIdsCacheAsync()
{
await _distributedCache.RemoveAsync(PREMIUM_MODEL_IDS_CACHE_KEY);
_logger.LogInformation("已清除尊享模型ID分布式缓存");
}
}

View File

@@ -1,6 +1,7 @@
using SqlSugar;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.SqlSugarCore.Abstractions;
@@ -21,43 +22,62 @@ public class TokenValidationResult
/// Token Id
/// </summary>
public Guid TokenId { get; set; }
/// <summary>
/// token
/// </summary>
public string Token { get; set; }
}
public class TokenManager : DomainService
{
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
private readonly ISqlSugarRepository<AiModelEntity, Guid> _aiModelRepository;
public TokenManager(
ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository)
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository,
ISqlSugarRepository<AiModelEntity, Guid> aiModelRepository)
{
_tokenRepository = tokenRepository;
_usageStatisticsRepository = usageStatisticsRepository;
_aiModelRepository = aiModelRepository;
}
/// <summary>
/// 验证Token并返回用户Id和TokenId
/// </summary>
/// <param name="token">Token密钥</param>
/// <param name="tokenOrId">Token密钥或者TokenId</param>
/// <param name="modelId">模型Id用于判断是否是尊享模型需要检查额度</param>
/// <returns>Token验证结果</returns>
public async Task<TokenValidationResult> ValidateTokenAsync(string? token, string? modelId = null)
public async Task<TokenValidationResult> ValidateTokenAsync(object tokenOrId, string? modelId = null)
{
if (token is null)
if (tokenOrId is null)
{
throw new UserFriendlyException("当前请求未包含token", "401");
}
if (!token.StartsWith("yi-"))
TokenAggregateRoot entity;
if (tokenOrId is Guid tokenId)
{
throw new UserFriendlyException("当前请求token非法", "401");
entity = await _tokenRepository._DbQueryable
.Where(x => x.Id == tokenId)
.FirstAsync();
}
var entity = await _tokenRepository._DbQueryable
.Where(x => x.Token == token)
.FirstAsync();
else
{
var tokenStr = tokenOrId.ToString();
if (!tokenStr.StartsWith("yi-"))
{
throw new UserFriendlyException("当前请求token非法", "401");
}
entity = await _tokenRepository._DbQueryable
.Where(x => x.Token == tokenStr)
.FirstAsync();
}
if (entity is null)
{
throw new UserFriendlyException("当前请求token无效", "401");
@@ -76,21 +96,28 @@ public class TokenManager : DomainService
}
// 如果是尊享模型且Token设置了额度限制检查是否超限
if (!string.IsNullOrEmpty(modelId) &&
PremiumPackageConst.ModeIds.Contains(modelId) &&
entity.PremiumQuotaLimit.HasValue)
if (!string.IsNullOrEmpty(modelId) && entity.PremiumQuotaLimit.HasValue)
{
var usedQuota = await GetTokenPremiumUsedQuotaAsync(entity.UserId, entity.Id);
if (usedQuota >= entity.PremiumQuotaLimit.Value)
var isPremium = await _aiModelRepository._DbQueryable
.Where(x => x.ModelId == modelId)
.Select(x => x.IsPremium)
.FirstAsync();
if (isPremium)
{
throw new UserFriendlyException($"当前Token的尊享包额度已用完已使用{usedQuota},限制:{entity.PremiumQuotaLimit.Value}请调整额度限制或使用其他Token", "403");
var usedQuota = await GetTokenPremiumUsedQuotaAsync(entity.UserId, entity.Id);
if (usedQuota >= entity.PremiumQuotaLimit.Value)
{
throw new UserFriendlyException($"当前Token的尊享包额度已用完已使用{usedQuota},限制:{entity.PremiumQuotaLimit.Value}请调整额度限制或使用其他Token", "403");
}
}
}
return new TokenValidationResult
{
UserId = entity.UserId,
TokenId = entity.Id
TokenId = entity.Id,
Token = entity.Token
};
}
@@ -99,7 +126,11 @@ public class TokenManager : DomainService
/// </summary>
private async Task<long> GetTokenPremiumUsedQuotaAsync(Guid userId, Guid tokenId)
{
var premiumModelIds = PremiumPackageConst.ModeIds;
// 先获取所有尊享模型的ModelId列表
var premiumModelIds = await _aiModelRepository._DbQueryable
.Where(x => x.IsPremium)
.Select(x => x.ModelId)
.ToListAsync();
var usedQuota = await _usageStatisticsRepository._DbQueryable
.Where(x => x.UserId == userId && x.TokenId == tokenId && premiumModelIds.Contains(x.ModelId))

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.props" />
<ItemGroup>
<PackageReference Include="OpenAI" Version="2.8.0" />
<PackageReference Include="Volo.Abp.Ddd.Domain" Version="$(AbpVersion)" />
<PackageReference Include="Volo.Abp.Caching" Version="$(AbpVersion)" />
<PackageReference Include="Microsoft.SemanticKernel" Version="1.57.0" />

View File

@@ -7,6 +7,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenAI" Version="2.8.0" />
<PackageReference Include="Volo.Abp.AspNetCore.SignalR" Version="$(AbpVersion)" />
<PackageReference Include="Volo.Abp.Ddd.Domain" Version="$(AbpVersion)" />
<PackageReference Include="Volo.Abp.Caching" Version="$(AbpVersion)" />

View File

@@ -21,6 +21,7 @@ namespace Yi.Abp.Web.Jobs.ai_stock
public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{
return;
// 每次触发只有2/24的概率执行生成新闻
var random = new Random();
var probability = random.Next(0, 24);

View File

@@ -20,6 +20,7 @@ namespace Yi.Abp.Web.Jobs.ai_stock
public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{
return;
await _stockMarketManager.GenerateStocksAsync();
}
}

View File

@@ -32,6 +32,7 @@ public class AutoPassInGoodsJob: HangfireBackgroundWorkerBase
}
public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{
return;
await _marketManager.AutoPassInGoodsAsync();
}
}

View File

@@ -37,7 +37,7 @@ public class AutoRefreshMiningPoolJob : HangfireBackgroundWorkerBase
}
public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{
return;
//刷新矿池
await _miningPoolManager.RefreshMiningPoolAsync();
//刷新用户限制

View File

@@ -20,6 +20,7 @@ public class AutoUpdateCollectiblesValueJob : HangfireBackgroundWorkerBase
public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{
return;
await _collectiblesManager.UpdateAllValueAsync();
}
}

View File

@@ -31,6 +31,7 @@ public class OnHookAutoMiningJob : HangfireBackgroundWorkerBase
}
public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{
return;
await _miningPoolManager.OnHookMiningAsync();
}
}

View File

@@ -113,7 +113,7 @@ namespace Yi.Abp.Web
//本地开发环境,可以禁用作业执行
if (host.IsDevelopment())
{
Configure<AbpBackgroundWorkerOptions>(options => { options.IsEnabled = false; });
//Configure<AbpBackgroundWorkerOptions>(options => { options.IsEnabled = false; });
}
//请求日志
@@ -280,6 +280,7 @@ namespace Yi.Abp.Web
{
options.TokenValidationParameters = new TokenValidationParameters
{
RoleClaimType = "Roles",
ClockSkew = TimeSpan.Zero,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
@@ -298,7 +299,8 @@ namespace Yi.Abp.Web
}
else
{
if (messageContext.Request.Cookies.TryGetValue("Token", out var cookiesToken))
if (!messageContext.Request.Headers.ContainsKey("Authorization") &&
messageContext.Request.Cookies.TryGetValue("Token", out var cookiesToken))
{
messageContext.Token = cookiesToken;
}
@@ -358,8 +360,8 @@ namespace Yi.Abp.Web
var app = context.GetApplicationBuilder();
app.UseRouting();
//app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AgentStoreAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ActivationCodeRecordAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ImageStoreTaskAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ActivationCodeRecordAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();
//跨域

View File

@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(npx vue-tsc --noEmit)",
"Bash(timeout 60 npx vue-tsc:*)"
"Bash(timeout 60 npx vue-tsc:*)",
"Bash(npm run dev:*)"
],
"deny": [],
"ask": []

View File

@@ -112,7 +112,7 @@
<body>
<!-- 加载动画容器 -->
<div id="yixinai-loader" class="loader-container">
<div class="loader-title">意心Ai 2.9</div>
<div class="loader-title">意心Ai 3.0</div>
<div class="loader-subtitle">海外地址仅首次访问预计加载约10秒无需梯子</div>
<div class="loader-logo">
<div class="pulse-box"></div>

View File

@@ -4,5 +4,11 @@
<template>
<router-view />
</template>
<!-- 全局样式 -->
<style scoped lang="scss"></style>
<style>
.popover-content
{
z-index: 99;
}
</style>

View File

@@ -0,0 +1,33 @@
import { get, post } from '@/utils/request';
import type {
GenerateImageRequest,
ImageModel,
PublishImageRequest,
TaskListRequest,
TaskListResponse,
TaskStatusResponse,
} from './types';
export function generateImage(data: GenerateImageRequest) {
return post<string>('/ai-image/generate', data).json();
}
export function getTaskStatus(taskId: string) {
return get<TaskStatusResponse>(`/ai-image/task/${taskId}`).json();
}
export function getMyTasks(params: TaskListRequest) {
return get<TaskListResponse>('/ai-image/my-tasks', params).json();
}
export function getImagePlaza(params: TaskListRequest) {
return get<TaskListResponse>('/ai-image/plaza', params).json();
}
export function publishImage(data: PublishImageRequest) {
return post<void>('/ai-image/publish', data).json();
}
export function getImageModels() {
return post<ImageModel[]>('/ai-image/model').json();
}

View File

@@ -0,0 +1,69 @@
export interface GenerateImageRequest {
tokenId: string;
prompt: string;
modelId: string;
referenceImagesPrefixBase64?: string[];
}
export interface TaskStatusResponse {
id: string;
prompt: string;
storePrefixBase64?: string;
storeUrl?: string;
taskStatus: 'Processing' | 'Success' | 'Fail';
publishStatus: string;
categories: string[];
creationTime: string;
errorInfo?: string;
}
export interface TaskListRequest {
SkipCount: number;
MaxResultCount: number;
TaskStatus?: 'Processing' | 'Success' | 'Fail';
Prompt?: string;
PublishStatus?: 'Unpublished' | 'Published';
StartTime?: string;
EndTime?: string;
OrderByColumn?: string;
IsAsc?: string;
IsAscending?: boolean;
Sorting?: string;
Categories?: string;
UserName?: string;
}
export interface TaskItem {
id: string;
prompt: string;
storePrefixBase64?: string;
storeUrl?: string;
taskStatus: 'Processing' | 'Success' | 'Fail';
publishStatus: string;
categories: string[];
creationTime: string;
errorInfo?: string;
isAnonymous?: boolean;
userName?: string | null;
userId?: string | null;
}
export interface TaskListResponse {
total: number;
items: TaskItem[];
}
export interface PublishImageRequest {
taskId: string;
categories: string[];
isAnonymous?: boolean;
}
export interface ImageModel {
id: string;
modelId: string;
modelName: string;
modelDescribe: string;
remark: string;
isPremiumPackage: boolean;
}

View File

@@ -0,0 +1,100 @@
import { del, get, post, put } from '@/utils/request';
import type {
AiAppDto,
AiAppCreateInput,
AiAppUpdateInput,
AiAppGetListInput,
AiModelDto,
AiModelCreateInput,
AiModelUpdateInput,
AiModelGetListInput,
PagedResultDto,
} from './types';
// ==================== AI应用管理 ====================
// 获取AI应用列表
export function getAppList(params?: AiAppGetListInput) {
const queryParams = new URLSearchParams();
if (params?.searchKey) {
queryParams.append('SearchKey', params.searchKey);
}
if (params?.skipCount !== undefined) {
queryParams.append('SkipCount', params.skipCount.toString());
}
if (params?.maxResultCount !== undefined) {
queryParams.append('MaxResultCount', params.maxResultCount.toString());
}
const queryString = queryParams.toString();
const url = queryString ? `/channel/app?${queryString}` : '/channel/app';
return get<PagedResultDto<AiAppDto>>(url).json();
}
// 根据ID获取AI应用
export function getAppById(id: string) {
return get<AiAppDto>(`/channel/app/${id}`).json();
}
// 创建AI应用
export function createApp(data: AiAppCreateInput) {
return post<AiAppDto>('/channel/app', data).json();
}
// 更新AI应用
export function updateApp(data: AiAppUpdateInput) {
return put<AiAppDto>('/channel/app', data).json();
}
// 删除AI应用
export function deleteApp(id: string) {
return del(`/channel/app/${id}`).json();
}
// ==================== AI模型管理 ====================
// 获取AI模型列表
export function getModelList(params?: AiModelGetListInput) {
const queryParams = new URLSearchParams();
if (params?.searchKey) {
queryParams.append('SearchKey', params.searchKey);
}
if (params?.aiAppId) {
queryParams.append('AiAppId', params.aiAppId);
}
if (params?.isPremiumOnly !== undefined) {
queryParams.append('IsPremiumOnly', params.isPremiumOnly.toString());
}
if (params?.skipCount !== undefined) {
queryParams.append('SkipCount', params.skipCount.toString());
}
if (params?.maxResultCount !== undefined) {
queryParams.append('MaxResultCount', params.maxResultCount.toString());
}
const queryString = queryParams.toString();
const url = queryString ? `/channel/model?${queryString}` : '/channel/model';
return get<PagedResultDto<AiModelDto>>(url).json();
}
// 根据ID获取AI模型
export function getModelById(id: string) {
return get<AiModelDto>(`/channel/model/${id}`).json();
}
// 创建AI模型
export function createModel(data: AiModelCreateInput) {
return post<AiModelDto>('/channel/model', data).json();
}
// 更新AI模型
export function updateModel(data: AiModelUpdateInput) {
return put<AiModelDto>('/channel/model', data).json();
}
// 删除AI模型
export function deleteModel(id: string) {
return del(`/channel/model/${id}`).json();
}

View File

@@ -0,0 +1,121 @@
// 模型类型枚举
export enum ModelTypeEnum {
Chat = 0,
Image = 1,
Embedding = 2,
PremiumChat = 3,
}
// 模型API类型枚举
export enum ModelApiTypeEnum {
OpenAi = 0,
Claude = 1,
}
// AI应用DTO
export interface AiAppDto {
id: string;
name: string;
endpoint: string;
extraUrl?: string;
apiKey: string;
orderNum: number;
creationTime: string;
}
// 创建AI应用输入
export interface AiAppCreateInput {
name: string;
endpoint: string;
extraUrl?: string;
apiKey: string;
orderNum: number;
}
// 更新AI应用输入
export interface AiAppUpdateInput {
id: string;
name: string;
endpoint: string;
extraUrl?: string;
apiKey: string;
orderNum: number;
}
// 获取AI应用列表输入
export interface AiAppGetListInput {
searchKey?: string;
skipCount?: number;
maxResultCount?: number;
}
// AI模型DTO
export interface AiModelDto {
id: string;
handlerName: string;
modelId: string;
name: string;
description?: string;
orderNum: number;
aiAppId: string;
extraInfo?: string;
modelType: ModelTypeEnum;
modelApiType: ModelApiTypeEnum;
multiplier: number;
multiplierShow: number;
providerName?: string;
iconUrl?: string;
isPremium: boolean;
}
// 创建AI模型输入
export interface AiModelCreateInput {
handlerName: string;
modelId: string;
name: string;
description?: string;
orderNum: number;
aiAppId: string;
extraInfo?: string;
modelType: ModelTypeEnum;
modelApiType: ModelApiTypeEnum;
multiplier: number;
multiplierShow: number;
providerName?: string;
iconUrl?: string;
isPremium: boolean;
}
// 更新AI模型输入
export interface AiModelUpdateInput {
id: string;
handlerName: string;
modelId: string;
name: string;
description?: string;
orderNum: number;
aiAppId: string;
extraInfo?: string;
modelType: ModelTypeEnum;
modelApiType: ModelApiTypeEnum;
multiplier: number;
multiplierShow: number;
providerName?: string;
iconUrl?: string;
isPremium: boolean;
}
// 获取AI模型列表输入
export interface AiModelGetListInput {
searchKey?: string;
aiAppId?: string;
isPremiumOnly?: boolean;
skipCount?: number;
maxResultCount?: number;
}
// 分页结果
export interface PagedResultDto<T> {
items: T[];
totalCount: number;
}

View File

@@ -6,3 +6,4 @@ export * from './model';
export * from './pay';
export * from './session';
export * from './user';
export * from './aiImage';

View File

@@ -138,7 +138,8 @@ export function disableToken(id: string) {
// 新增接口2
// 获取可选择的token信息
export function getSelectableTokenInfo() {
return get<any>('/token/select-list').json();
// return get<any>('/token/select-list').json();
return get<any>('/token/select-list?includeDefault=false').json();
}
/*
返回数据

View File

@@ -3,7 +3,6 @@ import type { GoodsItem } from '@/api/pay';
import { CircleCheck, Loading } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { createOrder, getOrderStatus } from '@/api';
import { getGoodsList, GoodsCategoryType } from '@/api/pay';
import SupportModelList from '@/components/userPersonalCenter/components/SupportModelList.vue';
@@ -305,8 +304,6 @@ async function checkPaymentStatus(outTradeNo: string) {
}
}
const router = useRouter();
function toggleDetails() {
showDetails.value = !showDetails.value;
}
@@ -322,7 +319,8 @@ function onClose() {
function goToActivation() {
close();
userStore.openUserCenter('activationCode');
// 使用 window.location 进行跳转,避免 router 注入问题
window.location.href = '/console/activation';
}
</script>

View File

@@ -31,33 +31,33 @@ class Particle {
this.x = x;
this.y = y;
this.hue = hue;
// Explosive physics
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 15 + 2;
const speed = Math.random() * 15 + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.alpha = 1;
this.decay = Math.random() * 0.015 + 0.005;
this.gravity = 0.05;
this.friction = 0.96;
this.friction = 0.96;
this.size = Math.random() * 3 + 1;
this.brightness = 50; // Standard brightness for white bg visibility (0-100% HSL L value)
this.flicker = Math.random() > 0.5;
this.flicker = Math.random() > 0.5;
}
update() {
this.vx *= this.friction;
this.vy *= this.friction;
this.vy += this.gravity;
this.x += this.vx;
this.y += this.vy;
this.alpha -= this.decay;
this.hue += 0.5;
this.hue += 0.5;
}
draw(ctx: CanvasRenderingContext2D) {
@@ -65,9 +65,9 @@ class Particle {
// On white background:
// We want high saturation (100%) and medium lightness (50%) to make colors pop against white.
// If lightness is too high (like 80-100), it fades into white.
const lightness = this.flicker ? Math.random() * 20 + 40 : this.brightness;
const lightness = this.flicker ? Math.random() * 20 + 40 : this.brightness;
ctx.fillStyle = `hsla(${this.hue}, 100%, ${lightness}%, ${this.alpha})`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
@@ -93,7 +93,7 @@ class Shockwave {
}
update() {
this.radius += 15;
this.radius += 15;
this.alpha -= 0.05;
this.lineWidth -= 0.2;
}
@@ -132,10 +132,10 @@ function animate() {
if (!ctx) return;
// Clear with transparent fade for trails on white
// 'destination-out' erases content.
// 'destination-out' erases content.
// To leave a trail on a white background (canvas is transparent over white gradient):
// We need to gently erase what's there.
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; // Alpha controls trail length
ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -177,14 +177,14 @@ function triggerCelebration() {
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
}
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// 1. Initial Mega Explosion
triggerShake();
createExplosion(cx, cy, Math.random() * 360);
// Start loop
animate();
@@ -194,15 +194,15 @@ function triggerCelebration() {
count++;
const rx = cx + (Math.random() - 0.5) * canvas.width * 0.8;
const ry = cy + (Math.random() - 0.5) * canvas.height * 0.8;
createExplosion(rx, ry, Math.random() * 360);
if (count % 3 === 0) triggerShake();
if (count % 3 === 0) triggerShake();
if (count > 25) {
clearInterval(timer);
}
}, 120);
}, 120);
}
async function handleRedeem() {
@@ -221,7 +221,7 @@ async function handleRedeem() {
duration: 3000,
showClose: true,
});
activationCode.value = '';
activationCode.value = '';
} catch (error: any) {
// console.error(error);
} finally {
@@ -238,13 +238,13 @@ onUnmounted(() => {
<div ref="containerRef" class="activation-container" :class="{ 'shake-anim': isShaking }">
<!-- Removed Dark overlay -->
<canvas ref="canvasRef" class="fireworks-canvas"></canvas>
<div class="content-wrapper">
<div class="gift-icon-wrapper">
<div class="gift-box">🎁</div>
<div class="gift-glow"></div>
</div>
<h2 class="title">激活码兑换</h2>
<p class="subtitle">开启您的专属惊喜权益</p>
@@ -258,9 +258,9 @@ onUnmounted(() => {
clearable
@keyup.enter="handleRedeem"
/>
<el-button
class="redeem-btn"
<el-button
class="redeem-btn"
:loading="loading"
@click="handleRedeem"
>
@@ -321,7 +321,7 @@ onUnmounted(() => {
.content-wrapper {
position: relative;
z-index: 11;
z-index: 11;
width: 100%;
max-width: 480px;
padding: 40px;

View File

@@ -408,7 +408,10 @@ async function handleFlipCard(record: CardFlipRecord) {
await new Promise(resolve => requestAnimationFrame(resolve));
// 4. 移动克隆卡片到屏幕中心并放大(考虑边界限制)
const scale = Math.min(1.8, window.innerWidth / rect.width * 0.6); // 动态计算缩放比例
// 移动端使用更小的缩放比例
const isMobile = window.innerWidth <= 768;
const maxScale = isMobile ? 1.5 : 1.8;
const scale = Math.min(maxScale, window.innerWidth / rect.width * (isMobile ? 0.5 : 0.6));
const scaledWidth = rect.width * scale;
const scaledHeight = rect.height * scale;
@@ -416,8 +419,8 @@ async function handleFlipCard(record: CardFlipRecord) {
let centerX = window.innerWidth / 2;
let centerY = window.innerHeight / 2;
// 边界检查:确保卡片完全在视口内(留20px边距)
const margin = 20;
// 边界检查:确保卡片完全在视口内(移动端留更多边距)
const margin = isMobile ? 30 : 20;
const minX = scaledWidth / 2 + margin;
const maxX = window.innerWidth - scaledWidth / 2 - margin;
const minY = scaledHeight / 2 + margin;
@@ -1253,6 +1256,11 @@ function getCardClass(record: CardFlipRecord): string[] {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
@media (max-width: 768px) {
padding: 12px;
border-radius: 12px;
}
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
@@ -1277,15 +1285,36 @@ function getCardClass(record: CardFlipRecord): string[] {
.lucky-float-ball {
position: fixed;
left: 50%;
/* left: 20px; */
/* top: 20px; */
transform: translateX(-50%);
z-index: 999;
bottom: 20px;
transition: all 0.3s
cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
&:hover {
transform: scale(1.1);
transform: translateX(-50%) scale(1.1);
}
@media (max-width: 768px) {
bottom: 15px;
.lucky-circle {
width: 70px;
height: 70px;
}
.lucky-content .lucky-icon {
font-size: 20px;
}
.lucky-content .lucky-text {
font-size: 12px;
}
.lucky-label {
font-size: 11px;
margin-top: 4px;
}
}
&.lucky-full {
@@ -1408,6 +1437,12 @@ function getCardClass(record: CardFlipRecord): string[] {
animation: slideIn 0.5s ease;
flex-wrap: wrap;
@media (max-width: 768px) {
padding: 8px 10px;
gap: 8px;
margin-bottom: 10px;
}
.compact-stats {
display: flex;
align-items: center;
@@ -1497,6 +1532,11 @@ function getCardClass(record: CardFlipRecord): string[] {
border-radius: 12px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
@media (max-width: 768px) {
padding: 12px 20px;
border-radius: 8px;
}
}
.shuffle-text {
@@ -1505,6 +1545,15 @@ function getCardClass(record: CardFlipRecord): string[] {
color: #fff;
text-shadow: 0 2px 8px rgba(255, 215, 0, 0.6);
animation: textPulse 1.5s ease-in-out infinite;
white-space: nowrap;
@media (max-width: 768px) {
font-size: 14px;
}
@media (max-width: 480px) {
font-size: 12px;
}
}
}
@@ -1515,8 +1564,13 @@ function getCardClass(record: CardFlipRecord): string[] {
max-width: 100%;
@media (max-width: 768px) {
grid-template-columns: repeat(5, 1fr);
gap: 6px;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
// 洗牌阶段样式
@@ -1683,6 +1737,11 @@ function getCardClass(record: CardFlipRecord): string[] {
background: rgba(0, 0, 0, 0.2);
padding: 2px 6px;
border-radius: 4px;
@media (max-width: 768px) {
font-size: 9px;
padding: 2px 5px;
}
}
.card-content {
@@ -1691,6 +1750,10 @@ function getCardClass(record: CardFlipRecord): string[] {
align-items: center;
gap: 8px;
z-index: 1;
@media (max-width: 768px) {
gap: 6px;
}
}
// 系统logo样式优化为居中圆形更丰富的效果
@@ -1709,6 +1772,12 @@ function getCardClass(record: CardFlipRecord): string[] {
z-index: 3;
filter: brightness(1.1);
@media (max-width: 768px) {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 1);
}
// 外层光晕效果
&::before {
content: '';
@@ -1741,6 +1810,10 @@ function getCardClass(record: CardFlipRecord): string[] {
font-weight: bold;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
@media (max-width: 768px) {
font-size: 24px;
}
}
.card-type-badge {
@@ -1753,6 +1826,12 @@ function getCardClass(record: CardFlipRecord): string[] {
border-radius: 10px;
backdrop-filter: blur(4px);
z-index: 2;
@media (max-width: 768px) {
font-size: 9px;
padding: 2px 6px;
bottom: 4px;
}
}
.card-shine {
@@ -1827,6 +1906,10 @@ function getCardClass(record: CardFlipRecord): string[] {
position: relative;
z-index: 1;
@media (max-width: 768px) {
padding: 8px;
}
// logo水印样式
.result-watermark {
position: absolute;
@@ -1869,6 +1952,10 @@ function getCardClass(record: CardFlipRecord): string[] {
z-index: 1;
filter: drop-shadow(0 4px 8px rgba(255, 215, 0, 0.5));
margin-bottom: 4px;
@media (max-width: 768px) {
font-size: 36px;
}
}
.result-text {
@@ -1883,6 +1970,11 @@ function getCardClass(record: CardFlipRecord): string[] {
letter-spacing: 2px;
position: relative;
@media (max-width: 768px) {
font-size: 16px;
letter-spacing: 1px;
}
// 文字外发光
&::after {
content: attr(data-text);
@@ -1907,6 +1999,11 @@ function getCardClass(record: CardFlipRecord): string[] {
position: relative;
filter: drop-shadow(0 3px 10px rgba(255, 215, 0, 0.6));
@media (max-width: 768px) {
font-size: 32px;
margin: 8px 0;
}
// 金色光效边框
&::before {
content: attr(data-amount);
@@ -1932,6 +2029,11 @@ function getCardClass(record: CardFlipRecord): string[] {
letter-spacing: 3px;
text-transform: uppercase;
margin-top: 4px;
@media (max-width: 768px) {
font-size: 14px;
letter-spacing: 2px;
}
}
}
@@ -1953,6 +2055,10 @@ function getCardClass(record: CardFlipRecord): string[] {
margin-bottom: 6px;
filter: drop-shadow(0 2px 6px rgba(147, 112, 219, 0.3));
animation: gentleBounce 2s ease-in-out infinite;
@media (max-width: 768px) {
font-size: 36px;
}
}
.result-text {
@@ -1964,6 +2070,10 @@ function getCardClass(record: CardFlipRecord): string[] {
margin: 8px 0;
z-index: 1;
letter-spacing: 1px;
@media (max-width: 768px) {
font-size: 15px;
}
}
.result-tip {
@@ -1972,6 +2082,10 @@ function getCardClass(record: CardFlipRecord): string[] {
z-index: 1;
margin-top: 6px;
font-weight: 500;
@media (max-width: 768px) {
font-size: 12px;
}
}
}
@@ -1994,6 +2108,11 @@ function getCardClass(record: CardFlipRecord): string[] {
z-index: 1;
filter: drop-shadow(0 4px 12px rgba(255, 215, 0, 0.8));
margin: 10px 0;
@media (max-width: 768px) {
font-size: 42px;
margin: 8px 0;
}
}
.mystery-text {
@@ -2004,6 +2123,11 @@ function getCardClass(record: CardFlipRecord): string[] {
z-index: 1;
letter-spacing: 4px;
margin: 8px 0;
@media (max-width: 768px) {
font-size: 18px;
letter-spacing: 2px;
}
}
.mystery-hint {
@@ -2012,6 +2136,11 @@ function getCardClass(record: CardFlipRecord): string[] {
z-index: 1;
letter-spacing: 2px;
margin-top: 6px;
@media (max-width: 768px) {
font-size: 12px;
letter-spacing: 1px;
}
}
.mystery-stars {
@@ -2060,6 +2189,11 @@ function getCardClass(record: CardFlipRecord): string[] {
margin-bottom: 16px;
text-align: center;
line-height: 1.6;
@media (max-width: 768px) {
font-size: 13px;
margin-bottom: 12px;
}
}
.code-input {
@@ -2078,10 +2212,19 @@ function getCardClass(record: CardFlipRecord): string[] {
border: 1px solid #bae6fd;
border-radius: 12px;
@media (max-width: 768px) {
padding: 20px 12px;
}
.filled-icon {
font-size: 48px;
display: block;
margin-bottom: 12px;
@media (max-width: 768px) {
font-size: 40px;
margin-bottom: 10px;
}
}
.filled-text {
@@ -2090,6 +2233,10 @@ function getCardClass(record: CardFlipRecord): string[] {
line-height: 1.6;
margin: 0;
font-weight: 500;
@media (max-width: 768px) {
font-size: 14px;
}
}
}
}
@@ -2128,12 +2275,22 @@ function getCardClass(record: CardFlipRecord): string[] {
border-radius: 12px;
margin-bottom: 16px;
@media (max-width: 768px) {
padding: 14px;
margin-bottom: 12px;
}
.code-text {
font-size: 28px;
font-weight: bold;
color: #fff;
letter-spacing: 6px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
@media (max-width: 768px) {
font-size: 24px;
letter-spacing: 4px;
}
}
}
@@ -2142,8 +2299,17 @@ function getCardClass(record: CardFlipRecord): string[] {
gap: 8px;
margin-bottom: 12px;
@media (max-width: 768px) {
flex-direction: column;
gap: 10px;
}
.el-button {
flex: 1;
@media (max-width: 768px) {
width: 100%;
}
}
}
@@ -2153,6 +2319,10 @@ function getCardClass(record: CardFlipRecord): string[] {
line-height: 1.5;
margin: 0 0 12px 0;
@media (max-width: 768px) {
font-size: 12px;
}
strong {
color: #f56c6c;
font-weight: 600;
@@ -2199,12 +2369,20 @@ function getCardClass(record: CardFlipRecord): string[] {
padding: 12px;
text-align: left;
@media (max-width: 768px) {
padding: 10px;
}
.share-preview-title {
font-size: 14px;
font-weight: bold;
color: #303133;
margin-bottom: 8px;
text-align: center;
@media (max-width: 768px) {
font-size: 13px;
}
}
.share-preview-content {
@@ -2218,6 +2396,12 @@ function getCardClass(record: CardFlipRecord): string[] {
max-height: 200px;
overflow-y: auto;
@media (max-width: 768px) {
font-size: 12px;
padding: 8px;
max-height: 150px;
}
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 4px;
@@ -2276,9 +2460,17 @@ function getCardClass(record: CardFlipRecord): string[] {
display: inline-block;
margin-bottom: 20px;
@media (max-width: 768px) {
margin-bottom: 16px;
}
.double-icon {
font-size: 64px;
animation: bounce 1s infinite;
@media (max-width: 768px) {
font-size: 52px;
}
}
.double-sparkle {
@@ -2287,6 +2479,10 @@ function getCardClass(record: CardFlipRecord): string[] {
right: -10px;
font-size: 32px;
animation: spin 2s linear infinite;
@media (max-width: 768px) {
font-size: 26px;
}
}
}
@@ -2295,6 +2491,11 @@ function getCardClass(record: CardFlipRecord): string[] {
font-weight: bold;
color: #303133;
margin-bottom: 16px;
@media (max-width: 768px) {
font-size: 18px;
margin-bottom: 12px;
}
}
.double-text {
@@ -2303,10 +2504,19 @@ function getCardClass(record: CardFlipRecord): string[] {
color: #606266;
margin-bottom: 24px;
@media (max-width: 768px) {
font-size: 14px;
margin-bottom: 20px;
}
.highlight {
color: #f56c6c;
font-weight: bold;
font-size: 16px;
@media (max-width: 768px) {
font-size: 15px;
}
}
}

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import type { DailyTaskItem, DailyTaskStatusOutput } from '@/api/dailyTask/types';
import { ElMessage } from 'element-plus';
import { getTodayTaskStatus, claimTaskReward } from '@/api/dailyTask';
import type { DailyTaskStatusOutput, DailyTaskItem } from '@/api/dailyTask/types';
import { onMounted, ref } from 'vue';
import { claimTaskReward, getTodayTaskStatus } from '@/api/dailyTask';
const taskData = ref<DailyTaskStatusOutput | null>(null);
const loading = ref(false);
@@ -17,15 +17,18 @@ async function fetchTaskStatus() {
try {
const res = await getTodayTaskStatus();
taskData.value = res.data;
} catch (error: any) {
}
catch (error: any) {
ElMessage.error(error?.message || '获取任务状态失败');
} finally {
}
finally {
loading.value = false;
}
}
async function handleClaim(task: DailyTaskItem) {
if (task.status !== 1) return;
if (task.status !== 1)
return;
claiming.value[task.level] = true;
try {
@@ -34,9 +37,11 @@ async function handleClaim(task: DailyTaskItem) {
// 刷新任务状态
await fetchTaskStatus();
} catch (error: any) {
}
catch (error: any) {
ElMessage.error(error?.message || '领取奖励失败');
} finally {
}
finally {
claiming.value[task.level] = false;
}
}
@@ -76,8 +81,10 @@ function getButtonClass(task: DailyTaskItem): string {
// 获取进度条颜色
function getProgressColor(task: DailyTaskItem): string {
if (task.status === 2) return '#FFD700'; // 已完成:金色
if (task.status === 1) return '#67C23A'; // 可领取:绿
if (task.status === 2)
return '#FFD700'; // 已完成:金
if (task.status === 1)
return '#67C23A'; // 可领取:绿色
return '#409EFF'; // 进行中:蓝色
}
</script>
@@ -86,15 +93,21 @@ function getProgressColor(task: DailyTaskItem): string {
<div v-loading="loading" class="daily-task-container">
<div class="task-header">
<h2>每日任务</h2>
<p class="task-desc">完成每日任务领取额外尊享包 Token 奖励可累加重复</p>
<p class="task-desc">
完成每日任务领取额外尊享包 Token 奖励可累加重复
</p>
</div>
<div v-if="taskData" class="task-content">
<!-- 今日消耗统计 -->
<div class="consumption-card">
<div class="consumption-icon">🔥</div>
<div class="consumption-icon">
🔥
</div>
<div class="consumption-info">
<div class="consumption-label">今日尊享包消耗</div>
<div class="consumption-label">
今日尊享包消耗
</div>
<div class="consumption-value">
{{ formatTokenDisplay(taskData.todayConsumedTokens) }} Tokens
</div>
@@ -109,7 +122,7 @@ function getProgressColor(task: DailyTaskItem): string {
class="task-item"
:class="{
'task-completed': task.status === 2,
'task-claimable': task.status === 1
'task-claimable': task.status === 1,
}"
>
<div class="task-icon">
@@ -187,7 +200,6 @@ function getProgressColor(task: DailyTaskItem): string {
<style scoped>
.daily-task-container {
padding: 20px;
min-height: 400px;
height: 100%;
overflow-y: auto;

View File

@@ -83,10 +83,9 @@ onMounted(() => {
<style scoped lang="scss">
.premium-service {
padding: 24px;
height: 100%;
overflow-y: auto;
background: linear-gradient(to bottom, #f8f9fa 0%, #ffffff 100%);
//background: linear-gradient(to bottom, #f8f9fa 0%, #ffffff 100%);
// 美化滚动条
&::-webkit-scrollbar {
@@ -127,7 +126,6 @@ onMounted(() => {
/* 响应式布局 */
@media (max-width: 768px) {
.premium-service {
padding: 12px;
}
.usage-list-wrapper {

View File

@@ -549,6 +549,7 @@ onBeforeUnmount(() => {
</el-option>
</el-select>
<el-button
v-if="false"
:icon="FullScreen"
circle
plain
@@ -598,18 +599,12 @@ onBeforeUnmount(() => {
<style scoped>
.usage-statistics {
padding: 20px;
position: relative;
background: linear-gradient(135deg, #fff 0%, #f8f9fa 100%);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
height: 100%;
overflow-y: auto;
}
.usage-statistics:hover {
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.12);
}
.usage-statistics.fullscreen-mode {
@@ -619,7 +614,6 @@ onBeforeUnmount(() => {
right: 0;
bottom: 0;
z-index: 2000;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 30px;
overflow-y: auto;
border-radius: 0;
@@ -631,7 +625,6 @@ onBeforeUnmount(() => {
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e9ecef;
}
.header-actions {
@@ -655,7 +648,6 @@ onBeforeUnmount(() => {
.option-label {
text-decoration: line-through;
color: #909399;
}
}
}
@@ -670,7 +662,6 @@ onBeforeUnmount(() => {
}
&.disabled-icon {
color: #c0c4cc;
}
}
@@ -709,14 +700,10 @@ onBeforeUnmount(() => {
.chart-card {
margin-bottom: 30px;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
overflow: hidden;
background: white;
}
.chart-card:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
transform: translateY(-4px);
}

View File

@@ -105,16 +105,15 @@ function bindWechat() {
<template>
<div class="user-profile">
<!-- 顶部标题 -->
<div class="header">
<h2>
<el-icon><User /></el-icon>
个人信息
</h2>
</div>
<!-- 用户卡片 -->
<el-card class="profile-card" shadow="hover">
<!-- 顶部标题 -->
<div class="header">
<h2>
<el-icon><User /></el-icon>
个人信息
</h2>
</div>
<!-- 头像和基本信息区域 -->
<div class="user-header-section">
<!-- 头像区域 -->
@@ -138,7 +137,9 @@ function bindWechat() {
<!-- 用户名称和状态 -->
<div class="user-info-quick">
<h3 class="user-name">{{ userNick }}</h3>
<h3 class="user-name">
{{ userNick }}
</h3>
<div class="user-tags">
<el-tag v-if="userVipStatus" type="warning" effect="dark" size="large">
<el-icon><Promotion /></el-icon>
@@ -153,8 +154,12 @@ function bindWechat() {
</div>
<div class="user-stats">
<div class="stat-item">
<div class="stat-value">{{ formatDate(user.creationTime)?.split(' ')[0] || '-' }}</div>
<div class="stat-label">注册时间</div>
<div class="stat-value">
{{ formatDate(user.creationTime)?.split(' ')[0] || '-' }}
</div>
<div class="stat-label">
注册时间
</div>
</div>
</div>
</div>
@@ -171,7 +176,9 @@ function bindWechat() {
<el-icon><User /></el-icon>
用户名
</div>
<div class="info-value">{{ user.userName || '-' }}</div>
<div class="info-value">
{{ user.userName || '-' }}
</div>
</div>
<!-- 昵称 -->
@@ -180,7 +187,9 @@ function bindWechat() {
<el-icon><Postcard /></el-icon>
昵称
</div>
<div class="info-value">{{ userNick }}</div>
<div class="info-value">
{{ userNick }}
</div>
</div>
<!-- 邮箱 -->
@@ -215,7 +224,9 @@ function bindWechat() {
<!-- 微信绑定 -->
<div class="info-item full-width">
<div class="info-label">
<el-icon color="#07C160"><ChatDotRound /></el-icon>
<el-icon color="#07C160">
<ChatDotRound />
</el-icon>
微信绑定
</div>
<div class="info-value wechat-binding">
@@ -292,7 +303,6 @@ function bindWechat() {
<style scoped>
.user-profile {
padding: 20px;
height: 100%;
overflow-y: auto;
}

View File

@@ -1,4 +1,4 @@
export type LayoutType = | 'vertical' | 'blankPage';
export type LayoutType = 'default' | 'vertical' | 'blankPage' | 'blankPage';
// 仿豆包折叠逻辑
export type CollapseType
@@ -25,7 +25,7 @@ export interface DesignConfigState {
// 是否折叠菜单
isCollapse: boolean;
// 安全区是否被悬停
isSafeAreaHover: boolean;
isCollapseConversationList: boolean;
// 跟踪是否首次激活悬停
hasActivatedHover: boolean;
}
@@ -65,15 +65,13 @@ const design: DesignConfigState = {
// 需要自定义路由动画可以把 Main 组件样式代码注释放开从新对话切换到带id的路由时会执行这个动画样式
pageAnimateType: 'zoom-fade',
// 布局模式 (纵向vertical | ... | 自己定义)
layout: 'vertical',
layout: 'default',
// 折叠类型
collapseType: 'followSystem',
// 是否折叠菜单
// 是否折叠对话记录菜单
isCollapse: false,
// 安全区是否被悬停
isSafeAreaHover: false,
// 跟踪是否首次激活悬停
hasActivatedHover: false,
// 是否折叠对话记录菜单
isCollapseConversationList: false,
};
export default design;

View File

@@ -12,4 +12,4 @@ export const COLLAPSE_THRESHOLD: number = 600;
export const SIDE_BAR_WIDTH: number = 280;
// 路由白名单地址[本地存在的路由 staticRouter.ts 中]
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/not_login', '/products', '/model-library', '/403', '/404'];
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/conversation', '/chat/image', '/chat/video', '/model-library', '/403', '/404'];

View File

@@ -0,0 +1,110 @@
/**
* 权限配置文件
* 用于配置特定页面的访问权限
*/
/**
* 权限配置接口
*/
export interface PermissionConfig {
/** 路由路径 */
path: string;
/** 允许访问的用户名列表 */
allowedUsers: string[];
/** 权限描述 */
description?: string;
}
/**
* 页面权限配置列表
* 在这里配置需要特殊权限控制的页面
*/
export const PAGE_PERMISSIONS: PermissionConfig[] = [
{
path: '/console/channel',
allowedUsers: ['cc', 'Guo'],
description: '渠道商管理页面 - 仅限cc和Guo用户访问',
},
// 可以在这里继续添加其他需要权限控制的页面
// {
// path: '/console/admin',
// allowedUsers: ['admin', 'superadmin'],
// description: '管理员页面',
// },
];
/**
* 检查用户是否有权限访问指定路径
* @param path 路由路径
* @param userName 用户名
* @returns 是否有权限
*/
export function checkPagePermission(path: string, userName: string | undefined): boolean {
// 如果没有用户名返回false
if (!userName) {
return false;
}
// 查找该路径的权限配置
const permissionConfig = PAGE_PERMISSIONS.find(config => config.path === path);
// 如果没有配置权限说明该页面不需要特殊权限返回true
if (!permissionConfig) {
return true;
}
// 检查用户名是否在允许列表中(不区分大小写)
return permissionConfig.allowedUsers.some(
allowedUser => allowedUser.toLowerCase() === userName.toLowerCase(),
);
}
/**
* 获取用户无权访问的路由列表
* @param userName 用户名
* @returns 无权访问的路由路径数组
*/
export function getRestrictedRoutes(userName: string | undefined): string[] {
if (!userName) {
return PAGE_PERMISSIONS.map(config => config.path);
}
return PAGE_PERMISSIONS.filter(
config => !config.allowedUsers.some(
allowedUser => allowedUser.toLowerCase() === userName.toLowerCase(),
),
).map(config => config.path);
}
/**
* 检查路由是否需要权限控制
* @param path 路由路径
* @returns 是否需要权限控制
*/
export function isRestrictedRoute(path: string): boolean {
return PAGE_PERMISSIONS.some(config => config.path === path);
}
/**
* 过滤菜单路由,移除用户无权访问的菜单项
* @param routes 路由配置数组
* @param userName 用户名
* @returns 过滤后的路由配置数组
*/
export function filterMenuRoutes(routes: any[], userName: string | undefined): any[] {
return routes.filter((route) => {
// 检查当前路由是否有权限
const hasPermission = checkPagePermission(route.path, userName);
if (!hasPermission) {
return false;
}
// 如果有子路由,递归过滤
if (route.children && route.children.length > 0) {
route.children = filterMenuRoutes(route.children, userName);
}
return true;
});
}

View File

@@ -0,0 +1,72 @@
import { useBreakpoints, useWindowSize } from '@vueuse/core';
import { computed } from 'vue';
// 断点定义
export const breakpoints = {
xs: 0, // 手机竖屏 < 640px
sm: 640, // 手机横屏 ≥ 640px
md: 768, // 平板 ≥ 768px
lg: 1024, // 小桌面 ≥ 1024px
xl: 1280, // 桌面 ≥ 1280px
xxl: 1536, // 大桌面 ≥ 1536px
};
export function useResponsive() {
const bp = useBreakpoints(breakpoints);
// 设备类型判断
const isMobile = bp.smaller('md'); // < 768px
const isTablet = bp.between('md', 'lg'); // 768px - 1024px
const isDesktop = bp.greaterOrEqual('lg'); // ≥ 1024px
// 精确断点
const isXs = bp.smaller('sm'); // < 640px
const isSm = bp.between('sm', 'md'); // 640px - 768px
const isMd = bp.between('md', 'lg'); // 768px - 1024px
const isLg = bp.between('lg', 'xl'); // 1024px - 1280px
const isXl = bp.between('xl', 'xxl'); // 1280px - 1536px
const isXxl = bp.greater('xxl'); // > 1536px
// 监听窗口变化
const { width, height } = useWindowSize();
// 方向检测
const isPortrait = computed(() => height.value > width.value);
const isLandscape = computed(() => width.value > height.value);
// 当前断点名称
const currentBreakpoint = computed(() => {
if (isXs.value) return 'xs';
if (isSm.value) return 'sm';
if (isMd.value) return 'md';
if (isLg.value) return 'lg';
if (isXl.value) return 'xl';
return 'xxl';
});
return {
// 设备类型
isMobile,
isTablet,
isDesktop,
// 精确断点
isXs,
isSm,
isMd,
isLg,
isXl,
isXxl,
// 尺寸
width,
height,
// 方向
isPortrait,
isLandscape,
// 当前断点名称
currentBreakpoint,
};
}

View File

@@ -19,8 +19,9 @@ export function useWindowWidthObserver(
const isAboveThreshold = ref(false);
const thresholdRef = ref(threshold);
let prevIsAbove = false; // 记录上一次状态,避免重复触发
// 待定 待梳理 1227
// 默认逻辑:修改全局折叠状态
// eslint-disable-next-line unused-imports/no-unused-vars
const updateCollapseState = (isAbove: boolean) => {
// 判断当前的折叠状态
switch (designStore.collapseType) {
@@ -70,7 +71,7 @@ export function useWindowWidthObserver(
onChange(newIsAbove);
}
else {
updateCollapseState(newIsAbove);
// updateCollapseState(newIsAbove);
}
}
};

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import SystemAnnouncementDialog from '@/components/SystemAnnouncementDialog/index.vue';
import Header from '@/layouts/components/Header/index.vue';
</script>
<template>
<el-container class="layout-container">
<el-header class="layout-header">
<Header />
</el-header>
<el-container class="layout-container-main">
<router-view />
</el-container>
</el-container>
<!-- 系统公告弹窗 -->
<SystemAnnouncementDialog />
</template>
<style scoped>
.layout-container {
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--color-white);
.layout-header {
padding: 0;
border-bottom: var(--header-border) ;
}
.layout-container-main {
height: calc(100vh - var(--header-container-default-height));
padding: 0;
}
}
</style>

View File

@@ -1,8 +1,288 @@
<!-- 手机端布局 -->
<script setup></script>
<!-- 移动端布局 -->
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores';
const router = useRouter();
const userStore = useUserStore();
// 侧边栏抽屉状态
const drawerVisible = ref(false);
// 底部导航菜单
const bottomMenus = [
{
key: 'chat',
label: '对话',
icon: 'ChatDotRound',
path: '/chat/conversation',
},
{
key: 'image',
label: '图片',
icon: 'Picture',
path: '/chat/image',
},
{
key: 'video',
label: '视频',
icon: 'VideoCamera',
path: '/chat/video',
},
{
key: 'console',
label: '我的',
icon: 'User',
path: '/console',
},
];
// 侧边栏菜单
const sidebarMenus = [
{
key: 'model-library',
label: '模型库',
icon: 'Box',
path: '/model-library',
},
{
key: 'pricing',
label: '购买',
icon: 'ShoppingCart',
path: '/pricing',
},
{
key: 'logout',
label: '退出登录',
icon: 'SwitchButton',
action: 'logout',
},
];
// 当前路由
const currentPath = computed(() => router.currentRoute.value.path);
// 当前激活的底部菜单
const activeBottomMenu = computed(() => {
const path = currentPath.value;
if (path.includes('/chat/conversation')) return 'chat';
if (path.includes('/chat/image')) return 'image';
if (path.includes('/chat/video')) return 'video';
if (path.includes('/console')) return 'console';
return 'chat';
});
// 打开抽屉
function openDrawer() {
drawerVisible.value = true;
}
// 底部菜单点击
function handleBottomMenuClick(menu: typeof bottomMenus[0]) {
router.push(menu.path);
}
// 侧边栏菜单点击
function handleSidebarMenuClick(menu: typeof sidebarMenus[0]) {
if (menu.action === 'logout') {
userStore.logout();
drawerVisible.value = false;
}
else if (menu.path) {
router.push(menu.path);
drawerVisible.value = false;
}
}
</script>
<template>
<div />
<div class="mobile-layout">
<!-- 顶部栏 -->
<div class="mobile-header">
<el-button circle @click="openDrawer">
<el-icon><i-ep-menu /></el-icon>
</el-button>
<div class="header-title">意心AI</div>
<div class="header-avatar">
<el-avatar v-if="userStore.userInfo" :size="32" :src="userStore.userInfo.avatar">
{{ userStore.userInfo.name?.charAt(0) }}
</el-avatar>
</div>
</div>
<!-- 主内容区 -->
<div class="mobile-main">
<router-view />
</div>
<!-- 底部导航 -->
<div class="mobile-bottom-nav">
<div
v-for="menu in bottomMenus"
:key="menu.key"
class="nav-item"
:class="{ active: activeBottomMenu === menu.key }"
@click="handleBottomMenuClick(menu)"
>
<el-icon class="nav-icon">
<component :is="`i-ep-${menu.icon}`" />
</el-icon>
<div class="nav-label">
{{ menu.label }}
</div>
</div>
</div>
<!-- 侧边栏抽屉 -->
<el-drawer
v-model="drawerVisible"
title="菜单"
direction="ltr"
size="280px"
>
<!-- 用户信息 -->
<div v-if="userStore.userInfo" class="drawer-user">
<el-avatar :size="60" :src="userStore.userInfo.avatar">
{{ userStore.userInfo.name?.charAt(0) }}
</el-avatar>
<div class="user-info">
<div class="user-name">{{ userStore.userInfo.name }}</div>
<div class="user-email">{{ userStore.userInfo.email }}</div>
</div>
</div>
<!-- 菜单列表 -->
<el-menu class="drawer-menu">
<el-menu-item
v-for="menu in sidebarMenus"
:key="menu.key"
@click="handleSidebarMenuClick(menu)"
>
<el-icon>
<component :is="`i-ep-${menu.icon}`" />
</el-icon>
<span>{{ menu.label }}</span>
</el-menu-item>
</el-menu>
</el-drawer>
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.mobile-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
background-color: var(--el-bg-color);
}
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 16px;
background-color: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color);
flex-shrink: 0;
}
.header-title {
flex: 1;
text-align: center;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-avatar {
width: 40px;
display: flex;
justify-content: flex-end;
}
.mobile-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.mobile-bottom-nav {
display: flex;
align-items: center;
justify-content: space-around;
height: 56px;
padding-bottom: env(safe-area-inset-bottom);
background-color: var(--el-bg-color);
border-top: 1px solid var(--el-border-color);
flex-shrink: 0;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 12px;
cursor: pointer;
color: var(--el-text-color-secondary);
transition: all 0.2s;
&.active {
color: var(--el-color-primary);
.nav-icon {
transform: scale(1.1);
}
}
&:active {
opacity: 0.7;
}
}
.nav-icon {
font-size: 24px;
margin-bottom: 2px;
transition: transform 0.2s;
}
.nav-label {
font-size: 12px;
}
// 抽屉样式
.drawer-user {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
border-bottom: 1px solid var(--el-border-color);
margin-bottom: 16px;
}
.user-info {
margin-top: 12px;
text-align: center;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.user-email {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.drawer-menu {
border-right: none;
}
</style>

View File

@@ -2,8 +2,6 @@
<script setup lang="ts">
import SystemAnnouncementDialog from '@/components/SystemAnnouncementDialog/index.vue';
import { useSafeArea } from '@/hooks/useSafeArea';
import { useWindowWidthObserver } from '@/hooks/useWindowWidthObserver';
import Aside from '@/layouts/components/Aside/index.vue';
import Header from '@/layouts/components/Header/index.vue';
import Main from '@/layouts/components/Main/index.vue';
import { useAnnouncementStore, useDesignStore } from '@/stores';
@@ -25,7 +23,7 @@ useSafeArea({
});
/** 监听窗口大小变化,折叠侧边栏 */
useWindowWidthObserver();
// useWindowWidthObserver();
// 应用加载时检查是否需要显示公告弹窗
onMounted(() => {
@@ -43,7 +41,7 @@ onMounted(() => {
<Header />
</el-header>
<el-container class="layout-container-main">
<Aside />
<!-- <Aside /> -->
<el-main class="layout-main">
<!-- 路由页面 -->
<Main />
@@ -55,29 +53,29 @@ onMounted(() => {
</template>
<style lang="scss" scoped>
.layout-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
.layout-header {
padding: 0;
}
.layout-main {
height: 100%;
padding: 0;
}
.layout-container-main {
margin-left: var(--sidebar-left-container-default-width, 0);
transition: margin-left 0.3s ease;
}
}
/** 去除菜单右侧边框 */
.el-menu {
border-right: none;
}
.layout-scrollbar {
width: 100%;
}
//.layout-container {
// position: relative;
// width: 100%;
// height: 100vh;
// overflow: hidden;
// .layout-header {
// padding: 0;
// }
// .layout-main {
// height: 100%;
// padding: 0;
// }
// .layout-container-main {
// margin-left: var(--sidebar-left-container-default-width, 0);
// transition: margin-left 0.3s ease;
// }
//}
//
///** 去除菜单右侧边框 */
//.el-menu {
// border-right: none;
//}
//.layout-scrollbar {
// width: 100%;
//}
</style>

View File

@@ -1,392 +0,0 @@
<!-- Aside 侧边栏 -->
<script setup lang="ts">
import type { ConversationItem } from 'vue-element-plus-x/types/Conversations';
import type { ChatSessionVo } from '@/api/session/types';
import { useRoute, useRouter } from 'vue-router';
import { get_session } from '@/api';
import logo from '@/assets/images/logo.png';
import SvgIcon from '@/components/SvgIcon/index.vue';
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
import { useDesignStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
const route = useRoute();
const router = useRouter();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const sessionId = computed(() => route.params?.id);
const conversationsList = computed(() => sessionStore.sessionList);
const loadMoreLoading = computed(() => sessionStore.isLoadingMore);
const active = ref<string | undefined>();
onMounted(async () => {
// 获取会话列表
await sessionStore.requestSessionList();
// 高亮最新会话
if (conversationsList.value.length > 0 && sessionId.value) {
const currentSessionRes = await get_session(`${sessionId.value}`);
// 通过 ID 查询详情,设置当前会话 (因为有分页)
sessionStore.setCurrentSession(currentSessionRes.data);
}
});
watch(
() => sessionStore.currentSession,
(newValue) => {
active.value = newValue ? `${newValue.id}` : undefined;
},
);
// 创建会话
function handleCreatChat() {
// 创建会话, 跳转到默认聊天
sessionStore.createSessionBtn();
}
// 切换会话
function handleChange(item: ConversationItem<ChatSessionVo>) {
sessionStore.setCurrentSession(item);
router.replace({
name: 'chatWithId',
params: {
id: item.id,
},
});
}
// 处理组件触发的加载更多事件
async function handleLoadMore() {
if (!sessionStore.hasMore)
return; // 无更多数据时不加载
await sessionStore.loadMoreSessions();
}
// 右键菜单
function handleMenuCommand(command: string, item: ConversationItem<ChatSessionVo>) {
switch (command) {
case 'delete':
ElMessageBox.confirm('删除后,聊天记录将不可恢复。', '确定删除对话?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(() => {
// 删除会话
sessionStore.deleteSessions([item.id!]);
nextTick(() => {
if (item.id === active.value) {
// 如果删除当前会话 返回到默认页
sessionStore.createSessionBtn();
}
});
})
.catch(() => {
// 取消删除
});
break;
case 'rename':
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
confirmButtonClass: 'el-button--primary',
cancelButtonClass: 'el-button--info',
roundButton: true,
inputValue: item.sessionTitle, // 设置默认值
autofocus: false,
inputValidator: (value) => {
if (!value) {
return false;
}
return true;
},
}).then(({ value }) => {
sessionStore
.updateSession({
id: item.id!,
sessionTitle: value,
sessionContent: item.sessionContent,
})
.then(() => {
ElMessage({
type: 'success',
message: '修改成功',
});
nextTick(() => {
// 如果是当前会话,则更新当前选中会话信息
if (sessionStore.currentSession?.id === item.id) {
sessionStore.setCurrentSession({
...item,
sessionTitle: value,
});
}
});
});
});
break;
default:
break;
}
}
</script>
<template>
<div
class="aside-container"
:class="{
'aside-container-suspended': designStore.isSafeAreaHover,
'aside-container-collapse': designStore.isCollapse,
// 折叠且未激活悬停时添加 no-delay 类
'no-delay': designStore.isCollapse && !designStore.hasActivatedHover,
}"
>
<div class="aside-wrapper">
<div v-if="!designStore.isCollapse" class="aside-header">
<div class="flex items-center gap-8px hover:cursor-pointer" @click="handleCreatChat">
<el-image :src="logo" alt="logo" fit="cover" class="logo-img" />
<span class="logo-text max-w-150px text-overflow">意心AI</span>
</div>
<Collapse class="ml-auto" />
</div>
<div class="aside-body">
<div class="creat-chat-btn-wrapper">
<div class="creat-chat-btn" @click="handleCreatChat">
<el-icon class="add-icon">
<Plus />
</el-icon>
<span class="creat-chat-text">新对话</span>
<SvgIcon name="ctrl+k" size="37" />
</div>
</div>
<div class="aside-content">
<div v-if="conversationsList.length > 0" class="conversations-wrap overflow-hidden">
<Conversations
v-model:active="active"
:items="conversationsList"
:label-max-width="200"
:show-tooltip="true"
:tooltip-offset="60"
show-built-in-menu
groupable
row-key="id"
label-key="sessionTitle"
tooltip-placement="right"
:load-more="handleLoadMore"
:load-more-loading="loadMoreLoading"
:items-style="{
marginLeft: '8px',
userSelect: 'none',
borderRadius: '10px',
padding: '8px 12px',
}"
:items-active-style="{
backgroundColor: '#fff',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
color: 'rgba(0, 0, 0, 0.85)',
}"
:items-hover-style="{
backgroundColor: 'rgba(0, 0, 0, 0.04)',
}"
@menu-command="handleMenuCommand"
@change="handleChange"
/>
</div>
<el-empty v-else class="h-full flex-center" description="暂无对话记录" />
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// 基础样式
.aside-container {
position: absolute;
top: 0;
left: 0;
// z-index: 11;
width: var(--sidebar-default-width);
height: 100%;
pointer-events: auto;
background-color: var(--sidebar-background-color);
border-right: 0.5px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
.aside-wrapper {
display: flex;
flex-direction: column;
height: 100%;
// 侧边栏头部样式
.aside-header {
display: flex;
align-items: center;
height: 36px;
margin: 10px 12px 0;
.logo-img {
box-sizing: border-box;
width: 36px;
height: 36px;
padding: 4px;
overflow: hidden;
background-color: #ffffff;
border-radius: 50%;
img {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: rgb(0 0 0 / 85%);
transform: skewX(-2deg);
}
}
// 侧边栏内容样式
.aside-body {
.creat-chat-btn-wrapper {
padding: 0 12px;
.creat-chat-btn {
display: flex;
gap: 6px;
align-items: center;
padding: 8px 6px;
margin-top: 16px;
margin-bottom: 6px;
color: #0057ff;
cursor: pointer;
user-select: none;
background-color: rgb(0 87 255 / 6%);
border: 1px solid rgb(0 102 255 / 15%);
border-radius: 12px;
&:hover {
background-color: rgb(0 87 255 / 12%);
}
.creat-chat-text {
font-size: 14px;
font-weight: 700;
line-height: 22px;
}
.add-icon {
width: 24px;
height: 24px;
font-size: 16px;
}
.svg-icon {
height: 24px;
margin-left: auto;
color: rgb(0 87 255 / 30%);
}
}
}
.aside-content {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
// 会话列表高度-基础样式
.conversations-wrap {
height: calc(100vh - 110px);
.label {
display: flex;
align-items: center;
height: 100%;
}
}
}
}
}
}
// 折叠样式
.aside-container-collapse {
position: absolute;
top: 54px;
// z-index: 22;
height: auto;
max-height: calc(100% - 110px);
padding-bottom: 12px;
overflow: hidden;
/* 禁用悬停事件 */
pointer-events: none;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 15px;
box-shadow:
0 10px 20px 0 rgb(0 0 0 / 10%),
0 0 1px 0 rgb(0 0 0 / 15%);
opacity: 0;
transition: opacity 0.3s ease 0.3s, transform 0.3s ease 0.3s;
// 指定样式过渡
// 向左偏移一个宽度
transform: translateX(-100%);
/* 新增:未激活悬停时覆盖延迟 */
&.no-delay {
transition-delay: 0s, 0s;
}
}
// 悬停样式
.aside-container-collapse:hover,
.aside-container-collapse.aside-container-suspended {
height: auto;
max-height: calc(100% - 110px);
padding-bottom: 12px;
overflow: hidden;
pointer-events: auto;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 15px;
box-shadow:
0 10px 20px 0 rgb(0 0 0 / 10%),
0 0 1px 0 rgb(0 0 0 / 15%);
// 直接在这里写悬停时的样式(与 aside-container-suspended 一致)
opacity: 1;
transition: opacity 0.3s ease 0s, transform 0.3s ease 0s;
// 过渡动画沿用原有设置
transform: translateX(15px);
// 会话列表高度-悬停样式
.conversations-wrap {
height: calc(100vh - 155px) !important;
}
}
// 样式穿透
:deep() {
// 会话列表背景色
.conversations-list {
background-color: transparent !important;
}
// 群组标题样式 和 侧边栏菜单背景色一致
.conversation-group-title {
padding-left: 12px !important;
background-color: var(--sidebar-background-color) !important;
}
.conversation-group .active-sticky
{
z-index: 0 ;
}
.conversation-group .sticky-title{
z-index: 0 ;
}
}
</style>

View File

@@ -0,0 +1,804 @@
<script setup lang="ts">
import type { ConversationItem } from 'vue-element-plus-x/types/Conversations';
import type { ChatSessionVo } from '@/api/session/types';
import { ChatLineSquare, Expand, Fold, MoreFilled, Plus } from '@element-plus/icons-vue';
import { useRoute, useRouter } from 'vue-router';
import { get_session } from '@/api';
import { useDesignStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
const route = useRoute();
const router = useRouter();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const sessionId = computed(() => route.params?.id);
const conversationsList = computed(() => sessionStore.sessionList);
const loadMoreLoading = computed(() => sessionStore.isLoadingMore);
const active = ref<string | undefined>();
const isCollapsed = computed(() => designStore.isCollapseConversationList);
// 判断是否为新建对话状态(没有选中任何会话)
const isNewChatState = computed(() => !sessionStore.currentSession);
onMounted(async () => {
await sessionStore.requestSessionList();
if (conversationsList.value.length > 0 && sessionId.value) {
const currentSessionRes = await get_session(`${sessionId.value}`);
sessionStore.setCurrentSession(currentSessionRes.data);
}
});
watch(
() => sessionStore.currentSession,
(newValue) => {
active.value = newValue ? `${newValue.id}` : undefined;
},
);
// 创建会话
function handleCreatChat() {
sessionStore.createSessionBtn();
}
// 切换会话
function handleChange(item: ConversationItem<ChatSessionVo>) {
sessionStore.setCurrentSession(item);
router.replace({
name: 'chatConversationWithId',
params: {
id: item.id,
},
});
}
// 处理组件触发的加载更多事件
async function handleLoadMore() {
if (!sessionStore.hasMore)
return;
await sessionStore.loadMoreSessions();
}
// 右键菜单
function handleMenuCommand(command: string, item: ConversationItem<ChatSessionVo>) {
switch (command) {
case 'delete':
ElMessageBox.confirm('删除后,聊天记录将不可恢复。', '确定删除对话?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(() => {
sessionStore.deleteSessions([item.id!]);
nextTick(() => {
if (item.id === active.value) {
sessionStore.createSessionBtn();
}
});
})
.catch(() => {
// 取消删除
});
break;
case 'rename':
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
confirmButtonClass: 'el-button--primary',
cancelButtonClass: 'el-button--info',
roundButton: true,
inputValue: item.sessionTitle,
autofocus: false,
inputValidator: (value) => {
return !!value;
},
}).then(({ value }) => {
sessionStore
.updateSession({
id: item.id!,
sessionTitle: value,
sessionContent: item.sessionContent,
})
.then(() => {
ElMessage({
type: 'success',
message: '修改成功',
});
nextTick(() => {
if (sessionStore.currentSession?.id === item.id) {
sessionStore.setCurrentSession({
...item,
sessionTitle: value,
});
}
});
});
});
break;
default:
break;
}
}
// 折叠/展开侧边栏
function toggleSidebar() {
designStore.setIsCollapseConversationList(!designStore.isCollapseConversationList);
}
</script>
<template>
<div
class="aside-container"
:class="{ 'aside-collapsed': isCollapsed }"
>
<div class="aside-wrapper">
<!-- 头部 -->
<div class="aside-header">
<!-- 展开状态显示logo和标题 -->
<div
v-if="!isCollapsed"
class="header-content-expanded flex items-center gap-8px"
:class="{ 'is-disabled': isNewChatState, 'hover:cursor-pointer': !isNewChatState }"
@click="!isNewChatState && handleCreatChat()"
>
<span class="logo-text max-w-150px text-overflow">会话</span>
</div>
<!-- 折叠状态只显示logo -->
<div
v-else
class="header-content-collapsed flex items-center justify-center hover:cursor-pointer"
>
<el-icon size="20">
<ChatLineSquare />
</el-icon>
</div>
<!-- 折叠按钮 -->
<el-tooltip
:content="isCollapsed ? '展开侧边栏' : '折叠侧边栏'"
placement="bottom"
>
<el-button
class="collapse-btn"
type="text"
@click="toggleSidebar"
>
<el-icon v-if="isCollapsed">
<Expand />
</el-icon>
<el-icon v-else>
<Fold />
</el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 内容区域 -->
<div class="aside-body">
<!-- 创建会话按钮 -->
<div class="creat-chat-btn-wrapper">
<div
class="creat-chat-btn"
:class="{
'creat-chat-btn-collapsed': isCollapsed,
'is-disabled': isNewChatState,
}"
@click="!isNewChatState && handleCreatChat()"
>
<el-icon class="add-icon">
<Plus />
</el-icon>
<span v-if="!isCollapsed" class="creat-chat-text">
新对话
</span>
</div>
</div>
<!-- 会话列表 -->
<div class="aside-content">
<div v-if="conversationsList.length > 0" class="conversations-wrap">
<Conversations
v-model:active="active"
:items="conversationsList"
:label-max-width="isCollapsed ? 0 : 140"
:show-tooltip="!isCollapsed"
:tooltip-offset="60"
show-built-in-menu
groupable
row-key="id"
label-key="sessionTitle"
:tooltip-placement="isCollapsed ? 'right-start' : 'right'"
:load-more="handleLoadMore"
:load-more-loading="loadMoreLoading"
:items-style="{
marginLeft: '8px',
marginRight: '8px',
userSelect: 'none',
borderRadius: isCollapsed ? '12px' : '10px',
padding: isCollapsed ? '12px 8px' : '8px 12px',
justifyContent: isCollapsed ? 'center' : 'space-between',
width: isCollapsed ? '64px' : 'auto',
height: isCollapsed ? '64px' : 'auto',
minHeight: '48px',
flexDirection: isCollapsed ? 'column' : 'row',
position: 'relative',
overflow: 'hidden',
}"
:items-active-style="{
backgroundColor: '#fff',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
color: 'rgba(0, 0, 0, 0.85)',
}"
:items-hover-style="{
backgroundColor: 'rgba(0, 0, 0, 0.04)',
}"
@menu-command="handleMenuCommand"
@change="handleChange"
@contextmenu="handleContextMenu"
>
<!-- 自定义折叠状态下的会话项内容 -->
<template #default="{ item }">
<div class="conversation-item-content">
<div v-if="isCollapsed" class="collapsed-item">
<div
class="avatar-circle"
@click="handleChange(item)"
@contextmenu="(e) => handleContextMenu(e, item)"
>
{{ item.sessionTitle?.charAt(0) || 'A' }}
</div>
<div v-if="item.unreadCount" class="unread-indicator">
{{ item.unreadCount > 99 ? '99+' : item.unreadCount }}
</div>
<!-- 折叠状态下的更多操作按钮 -->
<div
class="collapsed-menu-trigger"
@click.stop="(e) => handleCollapsedMenuClick(e, item)"
@contextmenu.stop="(e) => handleContextMenu(e, item)"
>
<el-icon size="14">
<MoreFilled />
</el-icon>
</div>
</div>
<div v-else class="expanded-item">
<div class="conversation-info">
<div class="conversation-title">
{{ item.sessionTitle }}
</div>
<div v-if="item.sessionContent" class="conversation-preview">
{{ item.sessionContent.substring(0, 30) }}{{ item.sessionContent.length > 30 ? '...' : '' }}
</div>
</div>
</div>
</div>
</template>
</Conversations>
</div>
<el-empty
v-else
class="h-full flex-center"
:description="isCollapsed ? '' : '暂无对话记录'"
>
<template #description>
<span v-if="!isCollapsed">暂无对话记录</span>
</template>
</el-empty>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// 基础样式
.aside-container {
height: 100%;
border-right: 0.5px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
flex-shrink: 0;
//background-color: var(--sidebar-background-color, #f9fafb);
// 展开状态 - 240px
&:not(.aside-collapsed) {
width: 240px;
.aside-wrapper {
width: 240px;
}
}
// 折叠状态 - 100px
&.aside-collapsed {
display: none;
.aside-wrapper {
width: 100px;
}
}
}
.aside-wrapper {
display: flex;
flex-direction: column;
height: 100%;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
// 头部样式
.aside-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 12px;
border-bottom: 1px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
//background-color: var(--sidebar-header-bg, #ffffff);
.header-content-expanded {
flex: 1;
transition: opacity 0.2s ease;
&.is-disabled {
opacity: 0.5;
cursor: not-allowed !important;
pointer-events: none;
}
}
.header-content-collapsed {
width: 36px;
height: 36px;
border-radius: 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.el-icon {
color: var(--el-text-color-secondary);
}
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: rgb(0 0 0 / 85%);
transform: skewX(-2deg);
}
.collapse-btn {
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
transition: transform 0.2s ease;
&:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
transform: scale(1.1);
}
.el-icon {
font-size: 18px;
}
}
}
// 内容区域
.aside-body {
flex: 1;
display: flex;
flex-direction: column;
//padding: 0 4px;
overflow: hidden;
.creat-chat-btn-wrapper {
padding: 12px 8px 4px;
.creat-chat-btn {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
color: #0057ff;
cursor: pointer;
user-select: none;
background-color: rgb(0 87 255 / 6%);
border: 1px solid rgb(0 102 255 / 15%);
border-radius: 12px;
transition: all 0.2s ease;
&:hover:not(.is-disabled) {
background-color: rgb(0 87 255 / 12%);
transform: translateY(-1px);
}
&.is-disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
&.creat-chat-btn-collapsed {
width: 40px;
height: 40px;
border-radius: 50%;
margin: 0 auto;
}
.add-icon {
width: 24px;
height: 24px;
font-size: 16px;
}
.creat-chat-text {
font-size: 14px;
font-weight: 700;
line-height: 22px;
margin-left: 6px;
transition: opacity 0.2s ease;
}
}
}
.aside-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
.conversations-wrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
&:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
}
.conversation-item-content {
width: 100%;
height: 100%;
.collapsed-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
width: 100%;
height: 100%;
.avatar-circle {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
.unread-indicator {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background-color: #ff4d4f;
color: white;
border-radius: 8px;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
font-weight: 600;
}
.collapsed-menu-trigger {
position: absolute;
bottom: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
z-index: 2;
.el-icon {
color: var(--el-text-color-secondary);
font-size: 12px;
}
&:hover {
background-color: rgba(0, 0, 0, 0.1);
opacity: 1;
}
}
&:hover .collapsed-menu-trigger {
opacity: 0.7;
}
}
.expanded-item {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
.conversation-info {
flex: 1;
min-width: 0;
margin-right: 8px;
overflow: hidden;
.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%;
}
}
}
}
}
}
}
// 样式穿透 - 重点修复溢出问题
:deep() {
.conversations-list {
background-color: transparent !important;
width: 100% !important;
}
.conversation-group-title {
padding-left: 12px !important;
background-color: transparent !important;
transition: all 0.3s ease;
.title-text {
opacity: 0.6;
font-size: 12px;
transition: opacity 0.2s ease;
}
}
.conversation-item {
transition: all 0.3s ease;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
&-inner {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
}
&-content {
flex: 1 !important;
min-width: 0 !important;
max-width: calc(100% - 32px) !important;
overflow: hidden !important;
box-sizing: border-box !important;
}
// 确保操作按钮区域在展开状态下正常显示
&-actions {
flex-shrink: 0 !important;
display: flex !important;
align-items: center !important;
opacity: 1 !important;
visibility: visible !important;
transition: all 0.3s ease;
.el-button {
transition: all 0.2s ease;
width: 24px !important;
height: 24px !important;
padding: 0 !important;
margin-left: 4px !important;
flex-shrink: 0 !important;
&:hover {
transform: scale(1.1);
}
.el-icon {
font-size: 16px !important;
}
}
}
}
// 折叠状态样式
.aside-collapsed {
.conversation-group-title {
display: none !important;
}
.conversation-item {
justify-content: center !important;
padding: 12px 8px !important;
height: 64px !important;
min-height: 64px !important;
&-label {
display: none !important;
}
&-actions {
// 折叠状态下隐藏默认操作按钮,使用自定义的
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
&-content {
max-width: 100% !important;
}
}
}
// 展开状态样式
&:not(.aside-collapsed) {
.conversation-item {
&-actions {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
}
}
}
// 自定义对话框样式
:deep(.collapsed-menu-dialog) {
.el-message-box {
width: 160px !important;
padding: 12px !important;
&__header {
padding: 0 0 8px 0 !important;
border-bottom: none !important;
}
&__title {
font-size: 14px !important;
font-weight: 600 !important;
}
&__content {
padding: 0 !important;
}
&__headerbtn {
top: 8px !important;
right: 8px !important;
.el-icon {
font-size: 14px !important;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.aside-container {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
background: #fff;
&.aside-collapsed {
transform: translateX(-100%);
width: 100px !important;
}
&:not(.aside-collapsed) {
transform: translateX(0);
}
}
.aside-wrapper {
width: 280px !important;
.aside-collapsed & {
width: 100px !important;
}
}
// 移动端遮罩层
.aside-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease;
}
}
// 动画
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -14,27 +14,6 @@ function openTutorial() {
>
<!-- PC端显示文字 -->
<span class="pc-text">文档</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 14l9-5-9-5-9 5 9 5z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 14l6.16-3.422A12.083 12.083 0 0118 13.5c0 2.579-3.582 4.5-6 4.5s-6-1.921-6-4.5c0-.432.075-.85.198-1.244L12 14z"
/>
</svg>
</div>
</div>
</template>
@@ -70,19 +49,4 @@ function openTutorial() {
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.ai-tutorial-btn-container {
.ai-tutorial-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -1,18 +1,8 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useAnnouncementStore } from '@/stores';
const announcementStore = useAnnouncementStore();
const { announcements } = storeToRefs(announcementStore);
// 计算未读公告数量(系统公告数量)
const unreadCount = computed(() => {
if (!Array.isArray(announcements.value))
return 0;
return announcements.value.filter(a => a.type === 'System').length;
});
// 打开公告弹窗
function openAnnouncement() {
announcementStore.openDialog();
}
@@ -20,93 +10,127 @@ function openAnnouncement() {
<template>
<div class="announcement-btn-container" data-tour="announcement-btn">
<el-badge
is-dot
class="announcement-badge"
<div
class="announcement-btn"
title="查看公告"
@click="openAnnouncement"
>
<!-- :value="unreadCount" -->
<!-- :hidden="unreadCount === 0" -->
<!-- :max="99" -->
<div
class="announcement-btn"
title="查看公告"
@click="openAnnouncement"
>
<!-- PC端显示文字 -->
<span class="pc-text">公告</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</div>
</el-badge>
<!-- PC端显示文字 -->
<span class="pc-text">公告</span>
</div>
</div>
</template>
<style scoped lang="scss">
<style scoped>
.announcement-btn-container {
display: flex;
align-items: center;
}
.announcement-badge {
:deep(.el-badge__content) {
background-color: #f56c6c;
border: none;
}
.announcement-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #409eff;
transition: all 0.2s;
position: relative;
padding: 4px 8px;
border-radius: 4px;
}
.announcement-btn:hover {
color: #66b1ff;
transform: translateY(-1px);
background-color: rgba(64, 158, 255, 0.1);
}
/* PC端文字样式 */
.pc-text {
display: inline-block;
position: relative;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
line-height: 1.2;
padding: 2px 8px 2px 0;
}
/* 红点样式 - 缩小到一半 */
.pc-text::after,
.mobile-icon::after {
content: '';
position: absolute;
width: 6px; /* 缩小到6px */
height: 6px; /* 缩小到6px */
background-color: #f56c6c;
border-radius: 50%;
border: 1.5px solid #fff; /* 边框也相应缩小 */
box-shadow: 0 1px 2px rgba(245, 108, 108, 0.3);
animation: pulse 1.8s infinite;
z-index: 1;
}
/* PC端红点位置 - 调整位置使红点正好与"告"字相交 */
.pc-text::after {
top: -4px; /* 向上移动,与文字相交 */
right: -4px; /* 向右移动,与文字相交 */
}
/* 为小红点添加微小的光晕效果 */
.pc-text::before {
content: '';
position: absolute;
top: -6px;
right: -6px;
width: 10px; /* 光晕也相应缩小 */
height: 10px; /* 光晕也相应缩小 */
background-color: rgba(245, 108, 108, 0.2);
border-radius: 50%;
animation: glow 2s infinite;
z-index: 0;
}
@keyframes glow {
0%, 100% {
transform: scale(1);
opacity: 0.3;
}
.announcement-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #409eff;
transition: all 0.2s;
&:hover {
color: #66b1ff;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
50% {
transform: scale(1.1);
opacity: 0.4;
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.announcement-btn-container {
.announcement-btn {
.pc-text {
display: none;
}
/* 移动端图标样式 */
.mobile-icon {
display: none;
position: relative;
width: 20px;
height: 20px;
}
.mobile-icon {
display: inline;
}
}
/* 移动端图标内的红点位置 */
.mobile-icon::after {
top: -2px; /* 位置微调 */
right: -2px; /* 位置微调 */
width: 5px; /* 移动端红点更小一点 */
height: 5px; /* 移动端红点更小一点 */
}
/* 呼吸动画效果 - 调整为更微妙的动画 */
@keyframes pulse {
0% {
transform: scale(0.9);
box-shadow: 0 1px 2px rgba(245, 108, 108, 0.2);
}
50% {
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(245, 108, 108, 0.4);
}
100% {
transform: scale(0.9);
box-shadow: 0 1px 2px rgba(245, 108, 108, 0.2);
}
}
</style>

View File

@@ -1,23 +1,19 @@
<!-- 头像 -->
<script setup lang="ts">
import { ChatLineRound } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { nextTick, onMounted, ref, watch } from 'vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useGuideTour } from '@/hooks/useGuideTour';
import { useAnnouncementStore, useGuideTourStore, useUserStore } from '@/stores';
import { useUserStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
import { getUserProfilePicture, isUserVip } from '@/utils/user';
const router = useRouter();
const userStore = useUserStore();
const sessionStore = useSessionStore();
const guideTourStore = useGuideTourStore();
const announcementStore = useAnnouncementStore();
const { startUserCenterTour } = useGuideTour();
const { startHeaderTour } = useGuideTour();
/* 弹出面板 开始 */
const popoverStyle = ref({
@@ -29,41 +25,17 @@ const popoverRef = ref();
// 弹出面板内容
const popoverList = ref([
{
key: '5',
title: '控制台',
key: '1',
title: '用户信息',
icon: 'settings-4-fill',
},
{
key: '3',
divider: true,
},
{
key: '7',
title: '公告',
icon: 'notification-fill',
},
{
key: '8',
title: '模型库',
icon: 'apps-fill',
},
{
key: '9',
title: '文档',
icon: 'book-fill',
},
{
key: '6',
title: '新手引导',
icon: 'dashboard-fill',
},
{
key: '3',
divider: true,
},
// 待定
// {
// key: '6',
// title: '新手引导',
// icon: 'dashboard-fill',
// },
{
key: '4',
title: '退出登录',
@@ -71,85 +43,20 @@ const popoverList = ref([
},
]);
const dialogVisible = ref(false);
const rechargeLogRef = ref();
const activeNav = ref('user');
// ============ 邀请码分享功能 ============
/** 从 URL 获取的邀请码 */
const externalInviteCode = ref<string>('');
const navItems = [
{ name: 'user', label: '用户信息', icon: 'User' },
// { name: 'role', label: '角色管理', icon: 'Avatar' },
// { name: 'permission', label: '权限管理', icon: 'Key' },
// { name: 'userInfo', label: '用户信息', icon: 'User' },
{ name: 'apiKey', label: 'API密钥', icon: 'Key' },
{ name: 'rechargeLog', label: '充值记录', icon: 'Document' },
{ name: 'usageStatistics', label: '用量统计', icon: 'Histogram' },
{ name: 'premiumService', label: '尊享服务', icon: 'ColdDrink' },
{ name: 'dailyTask', label: '每日任务(限时)', icon: 'Trophy' },
{ name: 'cardFlip', label: '每周邀请(限时)', icon: 'Present' },
// { name: 'usageStatistics2', label: '用量统计2', icon: 'Histogram' },
{ name: 'activationCode', label: '激活码兑换', icon: 'MagicStick' },
];
function openDialog() {
dialogVisible.value = true;
}
function handleConfirm(activeNav: string) {
ElMessage.success('操作成功');
}
// 导航切换
function handleNavChange(nav: string) {
activeNav.value = nav;
// 同步更新 store 中的 tab 状态,防止下次通过 store 打开同一 tab 时因值未变而不触发 watch
if (userStore.userCenterActiveTab !== nav) {
userStore.userCenterActiveTab = nav;
}
}
// 联系售后
function handleContactSupport() {
rechargeLogRef.value?.contactCustomerService();
}
const { startHeaderTour } = useGuideTour();
// 开始引导教程
function handleStartTutorial() {
startHeaderTour();
}
// 点击
function handleClick(item: any) {
switch (item.key) {
case '1':
ElMessage.warning('暂未开放');
break;
case '2':
ElMessage.warning('暂未开放');
break;
case '5':
openDialog();
router.push('/console/user');
break;
case '6':
handleStartTutorial();
break;
case '7':
// 打开公告
popoverRef.value?.hide?.();
announcementStore.openDialog();
break;
case '8':
// 打开模型库
popoverRef.value?.hide?.();
router.push('/model-library');
break;
case '9':
// 打开文档
popoverRef.value?.hide?.();
window.open('https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde', '_blank');
break;
case '4':
popoverRef.value?.hide?.();
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
@@ -173,10 +80,7 @@ function handleClick(item: any) {
});
})
.catch(() => {
// ElMessage({
// type: 'info',
// message: '取消',
// });
// 取消退出,不执行任何操作
});
break;
default:
@@ -220,124 +124,15 @@ function openVipGuide() {
});
})
.catch(() => {
// 点击右上角关闭或关闭按钮,不执行任何操作
// 点击右上角关闭或"关闭"按钮,不执行任何操作
});
}
// ============ 监听对话框打开事件,切换到邀请码标签页 ============
watch(dialogVisible, (newVal) => {
if (newVal && externalInviteCode.value) {
// 对话框打开后,切换标签页(已通过 :default-active 绑定,会自动响应)
// console.log('[Avatar] watch: 对话框已打开,切换到 cardFlip 标签页');
nextTick(() => {
activeNav.value = 'cardFlip';
// console.log('[Avatar] watch: 已设置 activeNav 为', activeNav.value);
});
}
// 对话框关闭时,清除邀请码状态和 URL 参数
if (!newVal && externalInviteCode.value) {
// console.log('[Avatar] watch: 对话框关闭,清除邀请码状态');
externalInviteCode.value = '';
// 清除 URL 中的 inviteCode 参数
const url = new URL(window.location.href);
if (url.searchParams.has('inviteCode')) {
url.searchParams.delete('inviteCode');
window.history.replaceState({}, '', url.toString());
// console.log('[Avatar] watch: 已清除 URL 中的 inviteCode 参数');
}
}
});
// ============ 监听 URL 参数,实现邀请码快捷分享 ============
onMounted(() => {
// 获取 URL 查询参数
const urlParams = new URLSearchParams(window.location.search);
const inviteCode = urlParams.get('inviteCode');
if (inviteCode && inviteCode.trim()) {
// console.log('[Avatar] onMounted: 检测到邀请码', inviteCode);
// 保存邀请码
externalInviteCode.value = inviteCode.trim();
// 先设置标签页为 cardFlip
activeNav.value = 'cardFlip';
// console.log('[Avatar] onMounted: 设置 activeNav 为', activeNav.value);
// 延迟打开对话框,确保状态已更新
nextTick(() => {
setTimeout(() => {
// console.log('[Avatar] onMounted: 打开用户中心对话框');
dialogVisible.value = true;
}, 200);
});
// 注意:不立即清除 URL 参数,保留给登录后使用
// URL 参数会在对话框关闭时清除
}
});
// ============ 监听引导状态,自动打开用户中心并开始引导 ============
watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
if (shouldStart) {
// 清除触发标记
guideTourStore.clearUserCenterTourTrigger();
// 注册导航切换回调
guideTourStore.setUserCenterNavChangeCallback((nav: string) => {
activeNav.value = nav;
});
// 注册关闭弹窗回调
guideTourStore.setUserCenterCloseCallback(() => {
dialogVisible.value = false;
});
// 打开用户中心弹窗
nextTick(() => {
dialogVisible.value = true;
// 等待弹窗打开后开始引导
setTimeout(() => {
startUserCenterTour();
}, 600);
});
}
});
// ============ 监听 Store 状态,控制用户中心弹窗 (新增) ============
watch(() => userStore.isUserCenterVisible, (val) => {
dialogVisible.value = val;
if (val && userStore.userCenterActiveTab) {
activeNav.value = userStore.userCenterActiveTab;
}
});
watch(() => userStore.userCenterActiveTab, (val) => {
if (val) {
activeNav.value = val;
}
});
// 监听本地 dialogVisible 变化,同步回 Store可选为了保持一致性
watch(dialogVisible, (val) => {
if (!val) {
userStore.closeUserCenter();
}
});
// ============ 暴露方法供外部调用 ============
defineExpose({
openDialog,
});
</script>
<template>
<div class="flex items-center gap-2 ">
<div class="flex items-center gap-2">
<!-- 用户信息区域 -->
<div class="user-info-display cursor-pointer flex flex-col text-right mr-2 leading-tight" @click="openDialog">
<div class="user-info-display cursor-pointer flex flex-col text-right mr-2 leading-tight">
<div class="text-sm font-semibold text-gray-800">
{{ userStore.userInfo?.user.nick ?? '未登录用户' }}
</div>
@@ -354,6 +149,7 @@ defineExpose({
<span
v-else
class="inline-block px-2 py-0.5 text-xs text-gray-600 bg-gray-100 rounded-full cursor-pointer hover:bg-yellow-50 hover:text-yellow-700 transition"
@click="openVipGuide"
>
普通用户
</span>
@@ -361,7 +157,7 @@ defineExpose({
</div>
<!-- 头像区域 -->
<div class="avatar-container" data-tour="user-avatar">
<div class="avatar-container">
<Popover
ref="popoverRef"
placement="bottom-end"
@@ -393,6 +189,7 @@ defineExpose({
<span
v-else
class="inline-block px-2 py-0.5 text-xs text-gray-600 bg-gray-100 rounded-full cursor-pointer hover:bg-yellow-50 hover:text-yellow-700 transition"
@click="openVipGuide"
>
普通用户
</span>
@@ -403,7 +200,6 @@ defineExpose({
<div v-for="item in popoverList" :key="item.key" class="popover-content-box-items h-full">
<div
v-if="!item.divider"
class="popover-content-box-item flex items-center h-full gap-8px p-8px pl-10px pr-12px rounded-lg hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
@click="handleClick(item)"
>
@@ -412,78 +208,15 @@ defineExpose({
{{ item.title }}
</div>
</div>
<div v-if="item.divider" class="divder h-1px bg-gray-200 my-4px" />
</div>
</div>
</Popover>
</div>
<nav-dialog
v-model="dialogVisible"
title="控制台"
:nav-items="navItems"
:default-active="activeNav"
@confirm="handleConfirm"
@nav-change="handleNavChange"
>
<template #extra-actions>
<el-tooltip v-if="isUserVip() && activeNav === 'rechargeLog'" content="联系售后" placement="bottom">
<el-button circle plain size="small" @click="handleContactSupport">
<el-icon color="#07c160">
<ChatLineRound />
</el-icon>
</el-button>
</el-tooltip>
</template>
<!-- 用户管理内容 -->
<template #user>
<user-management />
</template>
<!-- 用量统计 -->
<template #usageStatistics>
<usage-statistics />
</template>
<!-- 尊享服务 -->
<template #premiumService>
<premium-service />
</template>
<!-- 用量统计 -->
<!-- <template #usageStatistics2> -->
<!-- <usage-statistics2 /> -->
<!-- </template> -->
<!-- 角色管理内容 -->
<template #role>
<!-- < /> -->
</template>
<!-- 权限管理内容 -->
<template #permission>
<!-- <permission-management /> -->
</template>
<template #apiKey>
<APIKeyManagement />
</template>
<template #activationCode>
<activation-code />
</template>
<template #dailyTask>
<daily-task />
</template>
<template #cardFlip>
<card-flip-activity :external-invite-code="externalInviteCode" />
</template>
<template #rechargeLog>
<recharge-log ref="rechargeLogRef" />
</template>
</nav-dialog>
</div>
</template>
<style scoped lang="scss">
.popover-content {
.popover-content{
width: 520px;
height: 520px;
}

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
// 检查是否在聊天页面
const isOnChatPage = computed(() => {
return route.path.startsWith('/chat');
});
function goToChat() {
router.push('/chat/conversation');
}
</script>
<template>
<div v-if="!isOnChatPage" class="start-chat-btn-container" data-tour="start-chat-btn">
<div
class="start-chat-btn"
title="开始聊天"
@click="goToChat"
>
<el-icon class="chat-icon">
<i-ep-chat-dot-round />
</el-icon>
<span class="btn-text">开始聊天</span>
</div>
</div>
</template>
<style scoped lang="scss">
.start-chat-btn-container {
display: flex;
align-items: center;
margin-right: 12px;
.start-chat-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
color: #fff;
font-weight: 600;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
&:active {
transform: translateY(0);
}
.chat-icon {
font-size: 18px;
}
.btn-text {
font-size: 14px;
}
}
}
// 移动端隐藏文字
@media (max-width: 768px) {
.start-chat-btn-container {
margin-right: 8px;
.start-chat-btn {
padding: 8px;
.btn-text {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { useColorMode } from '@vueuse/core';
// 使用 VueUse 的 useColorMode
const mode = useColorMode({
attribute: 'class',
modes: {
light: 'light',
dark: 'dark',
},
});
// 切换主题
function toggleTheme() {
mode.value = mode.value === 'dark' ? 'light' : 'dark';
}
// 主题图标
const themeIcon = computed(() => {
return mode.value === 'dark' ? 'Sunny' : 'Moon';
});
// 主题标题
const themeTitle = computed(() => {
return mode.value === 'dark' ? '切换到浅色模式' : '切换到深色模式';
});
</script>
<template>
<div class="theme-btn-container" data-tour="theme-btn">
<div
class="theme-btn"
:title="themeTitle"
@click="toggleTheme"
>
<!-- PC端显示文字 + 图标 -->
<el-icon class="theme-icon">
<component :is="`i-ep-${themeIcon}`" />
</el-icon>
<span class="pc-text">主题</span>
</div>
</div>
</template>
<style scoped lang="scss">
.theme-btn-container {
display: flex;
align-items: center;
.theme-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s;
color: var(--el-text-color-regular);
&:hover {
background-color: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.theme-icon {
font-size: 18px;
transition: transform 0.3s;
}
&:hover .theme-icon {
transform: rotate(20deg);
}
// PC端显示文字
.pc-text {
display: inline;
font-size: 14px;
font-weight: 500;
}
}
}
// 移动端隐藏文字
@media (max-width: 768px) {
.theme-btn-container {
.theme-btn {
padding: 8px;
.pc-text {
display: none;
}
}
}
}
</style>

View File

@@ -1,117 +1,624 @@
<!-- Header 头部 -->
<script setup lang="ts">
import { onKeyStroke } from '@vueuse/core';
import { SIDE_BAR_WIDTH } from '@/config/index';
import { useDesignStore, useUserStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import logo from '@/assets/images/logo.png';
import ConsoleBtn from '@/layouts/components0/Header/components/ConsoleBtn.vue';
import { useUserStore } from '@/stores';
import AiTutorialBtn from './components/AiTutorialBtn.vue';
import AnnouncementBtn from './components/AnnouncementBtn.vue';
import Avatar from './components/Avatar.vue';
import BuyBtn from './components/BuyBtn.vue';
import Collapse from './components/Collapse.vue';
import ConsoleBtn from './components/ConsoleBtn.vue';
import CreateChat from './components/CreateChat.vue';
import LoginBtn from './components/LoginBtn.vue';
import ModelLibraryBtn from './components/ModelLibraryBtn.vue';
import TitleEditing from './components/TitleEditing.vue';
import ThemeBtn from './components/ThemeBtn.vue';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const avatarRef = ref();
// 移动端菜单抽屉状态
const mobileMenuVisible = ref(false);
const currentSession = computed(() => sessionStore.currentSession);
onMounted(() => {
// 全局设置侧边栏默认宽度 (这个是不变的,一开始就设置)
document.documentElement.style.setProperty(`--sidebar-default-width`, `${SIDE_BAR_WIDTH}px`);
if (designStore.isCollapse) {
document.documentElement.style.setProperty(`--sidebar-left-container-default-width`, ``);
}
else {
document.documentElement.style.setProperty(
`--sidebar-left-container-default-width`,
`${SIDE_BAR_WIDTH}px`,
);
}
// 当前激活的菜单项
const activeIndex = computed(() => {
if (route.path.startsWith('/console'))
return 'console';
if (route.path.startsWith('/model-library'))
return 'model-library';
if (route.path.includes('/chat/'))
return 'chat';
return '';
});
// 定义 Ctrl+K 的处理函数
function handleCtrlK(event: KeyboardEvent) {
event.preventDefault(); // 防止默认行为
sessionStore.createSessionBtn();
// 导航处理
function handleSelect(key: string) {
if (key && key !== 'no-route') {
router.push(key);
mobileMenuVisible.value = false; // 移动端导航后关闭菜单
}
}
// 设置全局的键盘按键监听
onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtrlK, {
passive: false,
});
// 修改 AI 聊天菜单的点击事件
function handleAIClick(e: MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
router.push('/chat/conversation');
mobileMenuVisible.value = false;
}
// 打开控制台
function handleOpenConsole() {
avatarRef.value?.openDialog?.();
// 修改控制台菜单的点击事件
function handleConsoleClick(e: MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
router.push('/console/user');
mobileMenuVisible.value = false;
}
// 切换移动端菜单
function toggleMobileMenu() {
mobileMenuVisible.value = !mobileMenuVisible.value;
}
</script>
<template>
<div class="header-container">
<div class="header-box relative z-10 top-0 left-0 right-0">
<div class="absolute left-0 right-0 top-0 bottom-0 flex items-center flex-row">
<div
class="overflow-hidden flex h-full items-center flex-row flex-1 w-fit flex-shrink-0 min-w-0"
>
<div class="w-full flex items-center flex-row">
<!-- 左边 -->
<div
v-if="designStore.isCollapse"
class="left-box flex h-full items-center pl-20px gap-12px flex-shrink-0 flex-row"
>
<Collapse />
<CreateChat />
<div v-if="currentSession" class="w-0.5px h-30px bg-[rgba(217,217,217)]" />
</div>
<!-- 中间 -->
<div class="middle-box flex-1 min-w-0 ml-12px">
<TitleEditing />
</div>
</div>
</div>
<!-- 右边 -->
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
<AnnouncementBtn />
<ModelLibraryBtn />
<AiTutorialBtn />
<ConsoleBtn @open-console="handleOpenConsole" />
<BuyBtn v-show="userStore.userInfo" />
<Avatar v-show="userStore.userInfo" ref="avatarRef" />
<LoginBtn v-show="!userStore.userInfo" />
<!-- 桌面端菜单 -->
<el-menu
:default-active="activeIndex"
class="header-menu desktop-menu"
mode="horizontal"
:ellipsis="false"
:router="false"
@select="handleSelect"
>
<!-- 左侧品牌区域 -->
<div class="menu-left">
<div class="brand-container" @click="router.push('/')">
<el-image :src="logo" alt="logo" fit="contain" class="logo-img" />
<span class="brand-text">意心AI</span>
</div>
</div>
<!-- 右侧功能区域 -->
<div class="menu-right">
<!-- AI聊天菜单 -->
<el-sub-menu index="chat" class="chat-submenu" popper-class="custom-popover">
<template #title>
<span class="menu-title" @click="handleAIClick">AI应用</span>
</template>
<el-menu-item index="/chat/conversation">
AI对话
</el-menu-item>
<el-menu-item index="/chat/image">
AI图片
</el-menu-item>
<el-menu-item index="/chat/video">
AI视频
</el-menu-item>
<el-menu-item index="/chat/agent">
AI智能体
</el-menu-item>
</el-sub-menu>
<!-- 公告按钮 -->
<el-menu-item class="custom-menu-item" index="no-route">
<AnnouncementBtn :is-menu-item="true" />
</el-menu-item>
<!-- 模型库 -->
<el-menu-item index="/model-library" class="custom-menu-item">
<ModelLibraryBtn :is-menu-item="true" />
</el-menu-item>
<!-- AI教程 -->
<el-menu-item class="custom-menu-item" index="no-route">
<AiTutorialBtn />
</el-menu-item>
<!-- 控制台菜单 -->
<el-sub-menu index="console" class="console-submenu" popper-class="custom-popover">
<template #title>
<ConsoleBtn @click="handleConsoleClick" />
</template>
<el-menu-item index="/console/user">
用户信息
</el-menu-item>
<el-menu-item index="/console/apikey">
API密钥
</el-menu-item>
<el-menu-item index="/console/recharge-log">
充值记录
</el-menu-item>
<el-menu-item index="/console/usage">
用量统计
</el-menu-item>
<el-menu-item index="/console/premium">
尊享服务
</el-menu-item>
<el-menu-item index="/console/daily-task">
每日任务
</el-menu-item>
<el-menu-item index="/console/invite">
每周邀请
</el-menu-item>
<el-menu-item index="/console/activation">
激活码兑换
</el-menu-item>
</el-sub-menu>
<!-- 购买按钮 -->
<el-menu-item v-if="userStore.userInfo" class="custom-menu-item" index="no-route">
<BuyBtn :is-menu-item="true" />
</el-menu-item>
<!-- 主题切换暂不显示 -->
<el-menu-item v-if="false" class="custom-menu-item" index="no-route">
<ThemeBtn :is-menu-item="true" />
</el-menu-item>
<!-- 用户头像 -->
<div v-if="userStore.userInfo" class="avatar-container">
<Avatar />
</div>
<!-- 登录按钮 -->
<el-menu-item v-if="!userStore.userInfo" class="login-menu-item" index="no-route">
<LoginBtn :is-menu-item="true" />
</el-menu-item>
</div>
</el-menu>
<!-- 移动端头部 -->
<div class="mobile-header">
<div class="mobile-brand" @click="router.push('/')">
<el-image :src="logo" alt="logo" fit="contain" class="mobile-logo" />
<span class="mobile-brand-text">意心AI</span>
</div>
<div class="mobile-actions">
<!-- 用户头像或登录按钮 -->
<div v-if="userStore.userInfo" class="mobile-avatar">
<Avatar />
</div>
<LoginBtn v-else :is-menu-item="false" />
<!-- 汉堡菜单按钮 -->
<el-button class="menu-toggle" text @click="toggleMobileMenu">
<el-icon :size="24">
<component :is="mobileMenuVisible ? 'Close' : 'Menu'" />
</el-icon>
</el-button>
</div>
</div>
<!-- 移动端抽屉菜单 -->
<el-drawer
v-model="mobileMenuVisible"
direction="rtl"
:size="280"
:show-close="false"
class="mobile-drawer"
>
<template #header>
<div class="drawer-header">
<span class="drawer-title">菜单</span>
</div>
</template>
<el-menu
:default-active="activeIndex"
class="mobile-menu"
@select="handleSelect"
>
<!-- AI应用 -->
<el-sub-menu index="chat">
<template #title>
<el-icon><ChatDotRound /></el-icon>
<span>AI应用</span>
</template>
<el-menu-item index="/chat/conversation">AI对话</el-menu-item>
<el-menu-item index="/chat/image">AI图片</el-menu-item>
<el-menu-item index="/chat/video">AI视频</el-menu-item>
<el-menu-item index="/chat/agent">AI智能体</el-menu-item>
</el-sub-menu>
<!-- 模型库 -->
<el-menu-item index="/model-library">
<el-icon><Box /></el-icon>
<span>模型库</span>
</el-menu-item>
<!-- 控制台 -->
<el-sub-menu index="console">
<template #title>
<el-icon><Setting /></el-icon>
<span>控制台</span>
</template>
<el-menu-item index="/console/user">用户信息</el-menu-item>
<el-menu-item index="/console/apikey">API密钥</el-menu-item>
<el-menu-item index="/console/recharge-log">充值记录</el-menu-item>
<el-menu-item index="/console/usage">用量统计</el-menu-item>
<el-menu-item index="/console/premium">尊享服务</el-menu-item>
<el-menu-item index="/console/daily-task">每日任务</el-menu-item>
<el-menu-item index="/console/invite">每周邀请</el-menu-item>
<el-menu-item index="/console/activation">激活码兑换</el-menu-item>
</el-sub-menu>
<!-- 其他功能 -->
<div class="mobile-menu-actions">
<div class="action-item">
<AnnouncementBtn :is-menu-item="false" />
</div>
<div class="action-item">
<AiTutorialBtn />
</div>
<div v-if="userStore.userInfo" class="action-item">
<BuyBtn :is-menu-item="false" />
</div>
</div>
</el-menu>
</el-drawer>
</div>
</template>
<style scoped lang="scss">
.header-container {
display: flex;
flex-shrink: 0;
flex-direction: column;
--menu-hover-bg: var(--color-white);
--menu-active-color: var(--el-color-primary);
--menu-transition: all 0.2s ease;
width: 100%;
height: fit-content;
.header-box {
width: 100%;
width: calc(
100% - var(--sidebar-left-container-default-width, 0px) - var(
--sidebar-right-container-default-width,
0px
)
);
height: var(--header-container-default-heigth);
margin: 0 var(--sidebar-right-container-default-width, 0) 0
var(--sidebar-left-container-default-width, 0);
height: var(--header-container-default-height, 64px);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
user-select: none;
position: relative;
}
// 移动端头部(默认隐藏)
.mobile-header {
display: none;
}
.header-menu {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
border-bottom: none !important;
//background: var(--color-white);
}
// 左侧品牌区域
.menu-left {
flex-shrink: 0;
margin-left: 20px;
}
.brand-container {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: background-color var(--menu-transition);
&:hover {
background-color: var(--menu-hover-bg);
}
}
.logo-img {
width: 34px;
height: 34px;
flex-shrink: 0;
transition: transform var(--menu-transition);
&:hover {
transform: scale(1.05);
}
}
.brand-text {
font-size: 20px;
font-weight: 600;
color: var(--brand-color, #000000);
white-space: nowrap;
letter-spacing: -0.5px;
}
// 右侧功能区域
.menu-right {
display: flex;
align-items: center;
height: 100%;
margin-right: 16px;
gap: 4px;
}
// 公共菜单项样式
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
height: 100% !important;
border-bottom: none !important;
padding: 0 4px !important;
color: inherit !important;
&:hover {
background-color: transparent !important;
color: var(--menu-active-color) !important;
}
&.is-active {
background-color: transparent !important;
color: var(--menu-active-color) !important;
.menu-title {
color: var(--menu-active-color) !important;
}
}
}
// 聊天和控制台子菜单
.chat-submenu,
.console-submenu {
:deep(.el-sub-menu__title) {
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
}
}
.menu-title {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #606266;
transition: all 0.2s;
}
// 自定义按钮菜单项
.custom-menu-item,
.login-menu-item {
:deep(.el-menu-item-content) {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
}
}
// Avatar 容器
.avatar-container {
height: 100%;
display: flex;
align-items: center;
padding: 0 4px;
margin-left: 4px;
}
// 移动端头部样式
.mobile-brand {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background-color: var(--menu-hover-bg);
}
}
.mobile-logo {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.mobile-brand-text {
font-size: 18px;
font-weight: 600;
color: var(--brand-color, #000000);
}
.mobile-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mobile-avatar {
display: flex;
align-items: center;
}
.menu-toggle {
padding: 8px;
color: var(--el-text-color-primary);
&:hover {
color: var(--el-color-primary);
}
}
// 移动端抽屉样式
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0;
}
.drawer-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.mobile-menu {
border: none;
:deep(.el-sub-menu__title),
:deep(.el-menu-item) {
height: 48px;
line-height: 48px;
padding: 0 20px !important;
margin: 4px 0;
border-radius: 8px;
transition: all 0.2s;
&:hover {
background-color: var(--el-color-primary-light-9);
}
&.is-active {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
font-weight: 500;
}
}
:deep(.el-icon) {
margin-right: 12px;
font-size: 18px;
}
}
.mobile-menu-actions {
margin-top: 20px;
padding: 16px 0;
border-top: 1px solid var(--el-border-color-light);
.action-item {
padding: 8px 20px;
margin: 4px 0;
&:hover {
background-color: var(--el-color-primary-light-9);
border-radius: 8px;
}
}
}
// 响应式设计
@media (max-width: 1280px) {
.brand-text {
font-size: 18px;
}
.menu-left {
margin-left: 16px;
}
.menu-right {
margin-right: 12px;
gap: 2px;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
padding: 0 10px !important;
}
}
@media (max-width: 1024px) {
.brand-container {
gap: 8px;
padding: 6px 10px;
}
.logo-img {
width: 30px;
height: 30px;
}
.brand-text {
font-size: 16px;
}
.menu-title {
font-size: 13px;
}
.avatar-container {
padding: 0 8px;
}
}
@media (max-width: 768px) {
// 隐藏桌面端菜单
.desktop-menu {
display: none !important;
}
// 显示移动端头部
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 12px;
}
}
@media (max-width: 480px) {
.mobile-header {
padding: 0 8px;
}
.mobile-brand-text {
font-size: 16px;
}
.mobile-logo {
width: 28px;
height: 28px;
}
.mobile-actions {
gap: 4px;
}
}
</style>
<style lang="scss">
// 自定义弹出框样式
.custom-popover {
.el-menu {
border: none;
border-radius: 8px;
padding: 6px 0;
min-width: 160px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
.el-menu-item {
height: 40px;
line-height: 40px;
padding: 0 20px;
margin: 2px 8px;
border-radius: 6px;
font-size: 14px;
transition: all 0.2s ease;
&:hover {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
&.is-active {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
font-weight: 500;
}
}
}
}
</style>

View File

@@ -0,0 +1,725 @@
<script setup lang="ts">
import type { ConversationItem } from 'vue-element-plus-x/types/Conversations';
import type { ChatSessionVo } from '@/api/session/types';
import { ChatLineSquare, Expand, Fold, MoreFilled, Plus } from '@element-plus/icons-vue';
import { useRoute, useRouter } from 'vue-router';
import { get_session } from '@/api';
import { useDesignStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
const route = useRoute();
const router = useRouter();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const sessionId = computed(() => route.params?.id);
const conversationsList = computed(() => sessionStore.sessionList);
const loadMoreLoading = computed(() => sessionStore.isLoadingMore);
const active = ref<string | undefined>();
const isCollapsed = computed(() => designStore.isCollapseConversationList);
onMounted(async () => {
await sessionStore.requestSessionList();
if (conversationsList.value.length > 0 && sessionId.value) {
const currentSessionRes = await get_session(`${sessionId.value}`);
sessionStore.setCurrentSession(currentSessionRes.data);
}
});
watch(
() => sessionStore.currentSession,
(newValue) => {
active.value = newValue ? `${newValue.id}` : undefined;
},
);
// 创建会话
function handleCreatChat() {
sessionStore.createSessionBtn();
}
// 切换会话
function handleChange(item: ConversationItem<ChatSessionVo>) {
sessionStore.setCurrentSession(item);
router.replace({
name: 'chatConversationWithId',
params: {
id: item.id,
},
});
}
// 处理组件触发的加载更多事件
async function handleLoadMore() {
if (!sessionStore.hasMore)
return;
await sessionStore.loadMoreSessions();
}
// 右键菜单
function handleMenuCommand(command: string, item: ConversationItem<ChatSessionVo>) {
switch (command) {
case 'delete':
ElMessageBox.confirm('删除后,聊天记录将不可恢复。', '确定删除对话?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(() => {
sessionStore.deleteSessions([item.id!]);
nextTick(() => {
if (item.id === active.value) {
sessionStore.createSessionBtn();
}
});
})
.catch(() => {
// 取消删除
});
break;
case 'rename':
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
confirmButtonClass: 'el-button--primary',
cancelButtonClass: 'el-button--info',
roundButton: true,
inputValue: item.sessionTitle,
autofocus: false,
inputValidator: (value) => {
return !!value;
},
}).then(({ value }) => {
sessionStore
.updateSession({
id: item.id!,
sessionTitle: value,
sessionContent: item.sessionContent,
})
.then(() => {
ElMessage({
type: 'success',
message: '修改成功',
});
nextTick(() => {
if (sessionStore.currentSession?.id === item.id) {
sessionStore.setCurrentSession({
...item,
sessionTitle: value,
});
}
});
});
});
break;
default:
break;
}
}
// 折叠/展开侧边栏
function toggleSidebar() {
designStore.setIsCollapseConversationList(!designStore.isCollapseConversationList);
}
// 点击logo创建新会话仅在折叠状态
function handleLogoClick() {
if (isCollapsed.value) {
handleCreatChat();
}
}
// 处理右键菜单
function handleContextMenu(event: MouseEvent, item: ConversationItem<ChatSessionVo>) {
event.preventDefault();
// 在折叠状态下触发菜单
ElMessageBox.confirm('删除后,聊天记录将不可恢复。', '确定删除对话?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(() => {
sessionStore.deleteSessions([item.id!]);
nextTick(() => {
if (item.id === active.value) {
sessionStore.createSessionBtn();
}
});
})
.catch(() => {
// 取消删除
});
}
</script>
<template>
<div
class="aside-container"
:class="{ 'aside-collapsed': isCollapsed }"
>
<div class="aside-wrapper">
<!-- 头部 -->
<div class="aside-header">
<!-- 展开状态显示logo和标题 -->
<div
v-if="!isCollapsed"
class="header-content-expanded flex items-center gap-8px hover:cursor-pointer"
@click="handleCreatChat"
>
<span class="logo-text max-w-150px text-overflow">会话</span>
</div>
<!-- 折叠状态只显示logo -->
<div
v-else
class="header-content-collapsed flex items-center justify-center hover:cursor-pointer"
@click="handleLogoClick"
>
<el-icon size="20">
<ChatLineSquare />
</el-icon>
</div>
<!-- 折叠按钮 -->
<el-tooltip
:content="isCollapsed ? '展开侧边栏' : '折叠侧边栏'"
placement="bottom"
>
<el-button
class="collapse-btn"
type="text"
@click="toggleSidebar"
>
<el-icon v-if="isCollapsed">
<Expand />
</el-icon>
<el-icon v-else>
<Fold />
</el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 内容区域 -->
<div class="aside-body">
<!-- 创建会话按钮 -->
<div class="creat-chat-btn-wrapper">
<div
class="creat-chat-btn"
:class="{ 'creat-chat-btn-collapsed': isCollapsed }"
@click="handleCreatChat"
>
<el-icon class="add-icon">
<Plus />
</el-icon>
<span v-if="!isCollapsed" class="creat-chat-text">
新对话
</span>
</div>
</div>
<!-- 会话列表 -->
<div class="aside-content">
<div v-if="conversationsList.length > 0" class="conversations-wrap">
<Conversations
v-model:active="active"
:items="conversationsList"
:label-max-width="200"
:show-tooltip="!isCollapsed"
:tooltip-offset="60"
show-built-in-menu
groupable
row-key="id"
label-key="sessionTitle"
:tooltip-placement="isCollapsed ? 'right-start' : 'right'"
:load-more="handleLoadMore"
:load-more-loading="loadMoreLoading"
:items-style="{
marginLeft: '8px',
marginRight: '8px',
userSelect: 'none',
borderRadius: isCollapsed ? '12px' : '10px',
padding: isCollapsed ? '12px 8px' : '8px 12px',
justifyContent: isCollapsed ? 'center' : 'space-between',
width: isCollapsed ? '64px' : 'auto',
height: isCollapsed ? '64px' : 'auto',
minHeight: '48px',
flexDirection: isCollapsed ? 'column' : 'row',
position: 'relative',
}"
:items-active-style="{
backgroundColor: '#fff',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
color: 'rgba(0, 0, 0, 0.85)',
}"
:items-hover-style="{
backgroundColor: 'rgba(0, 0, 0, 0.04)',
}"
@menu-command="handleMenuCommand"
@change="handleChange"
@contextmenu="handleContextMenu"
>
<!-- 自定义折叠状态下的会话项内容 -->
<template #default="{ item }">
<div class="conversation-item-content">
<div v-if="isCollapsed" class="collapsed-item">
<div
class="avatar-circle"
@contextmenu="(e) => handleContextMenu(e, item)"
>
{{ item.sessionTitle?.charAt(0) || 'A' }}
</div>
<div v-if="item.unreadCount" class="unread-indicator">
{{ item.unreadCount }}
</div>
<!-- 折叠状态下的更多操作按钮 -->
<div
class="collapsed-menu-trigger"
@click.stop="handleMenuCommand('rename', item)"
@contextmenu.stop="(e) => handleContextMenu(e, item)"
>
<el-icon size="14">
<MoreFilled />
</el-icon>
</div>
</div>
<div v-else class="expanded-item">
<div class="conversation-info">
<div class="conversation-title">
{{ item.sessionTitle }}
</div>
<div v-if="item.sessionContent" class="conversation-preview">
{{ item.sessionContent.substring(0, 30) }}{{ item.sessionContent.length > 30 ? '...' : '' }}
</div>
</div>
<!-- 展开状态下的更多操作按钮Conversations组件自带 -->
</div>
</div>
</template>
</Conversations>
</div>
<el-empty
v-else
class="h-full flex-center"
:description="isCollapsed ? '' : '暂无对话记录'"
>
<template #description>
<span v-if="!isCollapsed">暂无对话记录</span>
</template>
</el-empty>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// 基础样式
.aside-container {
width: 240px;
height: 100%;
border-right: 0.5px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
flex-shrink: 0;
background-color: var(--sidebar-background-color, #f9fafb);
&.aside-collapsed {
width: 100px;
.aside-wrapper {
width: 100px;
}
.conversations-wrap {
padding: 0 8px !important;
}
}
}
.aside-wrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 240px;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
// 头部样式
.aside-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 12px;
border-bottom: 1px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
background-color: var(--sidebar-header-bg, #ffffff);
.header-content-expanded {
flex: 1;
}
.header-content-collapsed {
width: 36px;
height: 36px;
border-radius: 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.el-icon {
color: var(--el-text-color-secondary);
}
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: rgb(0 0 0 / 85%);
transform: skewX(-2deg);
}
.collapse-btn {
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
transition: transform 0.2s ease;
&:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
transform: scale(1.1);
}
.el-icon {
font-size: 18px;
}
}
}
// 内容区域
.aside-body {
flex: 1;
display: flex;
flex-direction: column;
//padding: 0 4px;
overflow: hidden;
.creat-chat-btn-wrapper {
padding: 12px 8px 4px;
.creat-chat-btn {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
color: #0057ff;
cursor: pointer;
user-select: none;
background-color: rgb(0 87 255 / 6%);
border: 1px solid rgb(0 102 255 / 15%);
border-radius: 12px;
transition: all 0.2s ease;
&:hover {
background-color: rgb(0 87 255 / 12%);
transform: translateY(-1px);
}
&.creat-chat-btn-collapsed {
width: 40px;
height: 40px;
border-radius: 50%;
margin: 0 auto;
}
.add-icon {
width: 24px;
height: 24px;
font-size: 16px;
}
.creat-chat-text {
font-size: 14px;
font-weight: 700;
line-height: 22px;
margin-left: 6px;
transition: opacity 0.2s ease;
}
}
}
.aside-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
.conversations-wrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
&:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
}
.conversation-item-content {
width: 100%;
height: 100%;
.collapsed-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
width: 100%;
height: 100%;
.avatar-circle {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
.unread-indicator {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background-color: #ff4d4f;
color: white;
border-radius: 8px;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.collapsed-menu-trigger {
position: absolute;
bottom: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
z-index: 2;
.el-icon {
color: var(--el-text-color-secondary);
font-size: 12px;
}
&:hover {
background-color: rgba(0, 0, 0, 0.1);
opacity: 1;
}
}
&:hover .collapsed-menu-trigger {
opacity: 0.7;
}
}
.expanded-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.conversation-info {
flex: 1;
min-width: 0;
margin-right: 8px;
.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;
}
.conversation-preview {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
}
}
// 样式穿透 - 重点优化操作按钮区域
:deep() {
.conversations-list {
background-color: transparent !important;
}
.conversation-group-title {
padding-left: 12px !important;
background-color: transparent !important;
transition: all 0.3s ease;
.title-text {
opacity: 0.6;
font-size: 12px;
transition: opacity 0.2s ease;
}
}
.conversation-item {
transition: all 0.3s ease;
// 确保操作按钮区域在折叠状态下可见
.conversation-item-actions {
transition: all 0.3s ease;
.el-button {
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
}
}
}
// 折叠状态样式
.aside-collapsed {
.conversation-group-title {
display: none !important;
}
.conversation-item {
justify-content: center !important;
padding: 12px 8px !important;
height: 64px !important;
min-height: 64px !important;
&-label {
display: none !important;
}
&-actions {
// 隐藏默认的操作按钮,使用自定义的
display: none !important;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.aside-container {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
&.aside-collapsed {
transform: translateX(-100%);
width: 100px !important;
}
&:not(.aside-collapsed) {
transform: translateX(0);
}
}
.aside-wrapper {
width: 280px !important;
.aside-collapsed & {
width: 100px !important;
}
}
// 移动端遮罩层
.aside-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease;
}
}
// 动画
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,8 @@
<!-- DesignConfig -->
<script setup lang="ts"></script>
<template>
<div>配置页面</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
// 打开AI使用教程跳转到外部链接
function openTutorial() {
window.open('https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde', '_blank');
}
</script>
<template>
<div class="ai-tutorial-btn-container" data-tour="ai-tutorial-link">
<div
class="ai-tutorial-btn"
title="点击跳转YiXinAI玩法指南专栏"
@click="openTutorial"
>
<!-- PC端显示文字 -->
<span class="pc-text">文档</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 14l9-5-9-5-9 5 9 5z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 14l6.16-3.422A12.083 12.083 0 0118 13.5c0 2.579-3.582 4.5-6 4.5s-6-1.921-6-4.5c0-.432.075-.85.198-1.244L12 14z"
/>
</svg>
</div>
</div>
</template>
<style scoped lang="scss">
.ai-tutorial-btn-container {
display: flex;
align-items: center;
.ai-tutorial-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #E6A23C;
transition: all 0.2s;
&:hover {
color: #F1B44C;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.ai-tutorial-btn-container {
.ai-tutorial-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useAnnouncementStore } from '@/stores';
const announcementStore = useAnnouncementStore();
const { announcements } = storeToRefs(announcementStore);
// 计算未读公告数量(系统公告数量)
const unreadCount = computed(() => {
if (!Array.isArray(announcements.value))
return 0;
return announcements.value.filter(a => a.type === 'System').length;
});
// 打开公告弹窗
function openAnnouncement() {
announcementStore.openDialog();
}
</script>
<template>
<div class="announcement-btn-container" data-tour="announcement-btn">
<el-badge
is-dot
class="announcement-badge"
>
<!-- :value="unreadCount" -->
<!-- :hidden="unreadCount === 0" -->
<!-- :max="99" -->
<div
class="announcement-btn"
title="查看公告"
@click="openAnnouncement"
>
<!-- PC端显示文字 -->
<span class="pc-text">公告</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</div>
</el-badge>
</div>
</template>
<style scoped lang="scss">
.announcement-btn-container {
display: flex;
align-items: center;
.announcement-badge {
:deep(.el-badge__content) {
background-color: #f56c6c;
border: none;
}
}
.announcement-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #409eff;
transition: all 0.2s;
&:hover {
color: #66b1ff;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.announcement-btn-container {
.announcement-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -0,0 +1,499 @@
<!-- 头像 -->
<script setup lang="ts">
import { ChatLineRound } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { nextTick, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useGuideTour } from '@/hooks/useGuideTour';
import { useAnnouncementStore, useGuideTourStore, useUserStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
import { getUserProfilePicture, isUserVip } from '@/utils/user';
const router = useRouter();
const userStore = useUserStore();
const sessionStore = useSessionStore();
const guideTourStore = useGuideTourStore();
const announcementStore = useAnnouncementStore();
const { startUserCenterTour } = useGuideTour();
/* 弹出面板 开始 */
const popoverStyle = ref({
width: '200px',
padding: '4px',
height: 'fit-content',
});
const popoverRef = ref();
// 弹出面板内容
const popoverList = ref([
{
key: '5',
title: '控制台',
icon: 'settings-4-fill',
},
{
key: '3',
divider: true,
},
{
key: '7',
title: '公告',
icon: 'notification-fill',
},
{
key: '8',
title: '模型库',
icon: 'apps-fill',
},
{
key: '9',
title: '文档',
icon: 'book-fill',
},
{
key: '6',
title: '新手引导',
icon: 'dashboard-fill',
},
{
key: '3',
divider: true,
},
{
key: '4',
title: '退出登录',
icon: 'logout-box-r-line',
},
]);
const dialogVisible = ref(false);
const rechargeLogRef = ref();
const activeNav = ref('user');
// ============ 邀请码分享功能 ============
/** 从 URL 获取的邀请码 */
const externalInviteCode = ref<string>('');
const navItems = [
{ name: 'user', label: '用户信息', icon: 'User' },
// { name: 'role', label: '角色管理', icon: 'Avatar' },
// { name: 'permission', label: '权限管理', icon: 'Key' },
// { name: 'userInfo', label: '用户信息', icon: 'User' },
{ name: 'apiKey', label: 'API密钥', icon: 'Key' },
{ name: 'rechargeLog', label: '充值记录', icon: 'Document' },
{ name: 'usageStatistics', label: '用量统计', icon: 'Histogram' },
{ name: 'premiumService', label: '尊享服务', icon: 'ColdDrink' },
{ name: 'dailyTask', label: '每日任务(限时)', icon: 'Trophy' },
{ name: 'cardFlip', label: '每周邀请(限时)', icon: 'Present' },
// { name: 'usageStatistics2', label: '用量统计2', icon: 'Histogram' },
{ name: 'activationCode', label: '激活码兑换', icon: 'MagicStick' },
];
function openDialog() {
dialogVisible.value = true;
}
function handleConfirm(activeNav: string) {
ElMessage.success('操作成功');
}
// 导航切换
function handleNavChange(nav: string) {
activeNav.value = nav;
// 同步更新 store 中的 tab 状态,防止下次通过 store 打开同一 tab 时因值未变而不触发 watch
if (userStore.userCenterActiveTab !== nav) {
userStore.userCenterActiveTab = nav;
}
}
// 联系售后
function handleContactSupport() {
rechargeLogRef.value?.contactCustomerService();
}
const { startHeaderTour } = useGuideTour();
// 开始引导教程
function handleStartTutorial() {
startHeaderTour();
}
// 点击
function handleClick(item: any) {
switch (item.key) {
case '1':
ElMessage.warning('暂未开放');
break;
case '2':
ElMessage.warning('暂未开放');
break;
case '5':
// 打开控制台
popoverRef.value?.hide?.();
router.push('/console');
break;
case '6':
handleStartTutorial();
break;
case '7':
// 打开公告
popoverRef.value?.hide?.();
announcementStore.openDialog();
break;
case '8':
// 打开模型库
popoverRef.value?.hide?.();
router.push('/model-library');
break;
case '9':
// 打开文档
popoverRef.value?.hide?.();
window.open('https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde', '_blank');
break;
case '4':
popoverRef.value?.hide?.();
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
confirmButtonText: '确认退出',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(async () => {
// 在这里执行退出方法
await userStore.logout();
// 清空回话列表并回到默认页
await sessionStore.requestSessionList(1, true);
await sessionStore.createSessionBtn();
ElMessage({
type: 'success',
message: '退出成功',
});
})
.catch(() => {
// ElMessage({
// type: 'info',
// message: '取消',
// });
});
break;
default:
break;
}
}
function openVipGuide() {
ElMessageBox.confirm(
`
<div class="text-center leading-relaxed">
<h3 class="text-lg font-bold mb-3">${isUserVip() ? 'YiXinAI-VIP 会员' : '成为 YiXinAI-VIP'}</h3>
<p class="mb-2">
${
isUserVip()
? '您已是尊贵会员,享受全部 AI 模型与专属服务。感谢支持!'
: '解锁所有 AI 模型,无限加速,专属客服,尽享尊贵体验。'
}
</p>
${
isUserVip()
? '<p class="text-sm text-gray-500">您可随时访问产品页面查看更多特权内容。</p>'
: '<p class="text-sm text-gray-500">点击下方按钮,立即升级为 VIP 会员!</p>'
}
</div>
`,
isUserVip() ? '会员状态' : '会员尊享',
{
confirmButtonText: '前往产品页面',
cancelButtonText: '关闭',
dangerouslyUseHTMLString: true,
type: 'info',
center: true,
roundButton: true,
},
)
.then(() => {
router.push({
name: 'products', // 使用命名路由
query: { from: isUserVip() ? 'vip' : 'user' }, // 可选:添加来源标识
});
})
.catch(() => {
// 点击右上角关闭或“关闭”按钮,不执行任何操作
});
}
// ============ 监听对话框打开事件,切换到邀请码标签页 ============
watch(dialogVisible, (newVal) => {
if (newVal && externalInviteCode.value) {
// 对话框打开后,切换标签页(已通过 :default-active 绑定,会自动响应)
// console.log('[Avatar] watch: 对话框已打开,切换到 cardFlip 标签页');
nextTick(() => {
activeNav.value = 'cardFlip';
// console.log('[Avatar] watch: 已设置 activeNav 为', activeNav.value);
});
}
// 对话框关闭时,清除邀请码状态和 URL 参数
if (!newVal && externalInviteCode.value) {
// console.log('[Avatar] watch: 对话框关闭,清除邀请码状态');
externalInviteCode.value = '';
// 清除 URL 中的 inviteCode 参数
const url = new URL(window.location.href);
if (url.searchParams.has('inviteCode')) {
url.searchParams.delete('inviteCode');
window.history.replaceState({}, '', url.toString());
// console.log('[Avatar] watch: 已清除 URL 中的 inviteCode 参数');
}
}
});
// ============ 监听 URL 参数,实现邀请码快捷分享 ============
onMounted(() => {
// 获取 URL 查询参数
const urlParams = new URLSearchParams(window.location.search);
const inviteCode = urlParams.get('inviteCode');
if (inviteCode && inviteCode.trim()) {
// console.log('[Avatar] onMounted: 检测到邀请码', inviteCode);
// 保存邀请码
externalInviteCode.value = inviteCode.trim();
// 先设置标签页为 cardFlip
activeNav.value = 'cardFlip';
// console.log('[Avatar] onMounted: 设置 activeNav 为', activeNav.value);
// 延迟打开对话框,确保状态已更新
nextTick(() => {
setTimeout(() => {
// console.log('[Avatar] onMounted: 打开用户中心对话框');
dialogVisible.value = true;
}, 200);
});
// 注意:不立即清除 URL 参数,保留给登录后使用
// URL 参数会在对话框关闭时清除
}
});
// ============ 监听引导状态,自动打开用户中心并开始引导 ============
watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
if (shouldStart) {
// 清除触发标记
guideTourStore.clearUserCenterTourTrigger();
// 注册导航切换回调
guideTourStore.setUserCenterNavChangeCallback((nav: string) => {
activeNav.value = nav;
});
// 注册关闭弹窗回调
guideTourStore.setUserCenterCloseCallback(() => {
dialogVisible.value = false;
});
// 打开用户中心弹窗
nextTick(() => {
dialogVisible.value = true;
// 等待弹窗打开后开始引导
setTimeout(() => {
startUserCenterTour();
}, 600);
});
}
});
// ============ 监听 Store 状态,控制用户中心弹窗 (新增) ============
watch(() => userStore.isUserCenterVisible, (val) => {
dialogVisible.value = val;
if (val && userStore.userCenterActiveTab) {
activeNav.value = userStore.userCenterActiveTab;
}
});
watch(() => userStore.userCenterActiveTab, (val) => {
if (val) {
activeNav.value = val;
}
});
// 监听本地 dialogVisible 变化,同步回 Store可选为了保持一致性
watch(dialogVisible, (val) => {
if (!val) {
userStore.closeUserCenter();
}
});
// ============ 暴露方法供外部调用 ============
defineExpose({
openDialog,
});
</script>
<template>
<div class="flex items-center gap-2 ">
<!-- 用户信息区域 -->
<div class="user-info-display cursor-pointer flex flex-col text-right mr-2 leading-tight" @click="openDialog">
<div class="text-sm font-semibold text-gray-800">
{{ userStore.userInfo?.user.nick ?? '未登录用户' }}
</div>
<!-- 角色展示 -->
<div>
<span
v-if="isUserVip()"
class="inline-block px-2 py-0.5 text-xs text-yellow-700 bg-yellow-100 rounded-full font-semibold"
>
YiXinAI-VIP
</span>
<span
v-else
class="inline-block px-2 py-0.5 text-xs text-gray-600 bg-gray-100 rounded-full cursor-pointer hover:bg-yellow-50 hover:text-yellow-700 transition"
>
普通用户
</span>
</div>
</div>
<!-- 头像区域 -->
<div class="avatar-container" data-tour="user-avatar">
<Popover
ref="popoverRef"
placement="bottom-end"
trigger="clickTarget"
:trigger-style="{ cursor: 'pointer' }"
popover-class="popover-content"
:popover-style="popoverStyle"
>
<template #trigger>
<el-avatar :src="getUserProfilePicture()" :size="28" fit="fit" shape="circle" />
</template>
<div class="popover-content-box shadow-lg">
<!-- 用户信息 -->
<div class="user-info-box flex items-center gap-8px p-8px rounded-lg mb-2">
<el-avatar :src="getUserProfilePicture()" :size="32" fit="fit" shape="circle" />
<div class="flex flex-col text-sm">
<div class="font-semibold text-gray-800">
{{ userStore.userInfo?.user.nick ?? '未登录用户' }}
</div>
<div class="text-xs text-gray-500">
<span
v-if="isUserVip()"
class="inline-block px-2 py-0.5 text-xs text-yellow-700 bg-yellow-100 rounded-full font-semibold"
>
YiXinAI-VIP
</span>
<span
v-else
class="inline-block px-2 py-0.5 text-xs text-gray-600 bg-gray-100 rounded-full cursor-pointer hover:bg-yellow-50 hover:text-yellow-700 transition"
>
普通用户
</span>
</div>
</div>
</div>
<div class="divder h-1px bg-gray-200 my-4px" />
<div v-for="item in popoverList" :key="item.key" class="popover-content-box-items h-full">
<div
v-if="!item.divider"
class="popover-content-box-item flex items-center h-full gap-8px p-8px pl-10px pr-12px rounded-lg hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
@click="handleClick(item)"
>
<SvgIcon :name="item.icon!" size="16" class-name="flex-none" />
<div class="popover-content-box-item-text font-size-14px text-overflow max-h-120px">
{{ item.title }}
</div>
</div>
<div v-if="item.divider" class="divder h-1px bg-gray-200 my-4px" />
</div>
</div>
</Popover>
</div>
<nav-dialog
v-model="dialogVisible"
title="控制台"
:nav-items="navItems"
:default-active="activeNav"
@confirm="handleConfirm"
@nav-change="handleNavChange"
>
<template #extra-actions>
<el-tooltip v-if="isUserVip() && activeNav === 'rechargeLog'" content="联系售后" placement="bottom">
<el-button circle plain size="small" @click="handleContactSupport">
<el-icon color="#07c160">
<ChatLineRound />
</el-icon>
</el-button>
</el-tooltip>
</template>
<!-- 用户管理内容 -->
<template #user>
<user-management />
</template>
<!-- 用量统计 -->
<template #usageStatistics>
<usage-statistics />
</template>
<!-- 尊享服务 -->
<template #premiumService>
<premium-service />
</template>
<!-- 用量统计 -->
<!-- <template #usageStatistics2> -->
<!-- <usage-statistics2 /> -->
<!-- </template> -->
<!-- 角色管理内容 -->
<template #role>
<!-- < /> -->
</template>
<!-- 权限管理内容 -->
<template #permission>
<!-- <permission-management /> -->
</template>
<template #apiKey>
<APIKeyManagement />
</template>
<template #activationCode>
<activation-code />
</template>
<template #dailyTask>
<daily-task />
</template>
<template #cardFlip>
<card-flip-activity :external-invite-code="externalInviteCode" />
</template>
<template #rechargeLog>
<recharge-log ref="rechargeLogRef" />
</template>
</nav-dialog>
</div>
</template>
<style scoped lang="scss">
.popover-content {
width: 520px;
height: 520px;
}
.popover-content-box {
padding: 8px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { showProductPackage } from '@/utils/product-package';
// 点击购买按钮
function onProductPackage() {
showProductPackage();
}
</script>
<template>
<div class="buy-btn-container">
<el-button
class="buy-btn flex items-center gap-2 px-5 py-2 font-semibold shadow-lg"
data-tour="buy-btn"
@click="onProductPackage"
>
<span>立即购买</span>
</el-button>
</div>
</template>
<style scoped lang="scss">
.buy-btn-container {
display: flex;
align-items: center;
margin: 0 22px 0 0;
.buy-btn {
background: linear-gradient(90deg, #FFD700, #FFC107);
color: #fff;
border: none;
border-radius: 9999px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(255, 215, 0, 0.5);
background: linear-gradient(90deg, #FFC107, #FFD700);
}
.icon-rocket {
color: #fff;
}
.animate-bounce {
animation: bounce 1.2s infinite;
}
}
}
// 移动端屏幕小于756px
@media screen and (max-width: 756px) {
.buy-btn-container {
margin: 0 ;
.buy-btn {
font-size: 12px;
max-width: 60px;
padding: 8px 12px;
}
}
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
</style>

View File

@@ -0,0 +1,39 @@
<!-- 侧边栏折叠按钮 -->
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useDesignStore } from '@/stores';
// const { changeCollapse } = useCollapseToggle();
const designStore = useDesignStore();
function handleChangeCollapse() {
designStore.setIsCollapseConversationList(!designStore.isCollapseConversationList);
// 待定
// changeCollapse();
// // 每次切换折叠状态,重置安全区状态
// designStore.isSafeAreaHover = false;
// // 重置首次激活悬停状态
// designStore.hasActivatedHover = false;
// if (!designStore.isCollapse) {
// document.documentElement.style.setProperty(
// `--sidebar-left-container-default-width`,
// `${SIDE_BAR_WIDTH}px`,
// );
// }
// else {
// document.documentElement.style.setProperty(`--sidebar-left-container-default-width`, ``);
// }
}
</script>
<template>
<div class="collapse-container btn-icon-btn" @click="handleChangeCollapse">
<SvgIcon v-if="!designStore.isCollapseConversationList" name="ms-left-panel-close-outline" size="24" />
<SvgIcon v-if="designStore.isCollapseConversationList" name="ms-left-panel-open-outline" size="24" />
</div>
</template>
<style lang="scss" scoped>
// .collapse-container {
// }
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { useUserStore } from '@/stores';
const userStore = useUserStore();
// 打开用户中心对话框(通过调用 Avatar 组件的方法)
function openConsole() {
// 触发事件,由父组件处理
emit('open-console');
}
const emit = defineEmits(['open-console']);
</script>
<template>
<div class="console-btn-container" data-tour="console-btn">
<div
class="console-btn"
title="打开控制台"
@click="openConsole"
>
<!-- PC端显示文字 -->
<span class="pc-text">控制台</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
</div>
</template>
<style scoped lang="scss">
.console-btn-container {
display: flex;
align-items: center;
.console-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #606266;
transition: all 0.2s;
&:hover {
color: #909399;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.console-btn-container {
.console-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -0,0 +1,47 @@
<!-- 添加新会话按钮 -->
<script setup lang="ts">
import { Plus } from '@element-plus/icons-vue';
import { useSessionStore } from '@/stores/modules/session';
const sessionStore = useSessionStore();
/* 创建会话 开始 */
function handleCreatChat() {
if (!sessionStore.currentSession)
return;
// 创建会话, 跳转到默认聊天
sessionStore.createSessionBtn();
}
/* 创建会话 结束 */
</script>
<template>
<div
class="create-chat-container flex-center flex-none p-6px pl-8px pr-8px c-#0057ff b-#0057ff b-rounded-12px border-1px hover:bg-#0057ff hover:c-#fff hover:b-#fff hover:cursor-pointer border-solid select-none"
:class="{
'is-disabled': !sessionStore.currentSession,
}"
@click="handleCreatChat"
>
<el-icon size="12" class="flex-center flex-none w-14px h-14px">
<Plus />
</el-icon>
<span class="ml-4px font-size-14px font-700">新对话</span>
</div>
</template>
<style scoped lang="scss">
.is-disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
color: #0057ff;
cursor: not-allowed;
background-color: transparent;
border-color: #0057ff;
border-style: solid;
transition: none;
}
}
</style>

View File

@@ -0,0 +1,29 @@
<!-- LoginBtn 登录按钮 -->
<script setup lang="ts">
import LoginDialog from '@/components/LoginDialog/index.vue';
import { useUserStore } from '@/stores';
const userStore = useUserStore();
const isLoginDialogVisible = computed(() => userStore.isLoginDialogVisible);
// 点击登录按钮时调用Store方法打开弹框
function handleClickLogin() {
userStore.openLoginDialog();
}
</script>
<template>
<div class="login-btn-wrapper">
<div
class="login-btn bg-#191c1f c-#fff font-size-14px rounded-8px flex-center text-overflow p-10px pl-12px pr-12px min-w-49px h-16px cursor-pointer hover:bg-#232629 select-none"
@click="handleClickLogin"
>
登录
</div>
<!-- 登录弹框 -->
<LoginDialog v-model:visible="isLoginDialogVisible" />
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goToModelLibrary() {
router.push('/model-library');
}
</script>
<template>
<div class="model-library-btn-container" data-tour="model-library-btn">
<div
class="model-library-btn"
title="查看模型库"
@click="goToModelLibrary"
>
<!-- PC端显示文字 -->
<span class="pc-text">模型库</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
</div>
</template>
<style scoped lang="scss">
.model-library-btn-container {
display: flex;
align-items: center;
.model-library-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #606266;
transition: all 0.2s;
&:hover {
color: #606266;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.model-library-btn-container {
.model-library-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
// 检查是否在聊天页面
const isOnChatPage = computed(() => {
return route.path.startsWith('/chat');
});
function goToChat() {
router.push('/chat/conversation');
}
</script>
<template>
<div v-if="!isOnChatPage" class="start-chat-btn-container" data-tour="start-chat-btn">
<div
class="start-chat-btn"
title="开始聊天"
@click="goToChat"
>
<el-icon class="chat-icon">
<i-ep-chat-dot-round />
</el-icon>
<span class="btn-text">开始聊天</span>
</div>
</div>
</template>
<style scoped lang="scss">
.start-chat-btn-container {
display: flex;
align-items: center;
margin-right: 12px;
.start-chat-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
color: #fff;
font-weight: 600;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
&:active {
transform: translateY(0);
}
.chat-icon {
font-size: 18px;
}
.btn-text {
font-size: 14px;
}
}
}
// 移动端隐藏文字
@media (max-width: 768px) {
.start-chat-btn-container {
margin-right: 8px;
.start-chat-btn {
padding: 8px;
.btn-text {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { useColorMode } from '@vueuse/core';
// 使用 VueUse 的 useColorMode
const mode = useColorMode({
attribute: 'class',
modes: {
light: 'light',
dark: 'dark',
},
});
// 切换主题
function toggleTheme() {
mode.value = mode.value === 'dark' ? 'light' : 'dark';
}
// 主题图标
const themeIcon = computed(() => {
return mode.value === 'dark' ? 'Sunny' : 'Moon';
});
// 主题标题
const themeTitle = computed(() => {
return mode.value === 'dark' ? '切换到浅色模式' : '切换到深色模式';
});
</script>
<template>
<div class="theme-btn-container" data-tour="theme-btn">
<div
class="theme-btn"
:title="themeTitle"
@click="toggleTheme"
>
<!-- PC端显示文字 + 图标 -->
<el-icon class="theme-icon">
<component :is="`i-ep-${themeIcon}`" />
</el-icon>
<span class="pc-text">主题</span>
</div>
</div>
</template>
<style scoped lang="scss">
.theme-btn-container {
display: flex;
align-items: center;
.theme-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s;
color: var(--el-text-color-regular);
&:hover {
background-color: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.theme-icon {
font-size: 18px;
transition: transform 0.3s;
}
&:hover .theme-icon {
transform: rotate(20deg);
}
// PC端显示文字
.pc-text {
display: inline;
font-size: 14px;
font-weight: 500;
}
}
}
// 移动端隐藏文字
@media (max-width: 768px) {
.theme-btn-container {
.theme-btn {
padding: 8px;
.pc-text {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
<!-- 标题编辑 -->
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useSessionStore } from '@/stores/modules/session';
const sessionStore = useSessionStore();
const currentSession = computed(() => sessionStore.currentSession);
function handleClickTitle() {
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
confirmButtonClass: 'el-button--primary',
cancelButtonClass: 'el-button--info',
roundButton: true,
inputValue: currentSession.value?.sessionTitle,
inputValidator: (value) => {
if (!value) {
return false;
}
return true;
},
})
.then(({ value }) => {
sessionStore
.updateSession({
id: currentSession.value!.id,
sessionTitle: value,
sessionContent: currentSession.value!.sessionContent,
})
.then(() => {
ElMessage({
type: 'success',
message: '修改成功',
});
nextTick(() => {
// 如果是当前会话,则更新当前选中会话信息
sessionStore.setCurrentSession({
...currentSession.value,
sessionTitle: value,
});
});
});
})
.catch(() => {
// ElMessage({
// type: 'info',
// message: '取消修改',
// });
});
}
</script>
<template>
<div v-if="currentSession" class="w-full h-full flex flex-col justify-center">
<div class="box-border mr-20px">
<div
class="title-editing-container p-4px w-fit max-w-full flex items-center justify-start cursor-pointer select-none hover:bg-[rgba(0,0,0,.04)] cursor-pointer rounded-md font-size-14px"
@click="handleClickTitle"
>
<div class="text-overflow select-none pr-8px">
{{ currentSession.sessionTitle }}
</div>
<SvgIcon name="draft-line" size="14" class="flex-none c-gray-500" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.title-editing-container {
transition: all 0.3s ease;
&:hover {
.svg-icon {
display: block;
opacity: 1;
}
}
.svg-icon {
display: none;
opacity: 0.5;
transition: all 0.3s ease;
}
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { QuestionFilled } from '@element-plus/icons-vue';
import { useGuideTour } from '@/hooks/useGuideTour';
const { startHeaderTour } = useGuideTour();
// 开始引导教程
function handleStartTutorial() {
startHeaderTour();
}
</script>
<template>
<div class="tutorial-btn-container" data-tour="tutorial-btn">
<div
class="tutorial-btn"
@click="handleStartTutorial"
>
<!-- PC端显示文字 -->
<span class="pc-text">新手引导</span>
<!-- 移动端显示图标 -->
<el-icon class="mobile-icon" :size="20">
<QuestionFilled />
</el-icon>
</div>
</div>
</template>
<style scoped lang="scss">
.tutorial-btn-container {
display: flex;
align-items: center;
.tutorial-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #409eff;
transition: all 0.2s;
&:hover {
color: #66b1ff;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.tutorial-btn-container {
.tutorial-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -0,0 +1,122 @@
<!-- Header 头部 -->
<script setup lang="ts">
import { onKeyStroke } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { SIDE_BAR_WIDTH } from '@/config/index';
import { useDesignStore, useUserStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
import AiTutorialBtn from './components/AiTutorialBtn.vue';
import AnnouncementBtn from './components/AnnouncementBtn.vue';
import Avatar from './components/Avatar.vue';
import BuyBtn from './components/BuyBtn.vue';
import Collapse from './components/Collapse.vue';
import ConsoleBtn from './components/ConsoleBtn.vue';
import CreateChat from './components/CreateChat.vue';
import LoginBtn from './components/LoginBtn.vue';
import ModelLibraryBtn from './components/ModelLibraryBtn.vue';
import StartChatBtn from './components/StartChatBtn.vue';
import ThemeBtn from './components/ThemeBtn.vue';
import TitleEditing from './components/TitleEditing.vue';
const router = useRouter();
const userStore = useUserStore();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const avatarRef = ref();
const currentSession = computed(() => sessionStore.currentSession);
onMounted(() => {
// 全局设置侧边栏默认宽度 (这个是不变的,一开始就设置)
document.documentElement.style.setProperty(`--sidebar-default-width`, `${SIDE_BAR_WIDTH}px`);
if (designStore.isCollapse) {
document.documentElement.style.setProperty(`--sidebar-left-container-default-width`, ``);
}
else {
document.documentElement.style.setProperty(
`--sidebar-left-container-default-width`,
`${SIDE_BAR_WIDTH}px`,
);
}
});
// 定义 Ctrl+K 的处理函数
function handleCtrlK(event: KeyboardEvent) {
event.preventDefault(); // 防止默认行为
sessionStore.createSessionBtn();
}
// 设置全局的键盘按键监听
onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtrlK, {
passive: false,
});
// 打开控制台
function handleOpenConsole() {
router.push('/console');
}
</script>
<template>
<div class="header-container">
<div class="header-box relative z-10 top-0 left-0 right-0">
<div class="absolute left-0 right-0 top-0 bottom-0 flex items-center flex-row">
<div
class="overflow-hidden flex h-full items-center flex-row flex-1 w-fit flex-shrink-0 min-w-0"
>
<div class="w-full flex items-center flex-row">
<!-- 左边 -->
<div
v-if="designStore.isCollapse"
class="left-box flex h-full items-center pl-20px gap-12px flex-shrink-0 flex-row"
>
<Collapse />
<CreateChat />
<div v-if="currentSession" class="w-0.5px h-30px bg-[rgba(217,217,217)]" />
</div>
<!-- 中间 -->
<div class="middle-box flex-1 min-w-0 ml-12px">
<TitleEditing />
</div>
</div>
</div>
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
<StartChatBtn />
<AnnouncementBtn />
<ModelLibraryBtn />
<ThemeBtn />
<AiTutorialBtn />
<ConsoleBtn @open-console="handleOpenConsole" />
<BuyBtn v-show="userStore.userInfo" />
<Avatar v-show="userStore.userInfo" ref="avatarRef" />
<LoginBtn v-show="!userStore.userInfo" />
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.header-container {
display: flex;
flex-shrink: 0;
flex-direction: column;
width: 100%;
height: fit-content;
.header-box {
width: 100%;
width: calc(
100% - var(--sidebar-left-container-default-width, 0px) - var(
--sidebar-right-container-default-width,
0px
)
);
height: var(--header-container-default-heigth);
margin: 0 var(--sidebar-right-container-default-width, 0) 0
var(--sidebar-left-container-default-width, 0);
}
}
</style>

View File

@@ -0,0 +1,8 @@
<!-- Logo -->
<script setup lang="ts"></script>
<template>
<div>Logo</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,89 @@
<!-- Main -->
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useDesignStore } from '@/stores';
import { useKeepAliveStore } from '@/stores/modules/keepAlive';
const designStore = useDesignStore();
const keepAliveStore = useKeepAliveStore();
const useroute = useRoute();
const transitionName = computed(() => {
if (useroute.meta.isDefaultChat) {
return 'slide';
}
else {
return designStore.pageAnimateType;
}
});
// 刷新当前路由页面缓存方法
const isRouterShow = ref(true);
const refreshMainPage = (val: boolean) => (isRouterShow.value = val);
provide('refresh', refreshMainPage);
</script>
<template>
<el-main
class="layout-main"
:class="{ 'layout-main-overfow-hidden': useroute.meta.isDefaultChat }"
>
<router-view v-slot="{ Component, route }">
<transition :name="transitionName" mode="out-in" appear>
<keep-alive :max="10" :include="keepAliveStore.keepAliveName">
<component :is="Component" v-if="isRouterShow" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</el-main>
</template>
<style scoped lang="scss">
.layout-main-overfow-hidden {
overflow: hidden;
}
/* 默认聊天页面:上下滑动动画 */
.slide-enter-from {
margin-top: 200px;
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s; /* 缓出动画 */
}
.slide-enter-to {
margin-top: 0;
opacity: 1;
}
.slide-leave-from {
margin-top: 0;
opacity: 1;
}
.slide-leave-to {
margin-top: 200px;
opacity: 0;
}
/* 带id聊天页面中间缩放动画 */
// .zoom-fade-enter-from {
// transform: scale(0.9); /* 进入前:缩小隐藏 */
// opacity: 0;
// }
// .zoom-fade-enter-active,
// .zoom-fade-leave-active {
// transition: all 0.3s; /* 缓入动画 */
// }
// .zoom-fade-enter-to {
// transform: scale(1); /* 进入后:正常大小 */
// opacity: 1;
// }
// .zoom-fade-leave-from {
// transform: scale(1); /* 离开前:正常大小 */
// opacity: 1;
// }
// .zoom-fade-leave-to {
// transform: scale(0.9); /* 离开后:缩小隐藏 */
// opacity: 0;
// }
</style>

View File

@@ -1,21 +1,41 @@
<!-- 主布局 -->
<script setup lang="ts">
import type { LayoutType } from '@/config/design';
import { useRoute } from 'vue-router';
import { useResponsive } from '@/hooks/useResponsive';
import LayoutBlankPage from '@/layouts/LayoutBlankPage/index.vue';
// import { useScreenStore } from '@/hooks/useScreen';
import LayoutDefault from '@/layouts/LayoutDefault/index.vue';
import LayoutMobile from '@/layouts/LayoutMobile/index.vue';
import LayoutVertical from '@/layouts/LayoutVertical/index.vue';
import { useDesignStore } from '@/stores';
// 这里添加布局类型
const LayoutComponent: Record<LayoutType, Component> = {
const LayoutComponent: Record<LayoutType | 'mobile', Component> = {
default: LayoutDefault,
vertical: LayoutVertical,
blankPage: LayoutBlankPage,
mobile: LayoutMobile,
};
const designStore = useDesignStore();
// const { isMobile } = useScreenStore();
const { isMobile } = useResponsive();
const route = useRoute();
/** 获取布局格式 */
const layout = computed((): LayoutType => designStore.layout);
const layout = computed((): LayoutType | 'mobile' => {
// 移动端强制使用移动布局
// if (isMobile.value) {
// return 'mobile';
// }
// 优先使用路由 meta 中定义的 layout
if (route.meta?.layout) {
return route.meta.layout as LayoutType;
}
// 否则使用全局设置的 layout
return designStore.layout;
});
onMounted(() => {
// 更好的做法是等待所有资源加载
window.addEventListener('load', () => {
@@ -47,4 +67,23 @@ onMounted(() => {
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
:deep(.aside-content .conversations-container){
width: 100%;
}
:deep(.aside-content .conversations-list){
padding: 0 !important;
}
:deep(.aside-content .conversations-wrap){
padding: 0 !important;
}
:deep(.aside-content .el-scrollbar__thumb){
width: 0 !important;
}
:deep(.nav-menu)
{
border-right: 0.5px solid var(--s-color-border-tertiary, rgba(0, 0, 0, 0.08));
}
</style>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
// 智能体功能 - 预留
</script>
<template>
<div class="image-generation-page">
<el-empty description="智能体功能开发中,敬请期待">
<template #image>
<el-icon style="font-size: 80px; color: var(--el-color-primary);">
<i-ep-picture />
</el-icon>
</template>
</el-empty>
</div>
</template>
<style scoped lang="scss">
.image-generation-page {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: var(--el-bg-color);
}
</style>

Some files were not shown because too many files have changed in this diff Show More