39 Commits

Author SHA1 Message Date
ccnetcore
67cb142c07 stlyle: 发布3.0版本 2026-01-03 21:24:44 +08:00
Gsh
f164b7dccc feat: 图片广场优化 2026-01-03 20:53:17 +08:00
Gsh
6cc14c1e32 feat: 图片广场优化 2026-01-03 19:21:26 +08:00
ccnetcore
e992cfc928 fix: 修复类型映射问题 2026-01-03 18:19:52 +08:00
Gsh
42edd4c230 feat: 路由动态权限控制、图片广场优化 2026-01-03 17:04:34 +08:00
ccnetcore
3892ff1937 feat: 完成匿名字段功能 2026-01-03 16:17:57 +08:00
ccnetcore
12878ba022 feat: 完成条件 2026-01-03 16:00:18 +08:00
Gsh
a3259ad36f feat: 前端新增图片生成功能 2026-01-03 15:16:18 +08:00
ccnetcore
5bb7dfb7cd feat: token 下拉列表支持可选是否包含默认项
为 GetSelectListAsync 接口新增 includeDefault 查询参数,允许调用方控制是否返回“默认”选项,默认保持原有行为。
2026-01-03 14:39:17 +08:00
ccnetcore
3447e2dc5d refactor: 清理无用代码并统一网关处理逻辑
- 移除未使用的 using 和多余空行,优化代码可读性
- 统一 yi- 前缀模型名处理逻辑,减少重复代码
- 使用 EnsureSuccessStatusCode 简化图片上传错误处理流程
- 不影响现有功能,仅做代码结构和规范优化
2026-01-03 14:07:04 +08:00
ccnetcore
88fae0cdc2 fix: 优化图片生成与上传错误处理及任务信息返回
- 图片上传接口新增状态码校验,返回明确错误信息
- 图片生成任务失败时记录完整错误信息与堆栈
- 图片任务查询结果补充发布状态、分类及错误信息
- 网关层模型名规范化与少量代码格式优化
2026-01-03 14:03:24 +08:00
ccnetcore
5a7f0ab108 feat: 支持更多类型的图片模型 2026-01-03 03:19:31 +08:00
ccnetcore
be5f57f654 feat: 完成渠道商限制 2026-01-03 02:58:21 +08:00
ccnetcore
a6e7a5e906 feat: 完成渠道商拦截 2026-01-03 02:20:52 +08:00
ccnetcore
c4ab176089 style: 优化整体title显示 2026-01-03 02:15:28 +08:00
ccnetcore
a50f877964 style: 优化控制台样式 2026-01-03 01:54:22 +08:00
ccnetcore
28cdc29369 fix: 修复图片模型会员标识判断逻辑
将 IsPremiumPackage 的判断从使用 PremiumPackageConst.ModeIds 改为直接读取模型的 IsPremium 属性,避免因配置不一致导致会员标识错误。
2026-01-03 01:46:40 +08:00
ccnetcore
e39cbaf5e7 fix: 修复模型为空问题 2026-01-03 01:45:27 +08:00
ccnetcore
9d1dd72584 style: 优化滚动条样式 2026-01-03 01:29:47 +08:00
ccnetcore
ea403fcae0 feat: 新增错误信息返回 2026-01-03 01:12:47 +08:00
ccnetcore
91533909c2 style: 优化滚动条样式 2026-01-03 01:10:04 +08:00
ccnetcore
61d5d40dbb chore: 暂时禁用多个定时任务执行逻辑
在相关 Job 的 DoWorkAsync 方法中提前 return,防止自动执行挖矿、行情生成、新闻生成及资产更新等后台任务运行。
2026-01-03 00:03:23 +08:00
ccnetcore
38dbd0aca7 Merge remote-tracking branch 'origin/ai-agent' into ai-agent 2026-01-03 00:00:25 +08:00
ccnetcore
343347ea11 feat: 新增图片广场、发布及模型查询接口
- 图片任务列表区分为“我的任务”和“图片广场(已发布)”
- 新增图片发布到广场接口,支持分类
- 新增图片模型列表查询接口
- 注释掉图片 Base64 前缀字段,统一使用 URL
- 调整相关依赖注入,支持模型仓储查询
2026-01-03 00:00:17 +08:00
Gsh
a9e11d161c fix: 前端页面架构重构优化 2026-01-02 23:08:40 +08:00
Gsh
d25ca6dc4a fix: 前端页面架构重构优化 2026-01-02 22:47:09 +08:00
ccnetcore
ba95d1798f feat: 优化AI图片存储与访问流程
- 统一图片存储服务地址常量,返回完整可访问URL
- 图片上传接口支持匿名访问,并按日期创建存储目录
- ImageStoreTask 移除无用生成图片 Base64 字段,调整大字段存储配置
- 创建图片任务时补充 ModelId 信息
- 优先使用 Authorization 头部,避免覆盖已有认证信息
- 前端补充 Element Plus Descriptions 组件类型声明
2026-01-02 21:32:48 +08:00
ccnetcore
436b5b910c Merge branch 'ai-agent-backend' into ai-agent
# Conflicts:
#	Yi.Ai.Vue3/src/pages/console/index.vue
#	Yi.Ai.Vue3/src/routers/modules/staticRouter.ts
2026-01-02 19:45:55 +08:00
ccnetcore
560a76558a feat: 完成图片生成功能 2026-01-02 19:26:09 +08:00
ccnetcore
3f53eb14ab chore: 添加 OpenAI NuGet 依赖
在 Stock.Domain 项目中引入 OpenAI 2.8.0 包,为后续 AI 能力集成做准备
2026-01-01 22:39:55 +08:00
ccnetcore
e46044e217 chore: 添加 OpenAI NuGet 依赖到 ChatHub Domain 模块 2026-01-01 22:36:24 +08:00
Gsh
9c842ab802 fix: 前端页面架构重构优化 2026-01-01 18:53:27 +08:00
Gsh
b8c0f9a212 fix: 前端页面架构重构优化 2026-01-01 18:53:27 +08:00
ccnetcore
6cc0059691 Revert "feat: 支持尊享包渠道"
This reverts commit 70ae2fab44.
2026-01-01 18:53:26 +08:00
ccnetcore
33d28a8cb0 feat: 支持尊享包渠道 2026-01-01 18:53:26 +08:00
Gsh
e4621d9049 fix: 前端页面架构重构初版 2026-01-01 18:53:25 +08:00
ccnetcore
c649ad31c2 fix: 修正2026元旦限购礼包的Token数量配置 2026-01-01 12:15:28 +08:00
ccnetcore
50fc8c5f0a feat: 新增活动激活码礼包与2026元旦限购商品
- 新增 888w、666w、100w 尊享Token 活动激活码礼包
- 新增 2026 元旦限购 1亿 / 2亿 Tokens 商品
- 调整原 1亿 Tokens 商品展示文案
2026-01-01 12:13:14 +08:00
ccnetcore
19ea76bd60 feat: 新增 gpt-5.2-codex 高级套餐模型支持 2025-12-31 00:20:36 +08:00
69 changed files with 3792 additions and 915 deletions

View File

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

View File

@@ -5,6 +5,11 @@ namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// </summary> /// </summary>
public class ImageGenerationInput public class ImageGenerationInput
{ {
/// <summary>
/// 密钥id
/// </summary>
public Guid? TokenId { get; set; }
/// <summary> /// <summary>
/// 提示词 /// 提示词
/// </summary> /// </summary>
@@ -16,7 +21,7 @@ public class ImageGenerationInput
public string ModelId { get; set; } = string.Empty; public string ModelId { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 参考图Base64列表可选包含前缀如 data:image/png;base64,... /// 参考图PrefixBase64列表可选包含前缀如 data:image/png;base64,...
/// </summary> /// </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.AiHub.Domain.Shared.Enums;
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary> /// <summary>
/// 图片任务分页查询输入 /// 图片任务分页查询输入
/// </summary> /// </summary>
public class ImageTaskPageInput public class ImageMyTaskPageInput: PagedAllResultRequestDto
{ {
/// <summary> /// <summary>
/// 页码从1开始 /// 提示词
/// </summary> /// </summary>
public int PageIndex { get; set; } = 1; public string? Prompt { get; set; }
/// <summary>
/// 每页数量
/// </summary>
public int PageSize { get; set; } = 10;
/// <summary> /// <summary>
/// 任务状态筛选(可选) /// 任务状态筛选(可选)
/// </summary> /// </summary>
public TaskStatusEnum? TaskStatus { get; set; } 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; public string Prompt { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 参考图Base64列表 /// 是否匿名
/// </summary> /// </summary>
public List<string>? ReferenceImagesBase64 { get; set; } public bool IsAnonymous { get; set; }
/// <summary>
/// 参考图URL列表
/// </summary>
public List<string>? ReferenceImagesUrl { get; set; }
/// <summary>
/// 生成图片Base64包含前缀
/// </summary>
public string? StoreBase64 { get; set; }
/// <summary> /// <summary>
/// 生成图片URL /// 生成图片URL
@@ -42,8 +32,35 @@ public class ImageTaskOutput
/// </summary> /// </summary>
public TaskStatusEnum TaskStatus { get; set; } 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>
/// 创建时间 /// 创建时间
/// </summary> /// </summary>
public DateTime CreationTime { get; set; } 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 /// 模型ID
/// </summary> /// </summary>
public Guid Id { get; set; } public Guid Id { get; set; }
/// <summary>
/// 模型分类
/// </summary>
public string Category { get; set; }
/// <summary> /// <summary>
/// 模型id /// 模型id
/// </summary> /// </summary>
@@ -28,36 +22,6 @@ public class ModelGetListOutput
/// </summary> /// </summary>
public string? ModelDescribe { get; set; } 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>
/// 备注信息 /// 备注信息
/// </summary> /// </summary>

View File

@@ -30,32 +30,94 @@ public class ImageGenerationJob : AsyncBackgroundJob<ImageGenerationJobArgs>, IT
public override async Task ExecuteAsync(ImageGenerationJobArgs args) public override async Task ExecuteAsync(ImageGenerationJobArgs args)
{ {
_logger.LogInformation("开始执行图片生成任务TaskId: {TaskId}, ModelId: {ModelId}, UserId: {UserId}", var task = await _imageStoreTaskRepository.GetFirstAsync(x => x.Id == args.TaskId);
args.TaskId, args.ModelId, args.UserId); if (task is null)
{
throw new UserFriendlyException($"{args.TaskId} 图片生成任务不存在");
}
_logger.LogInformation("开始执行图片生成任务TaskId: {TaskId}, ModelId: {ModelId}, UserId: {UserId}",
task.Id, task.ModelId, task.UserId);
try 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( await _aiGateWayManager.GeminiGenerateContentImageForStatisticsAsync(
args.TaskId, task.Id,
args.ModelId, task.ModelId,
request, request,
args.UserId); task.UserId,
tokenId: task.TokenId);
_logger.LogInformation("图片生成任务完成TaskId: {TaskId}", args.TaskId); _logger.LogInformation("图片生成任务完成TaskId: {TaskId}", args.TaskId);
} }
catch (Exception ex) 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);
// 更新任务状态为失败 task.TaskStatus = TaskStatusEnum.Fail;
var task = await _imageStoreTaskRepository.GetFirstAsync(x => x.Id == args.TaskId); task.ErrorInfo = error;
if (task != null)
{ await _imageStoreTaskRepository.UpdateAsync(task);
task.TaskStatus = TaskStatusEnum.Fail;
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 /// 图片任务ID
/// </summary> /// </summary>
public Guid TaskId { get; set; } 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

@@ -14,7 +14,7 @@ namespace Yi.Framework.AiHub.Application.Services;
/// <summary> /// <summary>
/// 渠道商管理服务实现 /// 渠道商管理服务实现
/// </summary> /// </summary>
[Authorize] [Authorize(Roles = "admin")]
public class ChannelService : ApplicationService, IChannelService public class ChannelService : ApplicationService, IChannelService
{ {
private readonly ISqlSugarRepository<AiAppAggregateRoot, Guid> _appRepository; private readonly ISqlSugarRepository<AiAppAggregateRoot, Guid> _appRepository;

View File

@@ -88,7 +88,7 @@ public class AiChatService : ApplicationService
} }
/// <summary> /// <summary>
/// 获取模型列表 /// 获取对话模型列表
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public async Task<List<ModelGetListOutput>> GetModelAsync() public async Task<List<ModelGetListOutput>> GetModelAsync()
@@ -100,16 +100,9 @@ public class AiChatService : ApplicationService
.Select(x => new ModelGetListOutput .Select(x => new ModelGetListOutput
{ {
Id = x.Id, Id = x.Id,
Category = "chat",
ModelId = x.ModelId, ModelId = x.ModelId,
ModelName = x.Name, ModelName = x.Name,
ModelDescribe = x.Description, ModelDescribe = x.Description,
ModelPrice = 0,
ModelType = "1",
ModelShow = "0",
SystemPrompt = null,
ApiHost = null,
ApiKey = null,
Remark = x.Description, Remark = x.Description,
IsPremiumPackage = x.IsPremium IsPremiumPackage = x.IsPremium
}).ToListAsync(); }).ToListAsync();
@@ -209,7 +202,7 @@ public class AiChatService : ApplicationService
[HttpPost("ai-chat/agent/send")] [HttpPost("ai-chat/agent/send")]
public async Task PostAgentSendAsync([FromBody] AgentSendInput input, CancellationToken cancellationToken) 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); await _aiBlacklistManager.VerifiyAiBlacklist(tokenValidation.UserId);
// 验证用户是否为VIP // 验证用户是否为VIP
@@ -241,7 +234,7 @@ public class AiChatService : ApplicationService
await _chatManager.AgentCompleteChatStreamAsync(_httpContextAccessor.HttpContext, await _chatManager.AgentCompleteChatStreamAsync(_httpContextAccessor.HttpContext,
input.SessionId, input.SessionId,
input.Content, input.Content,
input.Token, tokenValidation.Token,
tokenValidation.TokenId, tokenValidation.TokenId,
input.ModelId, input.ModelId,
tokenValidation.UserId, tokenValidation.UserId,

View File

@@ -3,14 +3,17 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SqlSugar;
using Volo.Abp; using Volo.Abp;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Volo.Abp.BackgroundJobs; using Volo.Abp.BackgroundJobs;
using Volo.Abp.Guids; using Volo.Abp.Guids;
using Volo.Abp.Users; using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; using Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
using Yi.Framework.AiHub.Application.Jobs; using Yi.Framework.AiHub.Application.Jobs;
using Yi.Framework.AiHub.Domain.Entities.Chat; 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.Extensions;
using Yi.Framework.AiHub.Domain.Managers; using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts; using Yi.Framework.AiHub.Domain.Shared.Consts;
@@ -32,6 +35,8 @@ public class AiImageService : ApplicationService
private readonly ModelManager _modelManager; private readonly ModelManager _modelManager;
private readonly IGuidGenerator _guidGenerator; private readonly IGuidGenerator _guidGenerator;
private readonly IWebHostEnvironment _webHostEnvironment; private readonly IWebHostEnvironment _webHostEnvironment;
private readonly TokenManager _tokenManager;
private readonly ISqlSugarRepository<AiModelEntity> _aiModelRepository;
public AiImageService( public AiImageService(
ISqlSugarRepository<ImageStoreTaskAggregateRoot> imageTaskRepository, ISqlSugarRepository<ImageStoreTaskAggregateRoot> imageTaskRepository,
@@ -40,7 +45,8 @@ public class AiImageService : ApplicationService
PremiumPackageManager premiumPackageManager, PremiumPackageManager premiumPackageManager,
ModelManager modelManager, ModelManager modelManager,
IGuidGenerator guidGenerator, IGuidGenerator guidGenerator,
IWebHostEnvironment webHostEnvironment) IWebHostEnvironment webHostEnvironment, TokenManager tokenManager,
ISqlSugarRepository<AiModelEntity> aiModelRepository)
{ {
_imageTaskRepository = imageTaskRepository; _imageTaskRepository = imageTaskRepository;
_backgroundJobManager = backgroundJobManager; _backgroundJobManager = backgroundJobManager;
@@ -49,6 +55,8 @@ public class AiImageService : ApplicationService
_modelManager = modelManager; _modelManager = modelManager;
_guidGenerator = guidGenerator; _guidGenerator = guidGenerator;
_webHostEnvironment = webHostEnvironment; _webHostEnvironment = webHostEnvironment;
_tokenManager = tokenManager;
_aiModelRepository = aiModelRepository;
} }
/// <summary> /// <summary>
@@ -65,6 +73,13 @@ public class AiImageService : ApplicationService
// 黑名单校验 // 黑名单校验
await _aiBlacklistManager.VerifiyAiBlacklist(userId); await _aiBlacklistManager.VerifiyAiBlacklist(userId);
//校验token
if (input.TokenId is not null)
{
await _tokenManager.ValidateTokenAsync(input.TokenId, input.ModelId);
}
// VIP校验 // VIP校验
if (!CurrentUser.IsAiVip()) if (!CurrentUser.IsAiVip())
{ {
@@ -86,32 +101,23 @@ public class AiImageService : ApplicationService
var task = new ImageStoreTaskAggregateRoot var task = new ImageStoreTaskAggregateRoot
{ {
Prompt = input.Prompt, Prompt = input.Prompt,
ReferenceImagesBase64 = input.ReferenceImagesBase64 ?? new List<string>(), ReferenceImagesPrefixBase64 = input.ReferenceImagesPrefixBase64 ?? new List<string>(),
ReferenceImagesUrl = new List<string>(), ReferenceImagesUrl = new List<string>(),
TaskStatus = TaskStatusEnum.Processing, TaskStatus = TaskStatusEnum.Processing,
UserId = userId UserId = userId,
UserName = CurrentUser.UserName,
TokenId = input.TokenId,
ModelId = input.ModelId
}; };
await _imageTaskRepository.InsertAsync(task); 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 await _backgroundJobManager.EnqueueAsync(new ImageGenerationJobArgs
{ {
TaskId = taskId, TaskId = task.Id,
ModelId = input.ModelId,
RequestJson = requestJson,
UserId = userId
}); });
return taskId; return task.Id;
} }
/// <summary> /// <summary>
@@ -134,12 +140,15 @@ public class AiImageService : ApplicationService
{ {
Id = task.Id, Id = task.Id,
Prompt = task.Prompt, Prompt = task.Prompt,
ReferenceImagesBase64 = task.ReferenceImagesBase64, // ReferenceImagesBase64 = task.ReferenceImagesBase64,
ReferenceImagesUrl = task.ReferenceImagesUrl, // ReferenceImagesUrl = task.ReferenceImagesUrl,
StoreBase64 = task.StoreBase64, // StoreBase64 = task.StoreBase64,
StoreUrl = task.StoreUrl, StoreUrl = task.StoreUrl,
TaskStatus = task.TaskStatus, TaskStatus = task.TaskStatus,
CreationTime = task.CreationTime PublishStatus = task.PublishStatus,
Categories = task.Categories,
CreationTime = task.CreationTime,
ErrorInfo = task.ErrorInfo,
}; };
} }
@@ -149,6 +158,7 @@ public class AiImageService : ApplicationService
/// <param name="base64Data">Base64图片数据包含前缀如 data:image/png;base64,</param> /// <param name="base64Data">Base64图片数据包含前缀如 data:image/png;base64,</param>
/// <returns>图片访问URL</returns> /// <returns>图片访问URL</returns>
[HttpPost("ai-image/upload-base64")] [HttpPost("ai-image/upload-base64")]
[AllowAnonymous]
public async Task<string> UploadBase64ToUrlAsync([FromBody] string base64Data) public async Task<string> UploadBase64ToUrlAsync([FromBody] string base64Data)
{ {
if (string.IsNullOrWhiteSpace(base64Data)) if (string.IsNullOrWhiteSpace(base64Data))
@@ -171,6 +181,7 @@ public class AiImageService : ApplicationService
{ {
mimeType = header.Split(':')[1].Split(';')[0]; mimeType = header.Split(':')[1].Split(';')[0];
} }
base64Content = parts[1]; base64Content = parts[1];
} }
} }
@@ -197,57 +208,166 @@ public class AiImageService : ApplicationService
throw new UserFriendlyException("Base64格式无效"); 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)) if (!Directory.Exists(uploadPath))
{ {
Directory.CreateDirectory(uploadPath); Directory.CreateDirectory(uploadPath);
} }
// 生成文件名并保存 // 保存文件
var fileId = _guidGenerator.Create(); var fileId = _guidGenerator.Create();
var fileName = $"{fileId}{extension}"; var fileName = $"{fileId}{extension}";
var filePath = Path.Combine(uploadPath, fileName); var filePath = Path.Combine(uploadPath, fileName);
await File.WriteAllBytesAsync(filePath, imageBytes); await File.WriteAllBytesAsync(filePath, imageBytes);
// 返回访问URL // 返回包含日期目录的访问URL
return $"/ai-images/{fileName}"; return $"/wwwroot/ai-images/{dateFolder}/{fileName}";
} }
/// <summary> /// <summary>
/// 分页查询任务列表 /// 分页查询我的任务列表
/// </summary> /// </summary>
/// <param name="input">分页查询参数</param> [HttpGet("ai-image/my-tasks")]
/// <returns>任务列表</returns> public async Task<PagedResult<ImageTaskOutput>> GetMyTaskPageAsync([FromQuery] ImageMyTaskPageInput input)
[HttpGet("ai-image/tasks")]
public async Task<PagedResult<ImageTaskOutput>> GetTaskPageAsync([FromQuery] ImageTaskPageInput input)
{ {
var userId = CurrentUser.GetId(); var userId = CurrentUser.GetId();
var query = _imageTaskRepository._DbQueryable RefAsync<int> total = 0;
var output = await _imageTaskRepository._DbQueryable
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
.WhereIF(input.TaskStatus.HasValue, x => x.TaskStatus == input.TaskStatus!.Value) .WhereIF(input.TaskStatus is not null, x => x.TaskStatus == input.TaskStatus)
.OrderByDescending(x => x.CreationTime); .WhereIF(!string.IsNullOrWhiteSpace(input.Prompt), x => x.Prompt.Contains(input.Prompt))
.WhereIF(input.PublishStatus is not null, x => x.PublishStatus == input.PublishStatus)
var total = await query.CountAsync(); .WhereIF(input.StartTime is not null && input.EndTime is not null,
var items = await query x => x.CreationTime >= input.StartTime && x.CreationTime <= input.EndTime)
.Skip((input.PageIndex - 1) * input.PageSize) .OrderByDescending(x => x.CreationTime)
.Take(input.PageSize)
.Select(x => new ImageTaskOutput .Select(x => new ImageTaskOutput
{ {
Id = x.Id, Id = x.Id,
Prompt = x.Prompt, Prompt = x.Prompt,
ReferenceImagesBase64 = x.ReferenceImagesBase64,
ReferenceImagesUrl = x.ReferenceImagesUrl,
StoreBase64 = x.StoreBase64,
StoreUrl = x.StoreUrl, StoreUrl = x.StoreUrl,
TaskStatus = x.TaskStatus, 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;
} }
} }
@@ -272,4 +392,4 @@ public class PagedResult<T>
Total = total; Total = total;
Items = items; Items = items;
} }
} }

View File

@@ -26,6 +26,7 @@ public class TokenService : ApplicationService
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository; private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository; private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
private readonly ModelManager _modelManager; private readonly ModelManager _modelManager;
public TokenService( public TokenService(
ISqlSugarRepository<TokenAggregateRoot> tokenRepository, ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository, ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository,
@@ -90,7 +91,7 @@ public class TokenService : ApplicationService
} }
[HttpGet("token/select-list")] [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 userId = CurrentUser.GetId();
var tokens = await _tokenRepository._DbQueryable var tokens = await _tokenRepository._DbQueryable
@@ -103,13 +104,17 @@ public class TokenService : ApplicationService
Name = x.Name, Name = x.Name,
IsDisabled = x.IsDisabled IsDisabled = x.IsDisabled
}).ToListAsync(); }).ToListAsync();
tokens.Insert(0,new TokenSelectListOutputDto if (includeDefault == true)
{ {
TokenId = Guid.Empty, tokens.Insert(0, new TokenSelectListOutputDto
Name = "默认", {
IsDisabled = false TokenId = Guid.Empty,
}); Name = "默认",
IsDisabled = false
});
}
return tokens; return tokens;
} }

View File

@@ -15,6 +15,7 @@ public class PremiumPackageConst
"gemini-3-pro-high", "gemini-3-pro-high",
"gemini-3-pro-image-preview", "gemini-3-pro-image-preview",
"gpt-5.2-codex-xhigh", "gpt-5.2-codex-xhigh",
"gpt-5.2-codex",
"glm-4.7", "glm-4.7",
"yi-claude-sonnet-4-5-20250929", "yi-claude-sonnet-4-5-20250929",

View File

@@ -20,7 +20,6 @@ public static class GeminiGenerateContentAcquirer
+ usage.Value.GetPath("thoughtsTokenCount").GetInt() + usage.Value.GetPath("thoughtsTokenCount").GetInt()
+ usage.Value.GetPath("toolUsePromptTokenCount").GetInt(); + usage.Value.GetPath("toolUsePromptTokenCount").GetInt();
return new ThorUsageResponse return new ThorUsageResponse
{ {
PromptTokens = inputTokens, PromptTokens = inputTokens,
@@ -32,14 +31,47 @@ public static class GeminiGenerateContentAcquirer
} }
/// <summary> /// <summary>
/// 获取图片url包含前缀 /// 获取图片 base64包含 data:image 前缀
/// 优先从 inlineData.data 中获取,其次从 markdown text 中解析
/// </summary> /// </summary>
/// <param name="response"></param> public static string GetImagePrefixBase64(JsonElement response)
/// <returns></returns>
public static string GetImageBase64(JsonElement response)
{ {
//todo // Step 1: 优先尝试从 candidates[0].content.parts[0].inlineData.data 获取
//获取他的base64字符串 var inlineBase64 = response
return string.Empty; .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

@@ -101,7 +101,31 @@ public enum ActivationCodeGoodsTypeEnum
/// </summary> /// </summary>
[ActivationCodeGoods(price: 0, tokenAmount: 100000, vipMonths: 0, isCombo: false, isReusable: true, [ActivationCodeGoods(price: 0, tokenAmount: 100000, vipMonths: 0, isCombo: false, isReusable: true,
isSameTypeOnce: false, displayName: "10w 尊享Token", content: "免费包")] isSameTypeOnce: false, displayName: "10w 尊享Token", content: "免费包")]
Premium10WFree = 100 Premium10WFree = 100,
/// <summary>
/// 0【888w 尊享Token】活动包
/// </summary>
[ActivationCodeGoods(price: 0, tokenAmount: 8880000, vipMonths: 0, isCombo: false, isReusable: false,
isSameTypeOnce: false, displayName: "888w 尊享Token", content: "888w活动赠送福利包")]
Premium888WFree = 200,
/// <summary>
/// 0【666w 尊享Token】活动包
/// </summary>
[ActivationCodeGoods(price: 0, tokenAmount: 6660000, vipMonths: 0, isCombo: false, isReusable: false,
isSameTypeOnce: false, displayName: "666w 尊享Token", content: "666w活动赠送福利包")]
Premium666WFree = 201,
/// <summary>
/// 0【100w 尊享Token】活动包
/// </summary>
[ActivationCodeGoods(price: 0, tokenAmount: 1000000, vipMonths: 0, isCombo: false, isReusable: false,
isSameTypeOnce: false, displayName: "100w 尊享Token", content: "100w活动赠送福利包")]
Premium100WFree = 202,
} }
public static class ActivationCodeGoodsTypeEnumExtensions public static class ActivationCodeGoodsTypeEnumExtensions

View File

@@ -110,10 +110,23 @@ public enum GoodsTypeEnum
PremiumPackage5000W = 101, PremiumPackage5000W = 101,
[Price(248.9, 0, 3500)] [Price(248.9, 0, 3500)]
[DisplayName("YiXinPremiumPackage 10000W Tokens", "1亿Tokens(推荐)", "极致性价比")] [DisplayName("YiXinPremiumPackage 10000W Tokens", "1亿Tokens", "极致性价比")]
[GoodsCategory(GoodsCategoryType.PremiumPackage)] [GoodsCategory(GoodsCategoryType.PremiumPackage)]
[TokenAmount(100000000)] [TokenAmount(100000000)]
PremiumPackage10000W = 102, PremiumPackage10000W = 102,
[Price(238.9, 0, 3500)]
[DisplayName("YiXinPremiumPackage 10000W Tokens", "1亿Tokens2026元旦限购", "活动9.5折特价")]
[GoodsCategory(GoodsCategoryType.PremiumPackage)]
[TokenAmount(100000000)]
PremiumPackage10000W_2026 = 103,
[Price(398.9, 0, 7000)]
[DisplayName("YiXinPremiumPackage 20000W Tokens", "2亿Tokens2026元旦限购", "史上最低8.8折")]
[GoodsCategory(GoodsCategoryType.PremiumPackage)]
[TokenAmount(200000000)]
PremiumPackage20000W_2026 = 104,
} }
public static class GoodsTypeEnumExtensions public static class GoodsTypeEnumExtensions

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

@@ -14,23 +14,18 @@ public class ImageStoreTaskAggregateRoot : FullAuditedAggregateRoot<Guid>
public string Prompt { get; set; } public string Prompt { get; set; }
/// <summary> /// <summary>
/// 参考图Base64 /// 参考图PrefixBase64带前缀如 data:image/png;base64,xxx
/// </summary> /// </summary>
[SugarColumn(IsJson = true)] [SugarColumn(IsJson = true, ColumnDataType = StaticConfig.CodeFirst_BigString)]
public List<string> ReferenceImagesBase64 { get; set; } public List<string> ReferenceImagesPrefixBase64 { get; set; }
/// <summary> /// <summary>
/// 参考图url /// 参考图url
/// </summary> /// </summary>
[SugarColumn(IsJson = true)] [SugarColumn(IsJson = true)]
public List<string> ReferenceImagesUrl { get; set; } public List<string> ReferenceImagesUrl { get; set; }
/// <summary>
/// 图片base64
/// </summary>
public string? StoreBase64 { get; set; }
/// <summary> /// <summary>
/// 图片绝对路径 /// 图片绝对路径
/// </summary> /// </summary>
@@ -46,6 +41,43 @@ public class ImageStoreTaskAggregateRoot : FullAuditedAggregateRoot<Guid>
/// </summary> /// </summary>
public Guid UserId { get; set; } 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>
/// 设置成功 /// 设置成功
/// </summary> /// </summary>
@@ -55,4 +87,18 @@ public class ImageStoreTaskAggregateRoot : FullAuditedAggregateRoot<Guid>
TaskStatus = TaskStatusEnum.Success; TaskStatus = TaskStatusEnum.Success;
StoreUrl = storeUrl; 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

@@ -92,7 +92,7 @@ public class AiGateWayManager : DomainService
{ {
throw new UserFriendlyException($"【{modelId}】模型当前版本【{modelApiType}】格式不支持"); throw new UserFriendlyException($"【{modelId}】模型当前版本【{modelApiType}】格式不支持");
} }
// ✅ 统一处理 -nx 后缀(网关层模型规范化) // ✅ 统一处理 yi- 后缀(网关层模型规范化)
if (!string.IsNullOrEmpty(aiModelDescribe.ModelId) && if (!string.IsNullOrEmpty(aiModelDescribe.ModelId) &&
aiModelDescribe.ModelId.StartsWith("yi-", StringComparison.OrdinalIgnoreCase)) aiModelDescribe.ModelId.StartsWith("yi-", StringComparison.OrdinalIgnoreCase))
{ {
@@ -976,7 +976,7 @@ public class AiGateWayManager : DomainService
} }
} }
private const string ImageStoreHost = "http://localhost:19001/api/app";
/// <summary> /// <summary>
/// Gemini 生成(Image)-非流式-缓存处理 /// Gemini 生成(Image)-非流式-缓存处理
/// 返回图片绝对路径 /// 返回图片绝对路径
@@ -1005,16 +1005,16 @@ public class AiGateWayManager : DomainService
var data = await chatService.GenerateContentAsync(modelDescribe, request, cancellationToken); var data = await chatService.GenerateContentAsync(modelDescribe, request, cancellationToken);
//解析json获取base64字符串 //解析json获取base64字符串
var imageBase64 = GeminiGenerateContentAcquirer.GetImageBase64(data); var imagePrefixBase64 = GeminiGenerateContentAcquirer.GetImagePrefixBase64(data);
//远程调用上传接口将base64转换为URL //远程调用上传接口将base64转换为URL
var httpClient = LazyServiceProvider.LazyGetRequiredService<IHttpClientFactory>().CreateClient(); var httpClient = LazyServiceProvider.LazyGetRequiredService<IHttpClientFactory>().CreateClient();
var uploadUrl = $"https://ccnetcore.com/prod-api/ai-hub/ai-image/upload-base64"; // 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 = $"{ImageStoreHost}/ai-image/upload-base64";
var content = new StringContent(JsonSerializer.Serialize(imagePrefixBase64), Encoding.UTF8, "application/json");
var uploadResponse = await httpClient.PostAsync(uploadUrl, content, cancellationToken); var uploadResponse = await httpClient.PostAsync(uploadUrl, content, cancellationToken);
uploadResponse.EnsureSuccessStatusCode(); uploadResponse.EnsureSuccessStatusCode();
var storeUrl = await uploadResponse.Content.ReadAsStringAsync(cancellationToken); var storeUrl = await uploadResponse.Content.ReadAsStringAsync(cancellationToken);
storeUrl = storeUrl.Trim('"'); // 移除JSON字符串的引号
var tokenUsage = new ThorUsageResponse var tokenUsage = new ThorUsageResponse
{ {
@@ -1041,8 +1041,7 @@ public class AiGateWayManager : DomainService
} }
//设置存储base64和url //设置存储base64和url
imageStoreTask.StoreBase64 = imageBase64; imageStoreTask.SetSuccess($"{ImageStoreHost}{storeUrl}");
imageStoreTask.SetSuccess(storeUrl);
await _imageStoreTaskRepository.UpdateAsync(imageStoreTask); await _imageStoreTaskRepository.UpdateAsync(imageStoreTask);
} }

View File

@@ -22,6 +22,11 @@ public class TokenValidationResult
/// Token Id /// Token Id
/// </summary> /// </summary>
public Guid TokenId { get; set; } public Guid TokenId { get; set; }
/// <summary>
/// token
/// </summary>
public string Token { get; set; }
} }
public class TokenManager : DomainService public class TokenManager : DomainService
@@ -43,25 +48,36 @@ public class TokenManager : DomainService
/// <summary> /// <summary>
/// 验证Token并返回用户Id和TokenId /// 验证Token并返回用户Id和TokenId
/// </summary> /// </summary>
/// <param name="token">Token密钥</param> /// <param name="tokenOrId">Token密钥或者TokenId</param>
/// <param name="modelId">模型Id用于判断是否是尊享模型需要检查额度</param> /// <param name="modelId">模型Id用于判断是否是尊享模型需要检查额度</param>
/// <returns>Token验证结果</returns> /// <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"); 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();
} }
else
var entity = await _tokenRepository._DbQueryable {
.Where(x => x.Token == token) var tokenStr = tokenOrId.ToString();
.FirstAsync(); if (!tokenStr.StartsWith("yi-"))
{
throw new UserFriendlyException("当前请求token非法", "401");
}
entity = await _tokenRepository._DbQueryable
.Where(x => x.Token == tokenStr)
.FirstAsync();
}
if (entity is null) if (entity is null)
{ {
throw new UserFriendlyException("当前请求token无效", "401"); throw new UserFriendlyException("当前请求token无效", "401");
@@ -100,7 +116,8 @@ public class TokenManager : DomainService
return new TokenValidationResult return new TokenValidationResult
{ {
UserId = entity.UserId, UserId = entity.UserId,
TokenId = entity.Id TokenId = entity.Id,
Token = entity.Token
}; };
} }

View File

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

View File

@@ -7,6 +7,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenAI" Version="2.8.0" />
<PackageReference Include="Volo.Abp.AspNetCore.SignalR" Version="$(AbpVersion)" /> <PackageReference Include="Volo.Abp.AspNetCore.SignalR" Version="$(AbpVersion)" />
<PackageReference Include="Volo.Abp.Ddd.Domain" Version="$(AbpVersion)" /> <PackageReference Include="Volo.Abp.Ddd.Domain" Version="$(AbpVersion)" />
<PackageReference Include="Volo.Abp.Caching" 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()) public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{ {
return;
// 每次触发只有2/24的概率执行生成新闻 // 每次触发只有2/24的概率执行生成新闻
var random = new Random(); var random = new Random();
var probability = random.Next(0, 24); 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()) public override async Task DoWorkAsync(CancellationToken cancellationToken = new CancellationToken())
{ {
return;
await _stockMarketManager.GenerateStocksAsync(); await _stockMarketManager.GenerateStocksAsync();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -113,7 +113,7 @@ namespace Yi.Abp.Web
//本地开发环境,可以禁用作业执行 //本地开发环境,可以禁用作业执行
if (host.IsDevelopment()) 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 options.TokenValidationParameters = new TokenValidationParameters
{ {
RoleClaimType = "Roles",
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer, ValidIssuer = jwtOptions.Issuer,
@@ -298,7 +299,8 @@ namespace Yi.Abp.Web
} }
else 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; messageContext.Token = cookiesToken;
} }
@@ -358,8 +360,8 @@ namespace Yi.Abp.Web
var app = context.GetApplicationBuilder(); var app = context.GetApplicationBuilder();
app.UseRouting(); app.UseRouting();
//app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AiModelEntity>(); // app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ImageStoreTaskAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ActivationCodeRecordAggregateRoot>(); // app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ActivationCodeRecordAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>(); // app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();
//跨域 //跨域

View File

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

View File

@@ -4,5 +4,11 @@
<template> <template>
<router-view /> <router-view />
</template> </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

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

View File

@@ -138,7 +138,8 @@ export function disableToken(id: string) {
// 新增接口2 // 新增接口2
// 获取可选择的token信息 // 获取可选择的token信息
export function getSelectableTokenInfo() { 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 { CircleCheck, Loading } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { createOrder, getOrderStatus } from '@/api'; import { createOrder, getOrderStatus } from '@/api';
import { getGoodsList, GoodsCategoryType } from '@/api/pay'; import { getGoodsList, GoodsCategoryType } from '@/api/pay';
import SupportModelList from '@/components/userPersonalCenter/components/SupportModelList.vue'; import SupportModelList from '@/components/userPersonalCenter/components/SupportModelList.vue';
@@ -305,8 +304,6 @@ async function checkPaymentStatus(outTradeNo: string) {
} }
} }
const router = useRouter();
function toggleDetails() { function toggleDetails() {
showDetails.value = !showDetails.value; showDetails.value = !showDetails.value;
} }
@@ -322,7 +319,8 @@ function onClose() {
function goToActivation() { function goToActivation() {
close(); close();
userStore.openUserCenter('activationCode'); // 使用 window.location 进行跳转,避免 router 注入问题
window.location.href = '/console/activation';
} }
</script> </script>

View File

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

View File

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

View File

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

View File

@@ -599,18 +599,12 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.usage-statistics { .usage-statistics {
padding: 20px;
position: relative; 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; transition: all 0.3s ease;
height: 100%;
overflow-y: auto; overflow-y: auto;
} }
.usage-statistics:hover { .usage-statistics:hover {
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.12);
} }
.usage-statistics.fullscreen-mode { .usage-statistics.fullscreen-mode {
@@ -620,7 +614,6 @@ onBeforeUnmount(() => {
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 2000; z-index: 2000;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 30px; padding: 30px;
overflow-y: auto; overflow-y: auto;
border-radius: 0; border-radius: 0;
@@ -632,7 +625,6 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
margin-bottom: 30px; margin-bottom: 30px;
padding-bottom: 20px; padding-bottom: 20px;
border-bottom: 2px solid #e9ecef;
} }
.header-actions { .header-actions {
@@ -656,7 +648,6 @@ onBeforeUnmount(() => {
.option-label { .option-label {
text-decoration: line-through; text-decoration: line-through;
color: #909399;
} }
} }
} }
@@ -671,7 +662,6 @@ onBeforeUnmount(() => {
} }
&.disabled-icon { &.disabled-icon {
color: #c0c4cc;
} }
} }
@@ -710,14 +700,10 @@ onBeforeUnmount(() => {
.chart-card { .chart-card {
margin-bottom: 30px; margin-bottom: 30px;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
overflow: hidden; overflow: hidden;
background: white;
} }
.chart-card:hover { .chart-card:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
transform: translateY(-4px); transform: translateY(-4px);
} }

View File

@@ -303,7 +303,6 @@ function bindWechat() {
<style scoped> <style scoped>
.user-profile { .user-profile {
padding: 20px;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
} }

View File

@@ -12,4 +12,4 @@ export const COLLAPSE_THRESHOLD: number = 600;
export const SIDE_BAR_WIDTH: number = 280; export const SIDE_BAR_WIDTH: number = 280;
// 路由白名单地址[本地存在的路由 staticRouter.ts 中] // 路由白名单地址[本地存在的路由 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

@@ -22,7 +22,7 @@ import Header from '@/layouts/components/Header/index.vue';
width: 100%; width: 100%;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: var(--color-gray-100); background: var(--color-white);
.layout-header { .layout-header {
padding: 0; padding: 0;
border-bottom: var(--header-border) ; border-bottom: var(--header-border) ;

View File

@@ -153,7 +153,6 @@ function toggleSidebar() {
<div <div
v-else v-else
class="header-content-collapsed flex items-center justify-center hover:cursor-pointer" class="header-content-collapsed flex items-center justify-center hover:cursor-pointer"
@click="handleLogoClick"
> >
<el-icon size="20"> <el-icon size="20">
<ChatLineSquare /> <ChatLineSquare />
@@ -188,7 +187,7 @@ function toggleSidebar() {
class="creat-chat-btn" class="creat-chat-btn"
:class="{ :class="{
'creat-chat-btn-collapsed': isCollapsed, 'creat-chat-btn-collapsed': isCollapsed,
'is-disabled': isNewChatState 'is-disabled': isNewChatState,
}" }"
@click="!isNewChatState && handleCreatChat()" @click="!isNewChatState && handleCreatChat()"
> >
@@ -306,7 +305,7 @@ function toggleSidebar() {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
background-color: var(--sidebar-background-color, #f9fafb); //background-color: var(--sidebar-background-color, #f9fafb);
// 展开状态 - 240px // 展开状态 - 240px
&:not(.aside-collapsed) { &:not(.aside-collapsed) {
@@ -401,7 +400,7 @@ function toggleSidebar() {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 4px; //padding: 0 4px;
overflow: hidden; overflow: hidden;
.creat-chat-btn-wrapper { .creat-chat-btn-wrapper {
@@ -760,6 +759,7 @@ function toggleSidebar() {
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15); box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
background: #fff;
&.aside-collapsed { &.aside-collapsed {
transform: translateX(-100%); transform: translateX(-100%);

View File

@@ -14,27 +14,6 @@ function openTutorial() {
> >
<!-- PC端显示文字 --> <!-- PC端显示文字 -->
<span class="pc-text">文档</span> <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>
</div> </div>
</template> </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> </style>

View File

@@ -1,18 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useAnnouncementStore } from '@/stores'; import { useAnnouncementStore } from '@/stores';
const announcementStore = useAnnouncementStore(); 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() { function openAnnouncement() {
announcementStore.openDialog(); announcementStore.openDialog();
} }
@@ -20,93 +10,127 @@ function openAnnouncement() {
<template> <template>
<div class="announcement-btn-container" data-tour="announcement-btn"> <div class="announcement-btn-container" data-tour="announcement-btn">
<el-badge <div
is-dot class="announcement-btn"
class="announcement-badge" title="查看公告"
@click="openAnnouncement"
> >
<!-- :value="unreadCount" --> <!-- PC端显示文字 -->
<!-- :hidden="unreadCount === 0" --> <span class="pc-text">公告</span>
<!-- :max="99" --> </div>
<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> </div>
</template> </template>
<style scoped lang="scss"> <style scoped>
.announcement-btn-container { .announcement-btn-container {
display: flex; display: flex;
align-items: center; align-items: center;
}
.announcement-badge { .announcement-btn {
:deep(.el-badge__content) { display: flex;
background-color: #f56c6c; align-items: center;
border: none; 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;
} }
50% {
.announcement-btn { transform: scale(1.1);
display: flex; opacity: 0.4;
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) { .mobile-icon {
.announcement-btn-container { display: none;
.announcement-btn { position: relative;
.pc-text { width: 20px;
display: none; 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> </style>

View File

@@ -1,23 +1,19 @@
<!-- 头像 --> <!-- 头像 -->
<script setup lang="ts"> <script setup lang="ts">
import { ChatLineRound } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { nextTick, onMounted, ref, watch } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import Popover from '@/components/Popover/index.vue'; import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue'; import SvgIcon from '@/components/SvgIcon/index.vue';
import { useGuideTour } from '@/hooks/useGuideTour'; import { useGuideTour } from '@/hooks/useGuideTour';
import { useAnnouncementStore, useGuideTourStore, useUserStore } from '@/stores'; import { useUserStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session'; import { useSessionStore } from '@/stores/modules/session';
import { getUserProfilePicture, isUserVip } from '@/utils/user'; import { getUserProfilePicture, isUserVip } from '@/utils/user';
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const guideTourStore = useGuideTourStore(); const { startHeaderTour } = useGuideTour();
const announcementStore = useAnnouncementStore();
const { startUserCenterTour } = useGuideTour();
/* 弹出面板 开始 */ /* 弹出面板 开始 */
const popoverStyle = ref({ const popoverStyle = ref({
@@ -29,41 +25,17 @@ const popoverRef = ref();
// 弹出面板内容 // 弹出面板内容
const popoverList = ref([ const popoverList = ref([
{
// { key: '1',
// key: '5', title: '用户信息',
// title: '控制台', icon: 'settings-4-fill',
// 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', // key: '6',
// title: '新手引导', // title: '新手引导',
// icon: 'dashboard-fill', // icon: 'dashboard-fill',
// }, // },
// {
// key: '3',
// divider: true,
// },
{ {
key: '4', key: '4',
title: '退出登录', title: '退出登录',
@@ -71,87 +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() { function handleStartTutorial() {
startHeaderTour(); startHeaderTour();
} }
// 点击 // 点击
function handleClick(item: any) { function handleClick(item: any) {
switch (item.key) { switch (item.key) {
case '1': case '1':
ElMessage.warning('暂未开放'); router.push('/console/user');
break;
case '2':
ElMessage.warning('暂未开放');
break;
case '5':
// 打开控制台
popoverRef.value?.hide?.();
router.push('/console');
break; break;
case '6': case '6':
handleStartTutorial(); handleStartTutorial();
break; 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': case '4':
popoverRef.value?.hide?.(); popoverRef.value?.hide?.();
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', { ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
@@ -175,10 +80,7 @@ function handleClick(item: any) {
}); });
}) })
.catch(() => { .catch(() => {
// ElMessage({ // 取消退出,不执行任何操作
// type: 'info',
// message: '取消',
// });
}); });
break; break;
default: default:
@@ -222,124 +124,15 @@ function openVipGuide() {
}); });
}) })
.catch(() => { .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> </script>
<template> <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"> <div class="text-sm font-semibold text-gray-800">
{{ userStore.userInfo?.user.nick ?? '未登录用户' }} {{ userStore.userInfo?.user.nick ?? '未登录用户' }}
</div> </div>
@@ -356,6 +149,7 @@ defineExpose({
<span <span
v-else 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" 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> </span>
@@ -363,7 +157,7 @@ defineExpose({
</div> </div>
<!-- 头像区域 --> <!-- 头像区域 -->
<div class="avatar-container" data-tour="user-avatar"> <div class="avatar-container">
<Popover <Popover
ref="popoverRef" ref="popoverRef"
placement="bottom-end" placement="bottom-end"
@@ -395,6 +189,7 @@ defineExpose({
<span <span
v-else 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" 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> </span>
@@ -405,7 +200,6 @@ defineExpose({
<div v-for="item in popoverList" :key="item.key" class="popover-content-box-items h-full"> <div v-for="item in popoverList" :key="item.key" class="popover-content-box-items h-full">
<div <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)]" 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)" @click="handleClick(item)"
> >
@@ -414,78 +208,15 @@ defineExpose({
{{ item.title }} {{ item.title }}
</div> </div>
</div> </div>
<div v-if="item.divider" class="divder h-1px bg-gray-200 my-4px" />
</div> </div>
</div> </div>
</Popover> </Popover>
</div> </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> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.popover-content { .popover-content{
width: 520px; width: 520px;
height: 520px; height: 520px;
} }

View File

@@ -1,133 +1,5 @@
<!--
&lt;!&ndash; Header 头部 &ndash;&gt;
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router'; import { computed, ref } from 'vue';
import logo from '@/assets/images/logo.png';
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 ConsoleBtn from './components/ConsoleBtn.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';
const router = useRouter();
const userStore = useUserStore();
// 打开控制台
function handleOpenConsole() {
router.push('/console');
}
</script>
<template>
<div class="header-container">
<div class="header-box">
&lt;!&ndash; 左侧logo和品牌区域 &ndash;&gt;
<div class="left-section">
<div class="brand-container">
<el-image :src="logo" alt="logo" fit="contain" class="logo-img" />
<span class="brand-text">意心AI</span>
</div>
</div>
&lt;!&ndash; 右侧功能按钮区域 &ndash;&gt;
<div class="right-section">
<StartChatBtn />
<AnnouncementBtn />
<ModelLibraryBtn />
<AiTutorialBtn />
<ConsoleBtn @open-console="handleOpenConsole" />
<BuyBtn v-show="userStore.userInfo" />
<ThemeBtn />
<LoginBtn v-show="!userStore.userInfo" />
<Avatar v-show="userStore.userInfo" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.header-container {
display: flex;
flex-shrink: 0;
width: 100%;
height: var(&#45;&#45;header-container-default-height, 60px);
.header-box {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
padding: 0 16px;
background: var(&#45;&#45;header-bg-color, #ffffff);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
// 左侧品牌区域
.left-section {
display: flex;
align-items: center;
min-width: fit-content;
flex-shrink: 0;
.brand-container {
display: flex;
align-items: center;
gap: 8px;
.logo-img {
width: 36px; // 优化为更合适的大小
height: 36px;
flex-shrink: 0;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
.brand-text {
font-size: 22px; // 减小字体大小
font-weight: bold;
color: var(&#45;&#45;brand-color, #000000);
white-space: nowrap;
letter-spacing: -0.5px;
transition: color 0.2s ease;
&:hover {
//color: var(&#45;&#45;brand-hover-color, #40a9ff);
}
}
}
}
// 右侧功能区域
.right-section {
display: flex;
align-items: center;
gap: 12px; // 优化按钮间距
height: 100%;
flex-shrink: 0;
// 统一按钮样式
:deep(.menu-button) {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>
-->
<!-- Header 头部 -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import logo from '@/assets/images/logo.png'; import logo from '@/assets/images/logo.png';
import ConsoleBtn from '@/layouts/components0/Header/components/ConsoleBtn.vue'; import ConsoleBtn from '@/layouts/components0/Header/components/ConsoleBtn.vue';
@@ -144,6 +16,9 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
// 移动端菜单抽屉状态
const mobileMenuVisible = ref(false);
// 当前激活的菜单项 // 当前激活的菜单项
const activeIndex = computed(() => { const activeIndex = computed(() => {
if (route.path.startsWith('/console')) if (route.path.startsWith('/console'))
@@ -159,15 +34,36 @@ const activeIndex = computed(() => {
function handleSelect(key: string) { function handleSelect(key: string) {
if (key && key !== 'no-route') { if (key && key !== 'no-route') {
router.push(key); router.push(key);
mobileMenuVisible.value = false; // 移动端导航后关闭菜单
} }
} }
// 修改 AI 聊天菜单的点击事件
function handleAIClick(e: MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
router.push('/chat/conversation');
mobileMenuVisible.value = false;
}
// 修改控制台菜单的点击事件
function handleConsoleClick(e: MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
router.push('/console/user');
mobileMenuVisible.value = false;
}
// 切换移动端菜单
function toggleMobileMenu() {
mobileMenuVisible.value = !mobileMenuVisible.value;
}
</script> </script>
<template> <template>
<div class="header-container"> <div class="header-container">
<!-- 桌面端菜单 -->
<el-menu <el-menu
:default-active="activeIndex" :default-active="activeIndex"
class="header-menu" class="header-menu desktop-menu"
mode="horizontal" mode="horizontal"
:ellipsis="false" :ellipsis="false"
:router="false" :router="false"
@@ -186,16 +82,19 @@ function handleSelect(key: string) {
<!-- AI聊天菜单 --> <!-- AI聊天菜单 -->
<el-sub-menu index="chat" class="chat-submenu" popper-class="custom-popover"> <el-sub-menu index="chat" class="chat-submenu" popper-class="custom-popover">
<template #title> <template #title>
<span class="menu-title">AI聊天</span> <span class="menu-title" @click="handleAIClick">AI应用</span>
</template> </template>
<el-menu-item index="/chat/conversation"> <el-menu-item index="/chat/conversation">
AI对话 AI对话
</el-menu-item> </el-menu-item>
<el-menu-item index="/chat/image"> <el-menu-item index="/chat/image">
图片生成 AI图片
</el-menu-item> </el-menu-item>
<el-menu-item index="/chat/video"> <el-menu-item index="/chat/video">
视频生成 AI视频
</el-menu-item>
<el-menu-item index="/chat/agent">
AI智能体
</el-menu-item> </el-menu-item>
</el-sub-menu> </el-sub-menu>
@@ -217,7 +116,7 @@ function handleSelect(key: string) {
<!-- 控制台菜单 --> <!-- 控制台菜单 -->
<el-sub-menu index="console" class="console-submenu" popper-class="custom-popover"> <el-sub-menu index="console" class="console-submenu" popper-class="custom-popover">
<template #title> <template #title>
<ConsoleBtn /> <ConsoleBtn @click="handleConsoleClick" />
</template> </template>
<el-menu-item index="/console/user"> <el-menu-item index="/console/user">
用户信息 用户信息
@@ -266,12 +165,103 @@ function handleSelect(key: string) {
</el-menu-item> </el-menu-item>
</div> </div>
</el-menu> </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> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.header-container { .header-container {
--menu-hover-bg: #f5f5f5; --menu-hover-bg: var(--color-white);
--menu-active-color: var(--el-color-primary); --menu-active-color: var(--el-color-primary);
--menu-transition: all 0.2s ease; --menu-transition: all 0.2s ease;
@@ -279,7 +269,12 @@ function handleSelect(key: string) {
height: var(--header-container-default-height, 64px); height: var(--header-container-default-height, 64px);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
user-select: none; user-select: none;
position: relative;
}
// 移动端头部(默认隐藏)
.mobile-header {
display: none;
} }
.header-menu { .header-menu {
@@ -289,6 +284,8 @@ function handleSelect(key: string) {
justify-content: space-between; justify-content: space-between;
height: 100%; height: 100%;
border-bottom: none !important; border-bottom: none !important;
//background: var(--color-white);
} }
// 左侧品牌区域 // 左侧品牌区域
@@ -344,7 +341,7 @@ function handleSelect(key: string) {
:deep(.el-sub-menu__title) { :deep(.el-sub-menu__title) {
height: 100% !important; height: 100% !important;
border-bottom: none !important; border-bottom: none !important;
padding: 0 12px !important; padding: 0 4px !important;
color: inherit !important; color: inherit !important;
&:hover { &:hover {
@@ -401,10 +398,116 @@ function handleSelect(key: string) {
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 4px;
margin-left: 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) { @media (max-width: 1280px) {
.brand-text { .brand-text {
@@ -451,52 +554,37 @@ function handleSelect(key: string) {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.brand-text { // 隐藏桌面端菜单
display: none; .desktop-menu {
display: none !important;
} }
.logo-img { // 显示移动端头部
width: 32px; .mobile-header {
height: 32px; display: flex;
} align-items: center;
justify-content: space-between;
.menu-left { height: 100%;
margin-left: 12px; padding: 0 12px;
}
.menu-right {
margin-right: 8px;
// 隐藏按钮文字
:deep(.button-text) {
display: none;
}
.menu-title {
display: none;
}
// 显示图标
:deep(.el-icon) {
font-size: 18px;
}
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
padding: 0 8px !important;
min-width: auto !important;
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.menu-right { .mobile-header {
gap: 0; padding: 0 8px;
}
:deep(.el-menu-item), .mobile-brand-text {
:deep(.el-sub-menu__title) { font-size: 16px;
padding: 0 6px !important; }
}
.mobile-logo {
width: 28px;
height: 28px;
}
.mobile-actions {
gap: 4px;
} }
} }
</style> </style>

View File

@@ -415,7 +415,7 @@ function handleContextMenu(event: MouseEvent, item: ConversationItem<ChatSession
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 4px; //padding: 0 4px;
overflow: hidden; overflow: hidden;
.creat-chat-btn-wrapper { .creat-chat-btn-wrapper {

View File

@@ -67,4 +67,23 @@ onMounted(() => {
</div> </div>
</template> </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>

View File

@@ -0,0 +1,618 @@
<script setup lang="ts">
import type { UploadFile, UploadUserFile } from 'element-plus';
import type { ImageModel, TaskStatusResponse } from '@/api/aiImage/types';
import {
CircleCloseFilled,
CopyDocument,
Delete,
Download,
MagicStick,
Picture as PictureIcon,
Plus,
Refresh,
ZoomIn,
} from '@element-plus/icons-vue';
import { useClipboard } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { getSelectableTokenInfo } from '@/api';
import { generateImage, getImageModels, getTaskStatus } from '@/api/aiImage';
const props = defineProps({
isActive: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['task-created']);
const { copy } = useClipboard();
// State
const tokenOptions = ref<any[]>([]);
const selectedTokenId = ref('');
const tokenLoading = ref(false);
const modelOptions = ref<ImageModel[]>([]);
const selectedModelId = ref('');
const modelLoading = ref(false);
const prompt = ref('');
const fileList = ref<UploadUserFile[]>([]);
const compressImage = ref(true);
const generating = ref(false);
const currentTaskId = ref('');
const currentTask = ref<TaskStatusResponse | null>(null);
const showViewer = ref(false);
let pollTimer: any = null;
const canGenerate = computed(() => {
return selectedModelId.value && prompt.value && !generating.value;
});
// Watch isActive to manage polling
watch(() => props.isActive, (active) => {
if (active) {
// Resume polling if we have a processing task
if (currentTaskId.value && currentTask.value?.taskStatus === 'Processing') {
startPolling(currentTaskId.value);
}
}
else {
stopPolling();
}
});
// Methods
async function fetchTokens() {
tokenLoading.value = true;
try {
const res = await getSelectableTokenInfo();
// Handle potential wrapper
const data = Array.isArray(res) ? res : (res as any).data || [];
// Add Default Option
tokenOptions.value = [
{ tokenId: '', name: '默认 (Default)', isDisabled: false },
...data,
];
// Default select "Default" if available, otherwise first available
if (!selectedTokenId.value) {
selectedTokenId.value = '';
}
}
catch (e) {
console.error(e);
}
finally {
tokenLoading.value = false;
}
}
async function fetchModels() {
modelLoading.value = true;
try {
const res = await getImageModels();
// Handle potential wrapper
const data = Array.isArray(res) ? res : (res as any).data || [];
modelOptions.value = data;
// Default select first
if (modelOptions.value.length > 0 && !selectedModelId.value) {
selectedModelId.value = modelOptions.value[0].modelId;
}
}
catch (e) {
console.error(e);
}
finally {
modelLoading.value = false;
}
}
function handleFileChange(uploadFile: UploadFile) {
const isLt5M = uploadFile.size! / 1024 / 1024 < 5;
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!');
const index = fileList.value.indexOf(uploadFile);
if (index !== -1)
fileList.value.splice(index, 1);
}
}
function handleRemove(file: UploadFile) {
const index = fileList.value.indexOf(file);
if (index !== -1)
fileList.value.splice(index, 1);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = error => reject(error);
});
}
async function handleGenerate() {
if (!canGenerate.value)
return;
generating.value = true;
// Reset current task display immediately
currentTask.value = {
id: '',
prompt: prompt.value,
taskStatus: 'Processing',
publishStatus: 'Unpublished',
categories: [],
creationTime: new Date().toISOString(),
};
try {
const base64Images: string[] = [];
for (const fileItem of fileList.value) {
if (fileItem.raw) {
const base64 = await fileToBase64(fileItem.raw);
base64Images.push(base64);
}
}
const res = await generateImage({
tokenId: selectedTokenId.value || undefined, // Send undefined if empty
prompt: prompt.value,
modelId: selectedModelId.value,
referenceImagesPrefixBase64: base64Images,
});
// Robust ID extraction: Handle string, object wrapper, or direct object
let taskId = '';
if (typeof res === 'string') {
taskId = res;
}
else if (typeof res === 'object' && res !== null) {
// Check for data property which might contain the ID string or object
const data = (res as any).data;
if (typeof data === 'string') {
taskId = data;
}
else if (typeof data === 'object' && data !== null) {
taskId = data.id || data.taskId;
}
else {
// Fallback to direct properties
taskId = (res as any).id || (res as any).taskId || (res as any).result;
}
}
if (!taskId) {
console.error('Task ID not found in response:', res);
throw new Error('Invalid Task ID');
}
currentTaskId.value = taskId;
startPolling(taskId);
emit('task-created');
}
catch (e) {
console.error(e);
ElMessage.error('生成任务创建失败');
currentTask.value = null;
}
finally {
generating.value = false; // Allow new tasks immediately
}
}
function startPolling(taskId: string) {
if (pollTimer)
clearInterval(pollTimer);
// Initial fetch
pollStatus(taskId);
pollTimer = setInterval(() => {
pollStatus(taskId);
}, 3000);
}
async function pollStatus(taskId: string) {
// Double check active status before polling (though timer should be cleared)
if (!props.isActive) {
stopPolling();
return;
}
try {
const res = await getTaskStatus(taskId);
// Handle response structure if needed
const taskData = (res as any).data || res;
// Only update if it matches the current task we are watching
// This prevents race conditions if user starts a new task
if (currentTaskId.value === taskId) {
currentTask.value = taskData;
// Case-insensitive check just in case
const status = taskData.taskStatus;
if (status === 'Success' || status === 'Fail') {
stopPolling();
if (status === 'Success') {
ElMessage.success('图片生成成功');
}
else {
ElMessage.error(taskData.errorInfo || '图片生成失败');
}
}
}
else {
// If task ID changed, stop polling this old task
// Actually, we should probably just let the new task's poller handle it
// But since we use a single pollTimer variable, starting a new task clears the old timer.
// So this check is mostly for the initial async call returning after new task started.
}
}
catch (e) {
console.error(e);
// Don't stop polling on transient network errors, but maybe log it
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function clearPrompt() {
prompt.value = '';
}
async function downloadImage() {
if (currentTask.value?.storeUrl) {
try {
const response = await fetch(currentTask.value.storeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `generated-image-${currentTask.value.id}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
catch (e) {
console.error('Download failed', e);
// Fallback
window.open(currentTask.value.storeUrl, '_blank');
}
}
}
async function copyError(text: string) {
await copy(text);
ElMessage.success('错误信息已复制');
}
// Exposed methods for external control
function setPrompt(text: string) {
prompt.value = text;
}
// Helper to load image from URL and convert to File object
async function addReferenceImage(url: string) {
if (fileList.value.length >= 2) {
ElMessage.warning('最多只能上传2张参考图');
return;
}
try {
const response = await fetch(url);
const blob = await response.blob();
const filename = url.split('/').pop() || 'reference.png';
const file = new File([blob], filename, { type: blob.type });
const uploadFile: UploadUserFile = {
name: filename,
url,
raw: file,
uid: Date.now(),
status: 'ready',
};
fileList.value.push(uploadFile);
}
catch (e) {
console.error('Failed to load reference image', e);
ElMessage.error('无法加载参考图');
}
}
defineExpose({
setPrompt,
addReferenceImage,
});
onMounted(() => {
fetchTokens();
fetchModels();
});
onUnmounted(() => {
stopPolling();
});
</script>
<template>
<div class="flex flex-col h-full md:flex-row gap-4 md:gap-6 p-3 md:p-4 bg-white rounded-lg shadow-sm">
<!-- Left Config Panel -->
<div class="w-full md:w-[400px] flex flex-col gap-4 md:gap-6 overflow-y-auto pr-0 md:pr-2 custom-scrollbar">
<div class="space-y-3 md:space-y-4">
<h2 class="text-base md:text-lg font-bold text-gray-800 flex items-center gap-2">
<el-icon><MagicStick /></el-icon>
配置
</h2>
<el-form label-position="top" class="space-y-2">
<!-- Token -->
<el-form-item label="API密钥 (可选)">
<el-select
v-model="selectedTokenId"
placeholder="请选择API密钥"
class="w-full"
:loading="tokenLoading"
clearable
>
<el-option
v-for="token in tokenOptions"
:key="token.tokenId"
:label="token.name"
:value="token.tokenId"
:disabled="token.isDisabled"
/>
</el-select>
</el-form-item>
<!-- Model -->
<el-form-item label="模型" required>
<el-select
v-model="selectedModelId"
placeholder="请选择模型"
class="w-full"
:loading="modelLoading"
>
<el-option
v-for="model in modelOptions"
:key="model.modelId"
:label="model.modelName"
:value="model.modelId"
>
<div class="flex flex-col py-1 max-w-[350px]">
<span class="font-medium truncate">{{ model.modelName }}</span>
<span class="text-xs text-gray-400 truncate" :title="model.modelDescribe">{{ model.modelDescribe }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<!-- Prompt -->
<el-form-item label="提示词" required>
<template #label>
<div class="flex justify-between items-center w-full">
<span>提示词</span>
<el-button link type="primary" size="small" @click="clearPrompt">
<el-icon class="mr-1">
<Delete />
</el-icon>清空
</el-button>
</div>
</template>
<el-input
v-model="prompt"
type="textarea"
:autosize="{ minRows: 8, maxRows: 15 }"
placeholder="描述你想要生成的画面,例如:一只在太空中飞行的赛博朋克风格的猫..."
maxlength="2000"
show-word-limit
class="custom-textarea"
resize="vertical"
/>
</el-form-item>
<!-- Reference Image -->
<el-form-item label="参考图 (可选)">
<div class="w-full bg-gray-50 p-4 rounded-lg border border-dashed border-gray-300 hover:border-blue-400 transition-colors">
<el-upload
v-model:file-list="fileList"
action="#"
list-type="picture-card"
:auto-upload="false"
:limit="2"
:on-change="handleFileChange"
:on-remove="handleRemove"
accept=".jpg,.jpeg,.png,.bmp,.webp"
:class="{ 'hide-upload-btn': fileList.length >= 2 }"
>
<div class="flex flex-col items-center justify-center text-gray-400">
<el-icon class="text-2xl mb-2">
<Plus />
</el-icon>
<span class="text-xs">点击上传</span>
</div>
</el-upload>
<div class="text-xs text-gray-400 mt-2 flex justify-between items-center flex-wrap gap-2">
<span>最多2张< 5MB (支持 JPG/PNG/WEBP)</span>
<el-checkbox v-model="compressImage" label="压缩图片" size="small" />
</div>
</div>
</el-form-item>
</el-form>
</div>
<div class="mt-auto pt-4">
<el-button
type="primary"
class="w-full h-12 text-lg shadow-lg shadow-blue-500/30 transition-all hover:shadow-blue-500/50"
:loading="generating"
:disabled="!canGenerate"
round
@click="handleGenerate"
>
{{ generating ? '生成中...' : '开始生成' }}
</el-button>
</div>
</div>
<!-- Right Result Panel -->
<div class="flex-1 bg-gray-100 rounded-xl overflow-hidden relative flex flex-col h-full">
<div v-if="currentTask" class="flex-1 flex flex-col relative h-full overflow-hidden">
<!-- Image Display -->
<div class="flex-1 flex items-center justify-center p-4 bg-checkboard overflow-hidden relative min-h-0">
<div v-if="currentTask.taskStatus === 'Success' && currentTask.storeUrl" class="relative group w-full h-full flex items-center justify-center">
<el-image
ref="previewImageRef"
:src="currentTask.storeUrl"
fit="contain"
class="w-full h-full"
:style="{ maxHeight: '100%', maxWidth: '100%' }"
:preview-src-list="[currentTask.storeUrl]"
:initial-index="0"
:preview-teleported="true"
/>
<!-- Hover Actions -->
<div class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2 z-10">
<el-button circle type="primary" :icon="ZoomIn" @click="showViewer = true" />
<el-button circle type="primary" :icon="Download" @click="downloadImage" />
<el-button circle type="success" :icon="Refresh" title="重新生成" @click="handleGenerate" />
</div>
<el-image-viewer
v-if="showViewer"
fit="contain"
:url-list="[currentTask.storeUrl]"
@close="showViewer = false"
/>
</div>
<!-- Processing State -->
<div v-else-if="currentTask.taskStatus === 'Processing'" class="text-center">
<div class="loader mb-6" />
<p class="text-gray-600 font-medium text-lg animate-pulse">
正在绘制您的想象...
</p>
<p class="text-gray-400 text-sm mt-2">
请稍候这可能需要几秒钟
</p>
</div>
<!-- Fail State -->
<div v-else-if="currentTask.taskStatus === 'Fail'" class="text-center text-red-500 w-full max-w-lg">
<el-icon class="text-6xl mb-4">
<CircleCloseFilled />
</el-icon>
<p class="text-lg font-medium mb-2">
生成失败
</p>
<div class="bg-red-50 p-4 rounded-lg border border-red-100 text-sm text-left relative group/error">
<div class="max-h-32 overflow-y-auto custom-scrollbar break-words pr-6">
{{ currentTask.errorInfo || '请检查提示词或稍后重试' }}
</div>
<el-button
class="absolute top-2 right-2 opacity-0 group-hover/error:opacity-100 transition-opacity"
size="small"
circle
:icon="CopyDocument"
title="复制错误信息"
@click="copyError(currentTask.errorInfo || '')"
/>
</div>
<el-button class="mt-6" icon="Refresh" @click="handleGenerate">
重试
</el-button>
</div>
</div>
<!-- Prompt Display (Bottom) -->
<div class="bg-white p-4 border-t border-gray-200 shrink-0 max-h-40 overflow-y-auto">
<p class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
当前提示词
</p>
<p class="text-gray-800 text-sm leading-relaxed whitespace-pre-wrap">
{{ currentTask.prompt }}
</p>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex-1 flex flex-col items-center justify-center text-gray-400 bg-gray-50/50 h-full">
<div class="w-32 h-32 bg-gray-200 rounded-full flex items-center justify-center mb-6">
<el-icon class="text-5xl text-gray-400">
<PictureIcon />
</el-icon>
</div>
<h3 class="text-xl font-semibold text-gray-600 mb-2">
准备好开始了吗
</h3>
<p class="text-gray-500 max-w-md text-center">
在左侧配置您的创意参数点击生成按钮见证AI的奇迹
</p>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
.bg-checkboard {
background-image:
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
/* Loader Animation */
.loader {
width: 48px;
height: 48px;
border: 5px solid #e5e7eb;
border-bottom-color: #3b82f6;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:deep(.hide-upload-btn .el-upload--picture-card) {
display: none;
}
</style>

View File

@@ -0,0 +1,547 @@
<script setup lang="ts">
import type { TaskItem, TaskListRequest } from '@/api/aiImage/types';
import {
CircleCloseFilled,
CollectionTag,
CopyDocument,
Download,
Filter,
Loading,
MagicStick,
Picture,
Refresh,
Search,
User,
WarningFilled,
} from '@element-plus/icons-vue';
import { useClipboard } from '@vueuse/core';
import { format } from 'date-fns';
import { ElMessage } from 'element-plus';
import { computed, reactive, ref, watch } from 'vue';
import { getImagePlaza } from '@/api/aiImage';
import TaskCard from './TaskCard.vue';
const emit = defineEmits(['use-prompt', 'use-reference']);
const taskList = ref<TaskItem[]>([]);
const pageIndex = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const noMore = ref(false);
const dialogVisible = ref(false);
const currentTask = ref<TaskItem | null>(null);
// Mobile filter drawer
const showMobileFilter = ref(false);
// Viewer State
const showViewer = ref(false);
const previewUrl = ref('');
// Filter State
const searchForm = reactive({
Prompt: '',
Categories: '',
UserName: '',
});
const dateRange = ref<[string, string] | null>(null);
const { copy } = useClipboard();
const disabled = computed(() => loading.value || noMore.value);
async function loadMore() {
if (loading.value || noMore.value)
return;
loading.value = true;
try {
const params: TaskListRequest = {
SkipCount: (pageIndex.value - 1) * pageSize.value,
MaxResultCount: pageSize.value,
TaskStatus: 'Success',
Prompt: searchForm.Prompt || undefined,
Categories: searchForm.Categories || undefined,
UserName: searchForm.UserName || undefined,
StartTime: dateRange.value ? dateRange.value[0] : undefined,
EndTime: dateRange.value ? dateRange.value[1] : undefined,
};
const res = await getImagePlaza(params);
// Handle potential wrapper
const data = (res as any).data || res;
const items = data.items || [];
const total = data.total || 0;
if (items.length < pageSize.value) {
noMore.value = true;
}
if (pageIndex.value === 1) {
taskList.value = items;
}
else {
// Avoid duplicates
const newItems = items.filter((item: TaskItem) => !taskList.value.some(t => t.id === item.id));
taskList.value.push(...newItems);
}
// Check if we reached total
if (taskList.value.length >= total) {
noMore.value = true;
}
if (items.length > 0) {
pageIndex.value++;
}
else {
noMore.value = true;
}
}
catch (error) {
console.error(error);
noMore.value = true;
}
finally {
loading.value = false;
}
}
function handleSearch() {
pageIndex.value = 1;
taskList.value = [];
noMore.value = false;
loadMore();
}
function handleReset() {
searchForm.Prompt = '';
searchForm.Categories = '';
searchForm.UserName = '';
dateRange.value = null;
handleSearch();
}
function handleCardClick(task: TaskItem) {
currentTask.value = task;
dialogVisible.value = true;
}
function handlePreview(url: string) {
previewUrl.value = url;
showViewer.value = true;
}
function closeViewer() {
showViewer.value = false;
}
function formatTime(time: string) {
try {
return format(new Date(time), 'yyyy-MM-dd HH:mm');
}
catch (e) {
return time;
}
}
async function copyPrompt(text: string) {
await copy(text);
ElMessage.success('已复制到剪贴板');
}
async function downloadImage(url: string) {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `image-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
}
catch (e) {
console.error('Download failed', e);
window.open(url, '_blank');
}
}
// Expose refresh method
defineExpose({
refresh: () => {
pageIndex.value = 1;
taskList.value = [];
noMore.value = false;
loadMore();
},
});
// Watch for filter changes - debounce for text inputs, immediate for others
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(() => searchForm.Prompt, () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
handleSearch();
}, 500);
});
watch([() => searchForm.Categories, () => searchForm.UserName], () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
handleSearch();
}, 500);
});
watch(dateRange, () => {
handleSearch();
});
</script>
<template>
<div class="h-full flex flex-col md:flex-row bg-gray-50">
<!-- Mobile Filter Button -->
<div class="md:hidden p-4 bg-white border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-800">
图片广场
</h2>
<el-button type="primary" @click="showMobileFilter = true">
<el-icon class="mr-1">
<Filter />
</el-icon>
筛选
</el-button>
</div>
<!-- Left Sidebar - Filters (Desktop) -->
<div class="hidden md:flex w-72 bg-white border-r border-gray-200 flex-col shadow-sm">
<div class="p-6 border-b border-gray-100">
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
<el-icon><Filter /></el-icon>
筛选条件
</h2>
</div>
<div class=" overflow-y-auto p-6 custom-scrollbar">
<el-form :model="searchForm" label-position="top" class="space-y-4">
<el-form-item label="提示词">
<el-input
v-model="searchForm.Prompt"
placeholder="搜索提示词..."
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="分类标签">
<el-input
v-model="searchForm.Categories"
placeholder="搜索分类..."
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><CollectionTag /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户名">
<el-input
v-model="searchForm.UserName"
placeholder="搜索用户..."
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-form>
</div>
<div class="p-6 border-t border-gray-100 space-y-2">
<el-button type="primary" class="w-full" @click="handleSearch">
<el-icon class="mr-1">
<Search />
</el-icon>
搜索
</el-button>
<el-button class="w-full refresh-btn " @click="handleReset">
<el-icon class="mr-1">
<Refresh />
</el-icon>
重置
</el-button>
</div>
</div>
<!-- Mobile Filter Drawer -->
<el-drawer
v-model="showMobileFilter"
title="筛选条件"
direction="ltr"
size="80%"
>
<el-form :model="searchForm" label-position="top" class="space-y-4">
<el-form-item label="提示词">
<el-input
v-model="searchForm.Prompt"
placeholder="搜索提示词..."
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="分类标签">
<el-input
v-model="searchForm.Categories"
placeholder="搜索分类..."
clearable
>
<template #prefix>
<el-icon><CollectionTag /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户名">
<el-input
v-model="searchForm.UserName"
placeholder="搜索用户..."
clearable
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="space-y-2">
<el-button type="primary" class="w-full" @click="handleSearch(); showMobileFilter = false">
<el-icon class="mr-1">
<Search />
</el-icon>
搜索
</el-button>
<el-button class="w-full" @click="handleReset(); showMobileFilter = false">
<el-icon class="mr-1">
<Refresh />
</el-icon>
重置
</el-button>
</div>
</template>
</el-drawer>
<!-- Right Content Area -->
<div class="flex-1 flex flex-col overflow-hidden">
<div
v-infinite-scroll="loadMore"
class="flex-1 overflow-y-auto p-4 md:p-6 custom-scrollbar"
:infinite-scroll-disabled="disabled"
:infinite-scroll-distance="50"
>
<div v-if="taskList.length > 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<TaskCard
v-for="task in taskList"
:key="task.id"
:task="task"
@click="handleCardClick(task)"
@use-prompt="$emit('use-prompt', $event)"
@use-reference="$emit('use-reference', $event)"
@preview="handlePreview"
/>
</div>
<!-- Empty State -->
<div v-else-if="!loading && taskList.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400">
<el-icon class="text-6xl mb-4">
<Picture />
</el-icon>
<p>暂无图片快去生成一张吧</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-6 flex justify-center">
<div class="flex items-center gap-2 text-gray-500">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>加载中...</span>
</div>
</div>
<!-- No More State -->
<div v-if="noMore && taskList.length > 0" class="py-6 text-center text-gray-400 text-sm">
- 到底了没有更多图片了 -
</div>
</div>
</div>
<!-- Dialog for details -->
<el-dialog
v-model="dialogVisible"
title="图片详情"
width="900px"
append-to-body
class="image-detail-dialog"
align-center
>
<div v-if="currentTask" class="flex flex-col md:flex-row gap-6 h-[600px]">
<!-- Left Image -->
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative group">
<el-image
v-if="currentTask.storeUrl"
:src="currentTask.storeUrl"
fit="contain"
class="w-full h-full"
:preview-src-list="[currentTask.storeUrl]"
/>
<div v-else class="flex flex-col items-center justify-center text-gray-400 p-4 text-center w-full h-full">
<span v-if="currentTask.taskStatus === 'Processing'" class="flex flex-col items-center">
<el-icon class="text-4xl mb-3 is-loading text-blue-500"><Loading /></el-icon>
<span class="text-blue-500 font-medium">生成中...</span>
</span>
<div v-else-if="currentTask.taskStatus === 'Fail'" class="flex flex-col items-center w-full max-w-md px-4">
<el-icon class="text-5xl mb-4 text-red-500">
<CircleCloseFilled />
</el-icon>
<span class="font-bold mb-4 text-lg text-red-600">生成失败</span>
<div class="w-full bg-gradient-to-br from-red-50 to-red-100 border-2 border-red-200 rounded-xl p-4 relative shadow-sm">
<div class="flex items-start gap-3">
<el-icon class="text-red-500 mt-0.5 flex-shrink-0">
<WarningFilled />
</el-icon>
<div class="flex-1 text-sm text-red-800 max-h-40 overflow-y-auto custom-scrollbar break-words text-left leading-relaxed">
{{ currentTask.errorInfo || '未知错误,请稍后重试' }}
</div>
</div>
<el-button
class="absolute top-2 right-2"
size="small"
circle
type="danger"
:icon="CopyDocument"
title="复制错误信息"
@click="copyPrompt(currentTask.errorInfo || '未知错误')"
/>
</div>
</div>
</div>
<!-- Download Button Overlay -->
<div v-if="currentTask.storeUrl" class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
</div>
</div>
<!-- Right Info -->
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-hidden">
<div class="flex-1 flex flex-col min-h-0">
<h3 class="font-bold text-gray-800 mb-2 flex items-center gap-2 shrink-0">
<el-icon><MagicStick /></el-icon> 提示词
</h3>
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100 text-sm text-gray-600 leading-relaxed relative group/prompt overflow-y-auto custom-scrollbar flex-1">
{{ currentTask.prompt }}
<el-button
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm z-10"
size="small"
circle
:icon="CopyDocument"
title="复制提示词"
@click="copyPrompt(currentTask.prompt)"
/>
</div>
</div>
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100 shrink-0">
<div class="flex justify-between text-sm">
<span class="text-gray-500">创建时间</span>
<span class="text-gray-800">{{ formatTime(currentTask.creationTime) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">状态</span>
<el-tag size="small" type="success">
生成成功
</el-tag>
</div>
<div class="grid grid-cols-1 gap-1 mt-2">
<el-button
type="primary"
:icon="MagicStick"
@click="$emit('use-prompt', currentTask.prompt)"
>
使用提示词
</el-button>
<el-button
v-if="false"
type="primary"
plain
:icon="Picture"
@click="$emit('use-reference', currentTask.storeUrl)"
>
做参考图
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- Global Image Viewer -->
<el-image-viewer
v-if="showViewer && previewUrl"
:url-list="[previewUrl]"
@close="closeViewer"
/>
</div>
</template>
<style lang="scss" scoped>
.refresh-btn {
margin: 20px 0;
}
</style>

View File

@@ -0,0 +1,699 @@
<script setup lang="ts">
import type { InputInstance } from 'element-plus';
import type { TaskItem, TaskListRequest } from '@/api/aiImage/types';
import { Check, CircleCloseFilled, CopyDocument, Download, Filter, Loading, MagicStick, Picture, Refresh, Search, Share, WarningFilled } from '@element-plus/icons-vue';
import { useClipboard } from '@vueuse/core';
import { format } from 'date-fns';
import { ElMessage } from 'element-plus';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { getMyTasks, publishImage } from '@/api/aiImage';
import TaskCard from './TaskCard.vue';
const emit = defineEmits(['use-prompt', 'use-reference']);
const taskList = ref<TaskItem[]>([]);
const pageIndex = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const noMore = ref(false);
const dialogVisible = ref(false);
const currentTask = ref<TaskItem | null>(null);
// Mobile filter drawer
const showMobileFilter = ref(false);
// Viewer State
const showViewer = ref(false);
const previewUrl = ref('');
// Filter State
const searchForm = reactive({
Prompt: '',
TaskStatus: '' as 'Processing' | 'Success' | 'Fail' | '',
PublishStatus: '' as 'Unpublished' | 'Published' | '',
});
const dateRange = ref<[string, string] | null>(null);
// Publish Dialog State
const publishDialogVisible = ref(false);
const publishing = ref(false);
const publishTags = ref<string[]>([]);
const inputValue = ref('');
const inputVisible = ref(false);
const InputRef = ref<InputInstance>();
const taskToPublish = ref<TaskItem | null>(null);
const isAnonymousPublish = ref(false);
const { copy } = useClipboard();
const disabled = computed(() => loading.value || noMore.value);
async function loadMore() {
if (loading.value || noMore.value)
return;
loading.value = true;
try {
const params: TaskListRequest = {
SkipCount: (pageIndex.value - 1) * pageSize.value,
MaxResultCount: pageSize.value,
Prompt: searchForm.Prompt || undefined,
TaskStatus: searchForm.TaskStatus || undefined,
PublishStatus: searchForm.PublishStatus || undefined,
StartTime: dateRange.value ? dateRange.value[0] : undefined,
EndTime: dateRange.value ? dateRange.value[1] : undefined,
};
const res = await getMyTasks(params);
// Handle potential wrapper
const data = (res as any).data || res;
const items = data.items || [];
const total = data.total || 0;
if (items.length < pageSize.value) {
noMore.value = true;
}
if (pageIndex.value === 1) {
taskList.value = items;
}
else {
// Avoid duplicates if any
const newItems = items.filter((item: TaskItem) => !taskList.value.some(t => t.id === item.id));
taskList.value.push(...newItems);
}
// Check if we reached total
if (taskList.value.length >= total) {
noMore.value = true;
}
if (items.length > 0) {
pageIndex.value++;
}
else {
noMore.value = true;
}
}
catch (error) {
console.error(error);
// Stop trying if error occurs to prevent loop
noMore.value = true;
}
finally {
loading.value = false;
}
}
function handleSearch() {
pageIndex.value = 1;
taskList.value = [];
noMore.value = false;
loadMore();
}
function handleReset() {
searchForm.Prompt = '';
searchForm.TaskStatus = '';
searchForm.PublishStatus = '';
dateRange.value = null;
handleSearch();
}
function handleCardClick(task: TaskItem) {
currentTask.value = task;
dialogVisible.value = true;
}
function handlePreview(url: string) {
previewUrl.value = url;
showViewer.value = true;
}
function closeViewer() {
showViewer.value = false;
}
function formatTime(time: string) {
try {
return format(new Date(time), 'yyyy-MM-dd HH:mm');
}
catch (e) {
return time;
}
}
async function copyPrompt(text: string) {
await copy(text);
ElMessage.success('提示词已复制');
}
function openPublishDialog(task: TaskItem) {
taskToPublish.value = task;
publishTags.value = [];
inputValue.value = '';
inputVisible.value = false;
isAnonymousPublish.value = false;
publishDialogVisible.value = true;
}
function handleCloseTag(tag: string) {
publishTags.value.splice(publishTags.value.indexOf(tag), 1);
}
function showInput() {
inputVisible.value = true;
nextTick(() => {
InputRef.value!.input!.focus();
});
}
function handleInputConfirm() {
if (inputValue.value) {
if (!publishTags.value.includes(inputValue.value)) {
publishTags.value.push(inputValue.value);
}
inputValue.value = '';
// 保持焦点
nextTick(() => {
InputRef.value?.focus();
});
}
}
async function confirmPublish() {
if (!taskToPublish.value)
return;
publishing.value = true;
try {
await publishImage({
taskId: taskToPublish.value.id,
categories: publishTags.value,
isAnonymous: isAnonymousPublish.value,
});
ElMessage.success('发布成功');
taskToPublish.value.publishStatus = 'Published';
publishDialogVisible.value = false;
}
catch (e) {
console.error(e);
ElMessage.error('发布失败');
}
finally {
publishing.value = false;
}
}
async function downloadImage(url: string) {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `image-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
}
catch (e) {
console.error('Download failed', e);
window.open(url, '_blank');
}
}
// Expose refresh method
defineExpose({
refresh: () => {
handleSearch();
},
});
// Watch for filter changes - debounce for prompt, immediate for others
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(() => searchForm.Prompt, () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
handleSearch();
}, 500);
});
watch([() => searchForm.TaskStatus, () => searchForm.PublishStatus, dateRange], () => {
handleSearch();
});
</script>
<template>
<div class="h-full flex flex-col md:flex-row bg-gray-50">
<!-- Mobile Filter Button -->
<div class="md:hidden p-4 bg-white border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-800">
我的图库
</h2>
<el-button type="primary" @click="showMobileFilter = true">
<el-icon class="mr-1">
<Filter />
</el-icon>
筛选
</el-button>
</div>
<!-- Left Sidebar - Filters (Desktop) -->
<div class="hidden md:flex w-72 bg-white border-r border-gray-200 flex-col shadow-sm">
<div class="p-6 border-b border-gray-100">
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
<el-icon><Filter /></el-icon>
筛选条件
</h2>
</div>
<div class=" overflow-y-auto p-6 custom-scrollbar">
<el-form :model="searchForm" label-position="top" class="space-y-4">
<el-form-item label="提示词">
<el-input
v-model="searchForm.Prompt"
placeholder="搜索提示词..."
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="任务状态">
<el-select
v-model="searchForm.TaskStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="进行中" value="Processing">
<div class="flex items-center gap-2">
<el-icon class="text-blue-500">
<Loading />
</el-icon>
<span>进行中</span>
</div>
</el-option>
<el-option label="成功" value="Success">
<div class="flex items-center gap-2">
<el-icon class="text-green-500">
<Check />
</el-icon>
<span>成功</span>
</div>
</el-option>
<el-option label="失败" value="Fail">
<div class="flex items-center gap-2">
<el-icon class="text-red-500">
<CircleCloseFilled />
</el-icon>
<span>失败</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="发布状态">
<el-select
v-model="searchForm.PublishStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="未发布" value="Unpublished" />
<el-option label="已发布" value="Published" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-form>
</div>
<div class="p-6 border-t border-gray-100 space-y-2">
<el-button type="primary" class="w-full" @click="handleSearch">
<el-icon class="mr-1">
<Search />
</el-icon>
搜索
</el-button>
<el-button class="w-full refresh-btn" @click="handleReset">
<el-icon class="mr-1">
<Refresh />
</el-icon>
重置
</el-button>
</div>
</div>
<!-- Mobile Filter Drawer -->
<el-drawer
v-model="showMobileFilter"
title="筛选条件"
direction="ltr"
size="80%"
>
<el-form :model="searchForm" label-position="top" class="space-y-4">
<el-form-item label="提示词">
<el-input
v-model="searchForm.Prompt"
placeholder="搜索提示词..."
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="任务状态">
<el-select
v-model="searchForm.TaskStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="进行中" value="Processing" />
<el-option label="成功" value="Success" />
<el-option label="失败" value="Fail" />
</el-select>
</el-form-item>
<el-form-item label="发布状态">
<el-select
v-model="searchForm.PublishStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="未发布" value="Unpublished" />
<el-option label="已发布" value="Published" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="space-y-2">
<el-button type="primary" class="w-full" @click="handleSearch(); showMobileFilter = false">
<el-icon class="mr-1">
<Search />
</el-icon>
搜索
</el-button>
<el-button class="w-full" @click="handleReset(); showMobileFilter = false">
<el-icon class="mr-1">
<Refresh />
</el-icon>
重置
</el-button>
</div>
</template>
</el-drawer>
<!-- Right Content Area -->
<div class="flex-1 flex flex-col overflow-hidden">
<div
v-infinite-scroll="loadMore"
class="flex-1 overflow-y-auto p-4 md:p-6 custom-scrollbar"
:infinite-scroll-disabled="disabled"
:infinite-scroll-distance="50"
>
<div v-if="taskList.length > 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<TaskCard
v-for="task in taskList"
:key="task.id"
:task="task"
show-publish-status
@click="handleCardClick(task)"
@use-prompt="$emit('use-prompt', $event)"
@use-reference="$emit('use-reference', $event)"
@publish="openPublishDialog(task)"
@preview="handlePreview"
/>
</div>
<!-- Empty State -->
<div v-else-if="!loading && taskList.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400">
<el-icon class="text-6xl mb-4">
<Picture />
</el-icon>
<p>没有找到相关图片</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="py-6 flex justify-center">
<div class="flex items-center gap-2 text-gray-500">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>加载中...</span>
</div>
</div>
<!-- No More State -->
<div v-if="noMore && taskList.length > 0" class="py-6 text-center text-gray-400 text-sm">
- 到底了没有更多图片了 -
</div>
</div>
</div>
<!-- Dialog for details -->
<el-dialog
v-model="dialogVisible"
title="图片详情"
width="900px"
append-to-body
class="image-detail-dialog"
align-center
>
<div v-if="currentTask" class="flex flex-col md:flex-row gap-6 h-[600px]">
<!-- Left Image -->
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative group">
<el-image
v-if="currentTask.storeUrl"
:src="currentTask.storeUrl"
fit="contain"
class="w-full h-full"
:preview-src-list="[currentTask.storeUrl]"
/>
<div v-else class="flex flex-col items-center justify-center text-gray-400 p-4 text-center w-full h-full">
<span v-if="currentTask.taskStatus === 'Processing'" class="flex flex-col items-center">
<el-icon class="text-4xl mb-3 is-loading text-blue-500"><Loading /></el-icon>
<span class="text-blue-500 font-medium">生成中...</span>
</span>
<div v-else-if="currentTask.taskStatus === 'Fail'" class="flex flex-col items-center w-full max-w-md px-4">
<el-icon class="text-5xl mb-4 text-red-500">
<CircleCloseFilled />
</el-icon>
<span class="font-bold mb-4 text-lg text-red-600">生成失败</span>
<div class="w-full bg-gradient-to-br from-red-50 to-red-100 border-2 border-red-200 rounded-xl p-4 relative shadow-sm">
<div class="flex items-start gap-3">
<el-icon class="text-red-500 mt-0.5 flex-shrink-0">
<WarningFilled />
</el-icon>
<div class="flex-1 text-sm text-red-800 max-h-40 overflow-y-auto custom-scrollbar break-words text-left leading-relaxed">
{{ currentTask.errorInfo || '未知错误,请稍后重试' }}
</div>
</div>
<el-button
class="absolute top-2 right-2"
size="small"
circle
type="danger"
:icon="CopyDocument"
title="复制错误信息"
@click="copyErrorInfo(currentTask.errorInfo || '未知错误')"
/>
</div>
</div>
</div>
<!-- Download Button Overlay -->
<div v-if="currentTask.storeUrl" class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
</div>
</div>
<!-- Right Info -->
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-hidden">
<div class="flex-1 flex flex-col min-h-0">
<h3 class="font-bold text-gray-800 mb-2 flex items-center gap-2 shrink-0">
<el-icon><MagicStick /></el-icon> 提示词
</h3>
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100 text-sm text-gray-600 leading-relaxed relative group/prompt overflow-y-auto custom-scrollbar flex-1">
{{ currentTask.prompt }}
<el-button
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm z-10"
size="small"
circle
:icon="CopyDocument"
title="复制提示词"
@click="copyPrompt(currentTask.prompt)"
/>
</div>
</div>
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100 shrink-0">
<div class="flex justify-between text-sm">
<span class="text-gray-500">创建时间</span>
<span class="text-gray-800">{{ formatTime(currentTask.creationTime) }}</span>
</div>
<div class="flex justify-between text-sm items-center">
<span class="text-gray-500">状态</span>
<div class="flex gap-2">
<el-tag v-if="currentTask.taskStatus === 'Success'" size="small" type="success">
成功
</el-tag>
<el-tag v-else-if="currentTask.taskStatus === 'Processing'" size="small" type="primary">
进行中
</el-tag>
<el-tag v-else size="small" type="danger">
失败
</el-tag>
<el-tag v-if="currentTask.publishStatus === 'Published'" size="small" type="warning" effect="dark">
已发布
</el-tag>
</div>
</div>
<div v-if="currentTask.taskStatus === 'Success'" class="pt-2 space-y-2">
<div class="grid grid-cols-1 gap-1">
<el-button
type="primary"
plain
:icon="MagicStick"
@click="$emit('use-prompt', currentTask.prompt)"
>
使用提示词
</el-button>
<el-button
v-if="false"
type="primary"
plain
:icon="Picture"
@click="$emit('use-reference', currentTask.storeUrl)"
>
做参考图
</el-button>
</div>
<el-button
v-if="currentTask.publishStatus === 'Unpublished'"
type="success"
class="w-full"
:icon="Share"
@click="openPublishDialog(currentTask)"
>
发布到广场
</el-button>
<el-button v-else type="info" disabled class="w-full">
已发布
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- Publish Dialog -->
<el-dialog
v-model="publishDialogVisible"
title="发布到广场"
width="500px"
append-to-body
align-center
>
<el-form label-position="top">
<el-form-item label="标签 (回车添加)">
<div class="flex gap-2 flex-wrap w-full p-2 border border-gray-200 rounded-md min-h-[40px]">
<el-tag
v-for="tag in publishTags"
:key="tag"
closable
:disable-transitions="false"
@close="handleCloseTag(tag)"
>
{{ tag }}
</el-tag>
<el-input
v-if="inputVisible"
ref="InputRef"
v-model="inputValue"
class="w-24"
size="small"
@keydown.enter.prevent="handleInputConfirm"
@blur="handleInputConfirm"
/>
<el-button v-else class="button-new-tag" size="small" @click="showInput">
+ New Tag
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-checkbox v-model="isAnonymousPublish" label="匿名发布" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="publishDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="publishing" @click="confirmPublish">
发布
</el-button>
</span>
</template>
</el-dialog>
<!-- Global Image Viewer -->
<el-image-viewer
v-if="showViewer && previewUrl"
:url-list="[previewUrl]"
@close="closeViewer"
/>
</div>
</template>
<style scoped>
.refresh-btn {
margin: 20px 0;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { TaskItem } from '@/api/aiImage/types';
import { Check, CircleCloseFilled, Download, Loading, MagicStick, Picture, Picture as PictureIcon, Share, User, ZoomIn } from '@element-plus/icons-vue';
import { format } from 'date-fns';
import { defineEmits, defineProps } from 'vue';
const props = defineProps<{
task: TaskItem;
showPublishStatus?: boolean;
}>();
const emit = defineEmits(['click', 'use-prompt', 'use-reference', 'publish', 'preview']);
function formatTime(time: string) {
try {
return format(new Date(time), 'MM-dd HH:mm');
}
catch (e) {
return '';
}
}
function handlePreview() {
if (props.task.storeUrl) {
emit('preview', props.task.storeUrl);
}
}
async function handleDownload() {
if (!props.task.storeUrl)
return;
try {
const response = await fetch(props.task.storeUrl);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `image-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
}
catch (e) {
console.error('Download failed', e);
window.open(props.task.storeUrl, '_blank');
}
}
</script>
<template>
<div class="task-card group relative flex flex-col bg-white rounded-xl overflow-hidden border border-gray-100 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer">
<!-- Image Area -->
<div class="aspect-square w-full relative bg-gray-100 overflow-hidden">
<!-- Blurred Background -->
<div
v-if="task.taskStatus === 'Success' && task.storeUrl"
class="absolute inset-0 bg-cover bg-center blur-xl opacity-60 scale-125"
:style="{ backgroundImage: `url(${task.storeUrl})` }"
/>
<el-image
v-if="task.taskStatus === 'Success' && task.storeUrl"
:src="task.storeUrl"
fit="contain"
loading="lazy"
class="w-full h-full relative z-10 transition-transform duration-500 group-hover:scale-105"
>
<template #error>
<div class="flex flex-col justify-center items-center w-full h-full text-gray-400 bg-gray-50">
<el-icon class="text-3xl mb-2">
<Picture />
</el-icon>
<span class="text-xs">加载失败</span>
</div>
</template>
<template #placeholder>
<div class="flex justify-center items-center w-full h-full bg-gray-50">
<el-icon class="is-loading text-gray-400">
<Loading />
</el-icon>
</div>
</template>
</el-image>
<!-- Non-Success States -->
<div v-else class="w-full h-full flex flex-col justify-center items-center p-4 text-center relative z-10 bg-gray-50">
<div v-if="task.taskStatus === 'Processing'" class="flex flex-col items-center text-blue-500">
<el-icon class="is-loading text-2xl mb-2">
<Loading />
</el-icon>
<span class="text-xs font-medium">生成中...</span>
</div>
<div v-else-if="task.taskStatus === 'Fail'" class="flex flex-col items-center text-red-500">
<el-icon class="text-2xl mb-2">
<CircleCloseFilled />
</el-icon>
<span class="text-xs font-medium">生成失败</span>
</div>
<div v-else class="text-gray-400">
<span class="text-xs">等待中</span>
</div>
</div>
<!-- Overlay (Hover) -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col items-center justify-center gap-3 z-20 backdrop-blur-[2px]">
<el-button type="primary" round size="small" class="transform scale-90 group-hover:scale-100 transition-transform shadow-lg" @click.stop="$emit('click')">
查看详情
</el-button>
<div v-if="task.taskStatus === 'Success'" class="flex gap-2">
<el-tooltip content="放大查看" placement="top" :show-after="500">
<el-button circle size="small" :icon="ZoomIn" @click.stop="handlePreview" />
</el-tooltip>
<el-tooltip content="下载图片" placement="top" :show-after="500">
<el-button circle size="small" :icon="Download" @click.stop="handleDownload" />
</el-tooltip>
<el-tooltip content="使用提示词" placement="top" :show-after="500">
<el-button circle size="small" :icon="MagicStick" @click.stop="$emit('use-prompt', task.prompt)" />
</el-tooltip>
<el-tooltip v-if="false" content="做参考图" placement="top" :show-after="500">
<el-button circle size="small" :icon="PictureIcon" @click.stop="$emit('use-reference', task.storeUrl)" />
</el-tooltip>
<el-tooltip v-if="task.publishStatus === 'Unpublished'" content="发布到广场" placement="top" :show-after="500">
<el-button circle size="small" type="success" :icon="Share" @click.stop="$emit('publish', task)" />
</el-tooltip>
</div>
</div>
<!-- Status Tag -->
<div v-if="showPublishStatus && task.publishStatus === 'Published'" class="absolute top-2 right-2 z-20">
<el-tag type="success" effect="dark" size="small" round>
已发布
</el-tag>
</div>
</div>
<!-- Content Area -->
<div class="p-3 flex-1 flex flex-col">
<p class="text-sm text-gray-700 line-clamp-2 mb-2 h-10 leading-5" :title="task.prompt">
{{ task.prompt }}
</p>
<!-- Tags -->
<div v-if="task.categories && task.categories.length > 0" class="flex flex-wrap gap-1 mb-2">
<el-tag
v-for="tag in task.categories.slice(0, 3)"
:key="tag"
size="small"
type="info"
effect="plain"
>
{{ tag }}
</el-tag>
<el-tag v-if="task.categories.length > 3" size="small" type="info" effect="plain">
+{{ task.categories.length - 3 }}
</el-tag>
</div>
<div class="mt-auto flex justify-between items-center text-xs text-gray-400">
<div class="flex flex-col gap-1">
<span>{{ formatTime(task.creationTime) }}</span>
<span v-if="!task.isAnonymous && task.userName" class="text-blue-500 flex items-center gap-1">
<el-icon><User /></el-icon> {{ task.userName }}
</span>
<span v-else-if="task.isAnonymous" class="text-gray-400 flex items-center gap-1">
<el-icon><User /></el-icon> 匿名用户
</span>
</div>
<span v-if="task.taskStatus === 'Success'" class="text-green-500 flex items-center gap-1">
<el-icon><Check /></el-icon> 完成
</span>
</div>
</div>
</div>
</template>
<style scoped>
.task-card {
backface-visibility: hidden;
}
</style>

View File

@@ -1,26 +1,87 @@
<script setup lang="ts"> <script setup lang="ts">
// 图片生成功能 - 预留 import { nextTick, ref, watch } from 'vue';
import ImageGenerator from './components/ImageGenerator.vue';
import ImagePlaza from './components/ImagePlaza.vue';
import MyImages from './components/MyImages.vue';
const activeTab = ref('plaza');
const myImagesRef = ref();
const imageGeneratorRef = ref();
function handleTaskCreated() {
// Optional: Switch to My Images or just notify
// For now, we stay on Generator page to see the result.
}
function handleUsePrompt(prompt: string) {
activeTab.value = 'generate';
nextTick(() => {
if (imageGeneratorRef.value) {
imageGeneratorRef.value.setPrompt(prompt);
}
});
}
function handleUseReference(url: string) {
activeTab.value = 'generate';
nextTick(() => {
if (imageGeneratorRef.value && url) {
imageGeneratorRef.value.addReferenceImage(url);
}
});
}
// Refresh My Images when tab is activated
watch(activeTab, (val) => {
if (val === 'my-images' && myImagesRef.value) {
myImagesRef.value.refresh();
}
});
</script> </script>
<template> <template>
<div class="image-generation-page"> <div class="image-page-container h-full flex flex-col">
<el-empty description="图片生成功能开发中,敬请期待"> <el-tabs v-model="activeTab" class="flex-1 flex flex-col" type="border-card">
<template #image> <el-tab-pane label="图片广场" name="plaza" class="h-full">
<el-icon style="font-size: 80px; color: var(--el-color-primary);"> <ImagePlaza
<i-ep-picture /> v-if="activeTab === 'plaza'"
</el-icon> @use-prompt="handleUsePrompt"
</template> @use-reference="handleUseReference"
</el-empty> />
</el-tab-pane>
<el-tab-pane label="图片生成" name="generate" class="h-full">
<ImageGenerator
ref="imageGeneratorRef"
:is-active="activeTab === 'generate'"
@task-created="handleTaskCreated"
/>
</el-tab-pane>
<el-tab-pane label="我的图库" name="my-images" class="h-full">
<MyImages
v-if="activeTab === 'my-images'"
ref="myImagesRef"
@use-prompt="handleUsePrompt"
@use-reference="handleUseReference"
/>
</el-tab-pane>
</el-tabs>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped>
.image-generation-page { .image-page-container {
display: flex; height: 100%;
align-items: center; box-sizing: border-box;
justify-content: center;
width: 100%; }
:deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
padding: 0;
height: calc(100% - 40px);
}
:deep(.el-tab-pane) {
height: 100%; height: 100%;
background-color: var(--el-bg-color);
} }
</style> </style>

View File

@@ -7,13 +7,14 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
// 控制侧边栏折叠状态 // 控制侧边栏折叠状态
const isCollapsed = ref(false); const isCollapsed = ref(true);
// 菜单项配置 // 菜单项配置
const navItems = [ const navItems = [
{ name: 'conversation', label: '对话', icon: 'ChatDotRound', path: '/chat/conversation' }, { name: 'conversation', label: 'AI对话', icon: 'ChatDotRound', path: '/chat/conversation' },
{ name: 'image', label: '图片生成', icon: 'Picture', path: '/chat/image' }, { name: 'image', label: 'AI图片', icon: 'Picture', path: '/chat/image' },
{ name: 'video', label: '视频生成', icon: 'VideoCamera', path: '/chat/video' }, { name: 'video', label: 'AI视频', icon: 'VideoCamera', path: '/chat/video' },
{ name: 'monitor', label: 'AI智能体', icon: 'Monitor', path: '/chat/agent' },
]; ];
// 当前激活的菜单 // 当前激活的菜单
@@ -47,7 +48,7 @@ window.addEventListener('resize', checkIsMobile);
<div class="console-page" :class="{ 'is-collapsed': isCollapsed }"> <div class="console-page" :class="{ 'is-collapsed': isCollapsed }">
<!-- 侧边栏导航 --> <!-- 侧边栏导航 -->
<div class="nav-sidebar" :class="{ 'is-collapsed': isCollapsed }"> <div class="nav-sidebar" :class="{ 'is-collapsed': isCollapsed }">
<div class="nav-header"> <div v-if="false" class="nav-header">
<h2 v-show="!isCollapsed" class="nav-title"> <h2 v-show="!isCollapsed" class="nav-title">
AI聊天 AI聊天
</h2> </h2>
@@ -92,7 +93,7 @@ window.addEventListener('resize', checkIsMobile);
<div v-if="isMobile" class="content-header"> <div v-if="isMobile" class="content-header">
<div class="mobile-toggle" @click="isCollapsed = false"> <div class="mobile-toggle" @click="isCollapsed = false">
<el-icon><i-ep-expand /></el-icon> <el-icon><i-ep-expand /></el-icon>
<span>菜单</span> <span>AI应用</span>
</div> </div>
</div> </div>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">

View File

@@ -208,7 +208,7 @@ watch(
.chat-header { .chat-header {
width: 100%; width: 100%;
max-width: 1000px; //max-width: 1000px;
height: 60px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -607,7 +607,7 @@ function handleImagePreview(url: string) {
.chat-header { .chat-header {
width: 100%; width: 100%;
max-width: 1000px; //max-width: 1000px;
height: 60px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -2,15 +2,29 @@
import { Expand, Fold } from '@element-plus/icons-vue'; import { Expand, Fold } from '@element-plus/icons-vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { checkPagePermission } from '@/config/permission.ts';
import { useUserStore } from '@/stores';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
// 从 URL 中提取邀请码参数
const inviteCodeFromUrl = computed(() => {
return route.query.inviteCode as string | undefined;
});
// 控制侧边栏折叠状态 // 控制侧边栏折叠状态
const isCollapsed = ref(false); const isCollapsed = ref(false);
const userStore = useUserStore();
const userName = userStore.userInfo?.user?.userName;
const hasPermission = checkPagePermission('/console/channel', userName);
// 菜单项配置 // 菜单项配置
const navItems = [
// 基础菜单项
const baseNavItems = [
{ name: 'user', label: '用户信息', icon: 'User', path: '/console/user' }, { name: 'user', label: '用户信息', icon: 'User', path: '/console/user' },
{ name: 'apikey', label: 'API密钥', icon: 'Key', path: '/console/apikey' }, { name: 'apikey', label: 'API密钥', icon: 'Key', path: '/console/apikey' },
{ name: 'recharge-log', label: '充值记录', icon: 'Document', path: '/console/recharge-log' }, { name: 'recharge-log', label: '充值记录', icon: 'Document', path: '/console/recharge-log' },
@@ -19,9 +33,13 @@ const navItems = [
{ name: 'daily-task', label: '每日任务(限时)', icon: 'Trophy', path: '/console/daily-task' }, { name: 'daily-task', label: '每日任务(限时)', icon: 'Trophy', path: '/console/daily-task' },
{ name: 'invite', label: '每周邀请(限时)', icon: 'Present', path: '/console/invite' }, { name: 'invite', label: '每周邀请(限时)', icon: 'Present', path: '/console/invite' },
{ name: 'activation', label: '激活码兑换', icon: 'MagicStick', path: '/console/activation' }, { name: 'activation', label: '激活码兑换', icon: 'MagicStick', path: '/console/activation' },
{ name: 'channel', label: '渠道商管理', icon: 'Setting', path: '/console/channel' },
]; ];
// 根据权限动态添加渠道商管理
const navItems = hasPermission
? [...baseNavItems, { name: 'channel', label: '渠道商管理', icon: 'Setting', path: '/console/channel' }]
: baseNavItems;
// 当前激活的菜单 // 当前激活的菜单
const activeNav = computed(() => { const activeNav = computed(() => {
const path = route.path; const path = route.path;
@@ -97,13 +115,13 @@ window.addEventListener('resize', checkIsMobile);
<div class="content-main"> <div class="content-main">
<div v-if="isMobile" class="content-header"> <div v-if="isMobile" class="content-header">
<div class="mobile-toggle" @click="isCollapsed = false"> <div class="mobile-toggle" @click="isCollapsed = false">
<el-icon><i-ep-expand /></el-icon> <el-icon><Expand /></el-icon>
<span>菜单</span> <span>控制台</span>
</div> </div>
</div> </div>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive> <keep-alive>
<component :is="Component" /> <component :is="Component" :external-invite-code="inviteCodeFromUrl" />
</keep-alive> </keep-alive>
</router-view> </router-view>
</div> </div>
@@ -224,7 +242,7 @@ window.addEventListener('resize', checkIsMobile);
.content-main { .content-main {
flex: 1; flex: 1;
padding: 20px; padding: 10px;
overflow-y: auto; overflow-y: auto;
min-width: 0; min-width: 0;
transition: margin-left 0.3s ease; transition: margin-left 0.3s ease;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelApiTypeOption, ModelLibraryDto, ModelTypeOption } from '@/api/model/types'; import type { ModelApiTypeOption, ModelLibraryDto, ModelTypeOption } from '@/api/model/types';
import { Box, Close, CopyDocument, HomeFilled, OfficeBuilding, Search } from '@element-plus/icons-vue'; import { Box, Close, CopyDocument, HomeFilled, OfficeBuilding, Search, Monitor } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -33,6 +33,10 @@ const modelTypeOptions = ref<ModelTypeOption[]>([]);
// API类型选项 // API类型选项
const apiTypeOptions = ref<ModelApiTypeOption[]>([]); const apiTypeOptions = ref<ModelApiTypeOption[]>([]);
function goToMonitor() {
window.open('http://data.ccnetcore.com:91/?period=24h', '_blank');
}
async function fetchModelList() { async function fetchModelList() {
loading.value = true; loading.value = true;
try { try {
@@ -265,15 +269,20 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
<el-button <div class="monitor-card" @click="goToMonitor">
v-if="false" <div class="monitor-icon">
:icon="HomeFilled" <el-icon class="ecg-icon"><Monitor /></el-icon>
class="home-btn" </div>
round <div class="monitor-info">
@click="goToHome" <div class="monitor-value">
> 实时监控
返回首页 <span class="live-dot" />
</el-button> </div>
<div class="monitor-label">
服务可用性矩阵
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -662,20 +671,66 @@ onMounted(() => {
} }
} }
.home-btn { .monitor-card {
background: rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
padding: 10px 24px; border: 1px solid rgba(255, 255, 255, 0.25);
font-weight: 500; border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); padding: 12px 20px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0; flex-shrink: 0;
&:hover { &:hover {
background: rgba(255, 255, 255, 0.35); background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
.ecg-icon {
animation: heartbeat 1.5s ease-in-out infinite both;
}
}
.monitor-icon {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.25);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
}
.monitor-info {
.monitor-value {
font-size: 16px;
font-weight: 700;
color: white;
line-height: 1.2;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 6px;
.live-dot {
width: 8px;
height: 8px;
background-color: #67c23a;
border-radius: 50%;
box-shadow: 0 0 8px #67c23a;
animation: pulse 2s infinite;
}
}
.monitor-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
}
} }
} }
} }
@@ -1161,7 +1216,7 @@ onMounted(() => {
} }
} }
.home-btn { .monitor-card {
width: 100%; width: 100%;
} }
} }
@@ -1380,4 +1435,18 @@ onMounted(() => {
transform: translateX(0); transform: translateX(0);
} }
} }
@keyframes heartbeat {
0% { transform: scale(1); }
14% { transform: scale(1.3); }
28% { transform: scale(1); }
42% { transform: scale(1.3); }
70% { transform: scale(1); }
}
@keyframes pulse {
0% { opacity: 1; box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.7); }
70% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(103, 194, 58, 0); }
100% { opacity: 1; box-shadow: 0 0 0 0 rgba(103, 194, 58, 0); }
}
</style> </style>

View File

@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// 页面效果查看
// https://ai.ccnetcore.com/pay-result?charset=UTF-8&out_trade_no=YI_20251015232040_3316&method=alipay.trade.page.pay.return&total_amount=215.90&sign=htfny1D%2B8wcLZzK7StevZG%2BD441RLXvksoAR%2BzOq%2B1WHMwfJdVkzyZF2bBmvbU%2FsHBB2HMl8TT3KoaHaf8UfWZgtDGbMoQC%2F1O%2BRcEw8jljlpW3XLMdKGx6dytqZkhq9lRD6tR3ofBiuviv2PmxVd1l%2Bcqs7nwNWwKJWonWI0c5UOE%2BYWgg3hjEJnMYVQjUb6FvrVLfANEU0YyTO%2Bi6vL55Gwug6GIXvGqUPZc3GbwXc%2FUHnu1qv4Yi6tc1dtUoLUNHVfTKrC2N55T84AALZteIK0m7suzrkvBPcKdpn4NGVDtv5cCBCHPjtD3COrNISrNUf3sQXpTvqJGw6dWag6g%3D%3D&trade_no=2025101522001438971445003566&auth_app_id=2021005182687851&version=1.0&app_id=2021005182687851&sign_type=RSA2&seller_id=2088870286599802&timestamp=2025-10-15+23%3A21%3A01
import { ElButton, ElDivider, ElMessage } from 'element-plus'; import { ElButton, ElDivider, ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';

View File

@@ -1,7 +1,9 @@
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'; import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useNProgress } from '@vueuse/integrations/useNProgress'; import { useNProgress } from '@vueuse/integrations/useNProgress';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { ROUTER_WHITE_LIST } from '@/config'; import { ROUTER_WHITE_LIST } from '@/config';
import { checkPagePermission } from '@/config/permission';
import { errorRouter, layoutRouter, staticRouter } from '@/routers/modules/staticRouter'; import { errorRouter, layoutRouter, staticRouter } from '@/routers/modules/staticRouter';
import { useDesignStore, useUserStore } from '@/stores'; import { useDesignStore, useUserStore } from '@/stores';
@@ -63,6 +65,16 @@ router.beforeEach(
if (!userStore.token) if (!userStore.token)
userStore.logout(); userStore.logout();
// 7. 页面权限检查
const userName = userStore.userInfo?.user?.userName;
const hasPermission = checkPagePermission(to.path, userName);
if (!hasPermission) {
// 用户无权访问该页面跳转到403页面
ElMessage.warning('您没有权限访问该页面');
return next('/403');
}
// 其余逻辑 预留... // 其余逻辑 预留...
// 8. 放行路由 // 8. 放行路由

View File

@@ -18,7 +18,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'chat', name: 'chat',
component: () => import('@/pages/chat/index.vue'), component: () => import('@/pages/chat/index.vue'),
meta: { meta: {
title: 'AI聊天', title: 'AI应用',
icon: 'HomeFilled', icon: 'HomeFilled',
}, },
children: [ children: [
@@ -32,7 +32,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'chatConversation', name: 'chatConversation',
component: () => import('@/pages/chat/conversation/index.vue'), component: () => import('@/pages/chat/conversation/index.vue'),
meta: { meta: {
title: 'AI对话', title: '意心Ai-AI对话',
isDefaultChat: true, isDefaultChat: true,
}, },
}, },
@@ -41,7 +41,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'chatConversationWithId', name: 'chatConversationWithId',
component: () => import('@/pages/chat/conversation/index.vue'), component: () => import('@/pages/chat/conversation/index.vue'),
meta: { meta: {
title: 'AI对话', title: '意心Ai-AI对话',
isDefaultChat: false, isDefaultChat: false,
}, },
}, },
@@ -50,7 +50,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'chatImage', name: 'chatImage',
component: () => import('@/pages/chat/image/index.vue'), component: () => import('@/pages/chat/image/index.vue'),
meta: { meta: {
title: '图片生成', title: '意心Ai-AI图片',
}, },
}, },
{ {
@@ -58,7 +58,15 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'chatVideo', name: 'chatVideo',
component: () => import('@/pages/chat/video/index.vue'), component: () => import('@/pages/chat/video/index.vue'),
meta: { meta: {
title: '视频生成', title: '意心Ai-AI视频',
},
},
{
path: 'agent',
name: 'monitor',
component: () => import('@/pages/chat/agent/index.vue'),
meta: {
title: '意心Ai-AI智能体',
}, },
}, },
], ],
@@ -70,7 +78,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'products', name: 'products',
component: () => import('@/pages/products/index.vue'), component: () => import('@/pages/products/index.vue'),
meta: { meta: {
title: '产品页面', title: '意心Ai-产品页面',
keepAlive: 0, keepAlive: 0,
isDefaultChat: false, isDefaultChat: false,
layout: 'blankPage', layout: 'blankPage',
@@ -83,7 +91,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'modelLibrary', name: 'modelLibrary',
component: () => import('@/pages/modelLibrary/index.vue'), component: () => import('@/pages/modelLibrary/index.vue'),
meta: { meta: {
title: '模型库', title: '意心Ai-模型库',
keepAlive: 0, keepAlive: 0,
isDefaultChat: false, isDefaultChat: false,
layout: 'default', layout: 'default',
@@ -96,7 +104,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'payResult', name: 'payResult',
component: () => import('@/pages/payResult/index.vue'), component: () => import('@/pages/payResult/index.vue'),
meta: { meta: {
title: '支付结果', title: '意心Ai-支付结果',
keepAlive: 0, keepAlive: 0,
isDefaultChat: false, isDefaultChat: false,
layout: 'blankPage', layout: 'blankPage',
@@ -109,7 +117,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'activityDetail', name: 'activityDetail',
component: () => import('@/pages/activity/detail.vue'), component: () => import('@/pages/activity/detail.vue'),
meta: { meta: {
title: '活动详情', title: '意心Ai-活动详情',
isDefaultChat: false, isDefaultChat: false,
layout: 'blankPage', layout: 'blankPage',
}, },
@@ -121,7 +129,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'announcementDetail', name: 'announcementDetail',
component: () => import('@/pages/announcement/detail.vue'), component: () => import('@/pages/announcement/detail.vue'),
meta: { meta: {
title: '公告详情', title: '意心Ai-公告详情',
isDefaultChat: false, isDefaultChat: false,
layout: 'blankPage', layout: 'blankPage',
}, },
@@ -133,7 +141,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'console', name: 'console',
component: () => import('@/pages/console/index.vue'), component: () => import('@/pages/console/index.vue'),
meta: { meta: {
title: '控制台', title: '意心Ai-控制台',
icon: 'Setting', icon: 'Setting',
layout: 'default', layout: 'default',
}, },
@@ -148,7 +156,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleUser', name: 'consoleUser',
component: () => import('@/components/userPersonalCenter/components/UserManagement.vue'), component: () => import('@/components/userPersonalCenter/components/UserManagement.vue'),
meta: { meta: {
title: '用户信息', title: '意心Ai-用户信息',
}, },
}, },
{ {
@@ -156,7 +164,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleApikey', name: 'consoleApikey',
component: () => import('@/components/userPersonalCenter/components/APIKeyManagement.vue'), component: () => import('@/components/userPersonalCenter/components/APIKeyManagement.vue'),
meta: { meta: {
title: 'API密钥', title: '意心Ai-API密钥',
}, },
}, },
{ {
@@ -164,7 +172,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleRechargeLog', name: 'consoleRechargeLog',
component: () => import('@/components/userPersonalCenter/components/RechargeLog.vue'), component: () => import('@/components/userPersonalCenter/components/RechargeLog.vue'),
meta: { meta: {
title: '充值记录', title: '意心Ai-充值记录',
}, },
}, },
{ {
@@ -172,7 +180,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleUsage', name: 'consoleUsage',
component: () => import('@/components/userPersonalCenter/components/UsageStatistics.vue'), component: () => import('@/components/userPersonalCenter/components/UsageStatistics.vue'),
meta: { meta: {
title: '用量统计', title: '意心Ai-用量统计',
}, },
}, },
{ {
@@ -180,7 +188,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consolePremium', name: 'consolePremium',
component: () => import('@/components/userPersonalCenter/components/PremiumService.vue'), component: () => import('@/components/userPersonalCenter/components/PremiumService.vue'),
meta: { meta: {
title: '尊享服务', title: '意心Ai-尊享服务',
}, },
}, },
{ {
@@ -188,7 +196,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleDailyTask', name: 'consoleDailyTask',
component: () => import('@/components/userPersonalCenter/components/DailyTask.vue'), component: () => import('@/components/userPersonalCenter/components/DailyTask.vue'),
meta: { meta: {
title: '每日任务', title: '意心Ai-每日任务',
}, },
}, },
{ {
@@ -196,7 +204,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleInvite', name: 'consoleInvite',
component: () => import('@/components/userPersonalCenter/components/CardFlipActivity.vue'), component: () => import('@/components/userPersonalCenter/components/CardFlipActivity.vue'),
meta: { meta: {
title: '每周邀请', title: '意心Ai-每周邀请',
}, },
}, },
{ {
@@ -204,7 +212,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleActivation', name: 'consoleActivation',
component: () => import('@/components/userPersonalCenter/components/ActivationCode.vue'), component: () => import('@/components/userPersonalCenter/components/ActivationCode.vue'),
meta: { meta: {
title: '激活码兑换', title: '意心Ai-激活码兑换',
}, },
}, },
{ {
@@ -212,7 +220,7 @@ export const layoutRouter: RouteRecordRaw[] = [
name: 'consoleChannel', name: 'consoleChannel',
component: () => import('@/pages/console/channel/index.vue'), component: () => import('@/pages/console/channel/index.vue'),
meta: { meta: {
title: '渠道商管理', title: '意心Ai-渠道商管理',
}, },
}, },
], ],

View File

@@ -193,16 +193,16 @@
--el-color-success: var(--color-success) !important; --el-color-success: var(--color-success) !important;
--el-color-warning: var(--color-warning) !important; --el-color-warning: var(--color-warning) !important;
--el-color-danger: var(--color-danger) !important; --el-color-danger: var(--color-danger) !important;
--el-color-info: var(--color-info) !important; //--el-color-info: var(--color-info) !important;
--el-font-size-base: var(--font-size-base) !important; --el-font-size-base: var(--font-size-base) !important;
--el-font-family: var(--font-family-sans) !important; --el-font-family: var(--font-family-sans) !important;
/* Element Plus 组件特定变量 */ /* Element Plus 组件特定变量 */
--el-menu-item-height: 48px; --el-menu-item-height: 48px;
--el-menu-bg-color: var(--sidebar-background-color); //--el-menu-bg-color: var(--sidebar-background-color);
--el-menu-text-color: var(--text-color-secondary); //--el-menu-text-color: var(--text-color-secondary);
--el-menu-active-color: var(--color-primary); --el-menu-active-color: var(--color-primary);
--el-menu-hover-bg-color: var(--color-gray-100); //--el-menu-hover-bg-color: var(--color-gray-100);
/* 表单相关 */ /* 表单相关 */
--el-form-label-font-size: var(--font-size-sm); --el-form-label-font-size: var(--font-size-sm);

View File

@@ -1,5 +1,9 @@
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import ElementPlus from 'element-plus';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import ProductPackage from '@/components/ProductPackage/index.vue'; import ProductPackage from '@/components/ProductPackage/index.vue';
import router from '@/routers';
import store from '@/stores';
export function showProductPackage() { export function showProductPackage() {
const div = document.createElement('div'); const div = document.createElement('div');
@@ -16,5 +20,16 @@ export function showProductPackage() {
}, },
}); });
// 关键:必须在 mount 之前按顺序注册所有依赖
app.use(store); // 1. 先注册 store
app.use(router); // 2. 再注册 router
app.use(ElementPlus); // 3. 最后注册 ElementPlus
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
// 最后才挂载应用
app.mount(div); app.mount(div);
} }

View File

@@ -19,6 +19,7 @@ declare module 'vue' {
ElBadge: typeof import('element-plus/es')['ElBadge'] ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckTag: typeof import('element-plus/es')['ElCheckTag'] ElCheckTag: typeof import('element-plus/es')['ElCheckTag']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapse: typeof import('element-plus/es')['ElCollapse']
@@ -36,6 +37,7 @@ declare module 'vue' {
ElHeader: typeof import('element-plus/es')['ElHeader'] ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage'] ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain'] ElMain: typeof import('element-plus/es')['ElMain']
@@ -59,6 +61,7 @@ declare module 'vue' {
ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default'] FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']
IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default'] IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default']
Indexl: typeof import('./../src/components/SupportModelProducts/indexl.vue')['default'] Indexl: typeof import('./../src/components/SupportModelProducts/indexl.vue')['default']
@@ -87,6 +90,7 @@ declare module 'vue' {
WelecomeText: typeof import('./../src/components/WelecomeText/index.vue')['default'] WelecomeText: typeof import('./../src/components/WelecomeText/index.vue')['default']
} }
export interface GlobalDirectives { export interface GlobalDirectives {
vInfiniteScroll: typeof import('element-plus/es')['ElInfiniteScroll']
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']
} }
} }