17 Commits
Author SHA1 Message Date
nanxun c6ba7ce4c2 Merge pull request 'Codex/merge feature nxdev' (#70) from codex/merge-feature-nxdev into main
Reviewed-on: #70
2026-09-11 18:38:41 +08:00
nanxun 0b3772dc32 merge: integrate feature-nxdev API fixes 2026-09-11 18:32:12 +08:00
nanxun 465361dd10 fix: align frontend with backend APIs 2026-09-11 18:20:37 +08:00
nanxun f1af6e6bc6 1111 2026-05-25 16:33:20 +08:00
nanxun 8d952578de 修改 2026-04-07 20:14:07 +08:00
nanxun cf8be0bff5 add 2026-04-07 20:12:56 +08:00
nanxun f7772a9c5a add 2026-03-29 12:03:15 +08:00
nanxun feed24f378 修改 2026-03-24 17:50:57 +08:00
nanxun 20013ba6bd Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev 2026-03-18 21:48:56 +08:00
nanxun 536b360127 Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev 2026-03-18 21:48:47 +08:00
nanxun 9e7609d6b4 Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev 2026-03-15 22:31:23 +08:00
nanxun d1db5e6490 后端:
完善注册页面
2026-03-15 22:27:34 +08:00
nanxun eb8455e141 后端:
add(GroupNotification):合并群通知表
2026-03-15 14:55:29 +08:00
nanxun f7223dc590 Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev 2026-03-09 14:44:44 +08:00
nanxun e38c15ed92 Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev 2026-03-09 14:44:37 +08:00
nanxun faaaaaa1fa Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev 2026-03-09 13:16:25 +08:00
nanxun fef4bbda70 Merge branch 'feature-nxdev' of https://gitea.nxsir.cn/code/IM into feature-nxdev 2026-03-09 13:16:16 +08:00
145 changed files with 10485 additions and 2205 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")] [assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2ecaa28091b41de707825db3628d380b62fa727f")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8d952578de8d3725b4fcad34056ce8cc01e73665")]
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")] [assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")] [assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
ed4980dfc7aff253176b260ed9015f9a80b52e92cbf3095eff3ed06865ea6e0d 894ce5acc8690cb041ab936657887727ab47ceff5777afb7e84a8a31d3de838c
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/modules.xml
/projectSettingsUpdater.xml
/.idea.IM_API.iml
/contentModel.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>
@@ -22,7 +22,7 @@ namespace IM_API.Application.EventHandlers.FriendAddHandler
var usersList = new List<string> { var usersList = new List<string> {
@event.RequestUserId.ToString(), @event.ResponseUserId.ToString() @event.RequestUserId.ToString(), @event.ResponseUserId.ToString()
}; };
var res = new HubResponse<MessageBaseDto>("Event", new MessageBaseDto() var res = new HubResponse<MessageBaseDto>(HubResponseType.FriendAccepted, new MessageBaseDto()
{ {
ChatType = ChatType.PRIVATE, ChatType = ChatType.PRIVATE,
Content = "您有新的好友关系已添加", Content = "您有新的好友关系已添加",
@@ -15,7 +15,7 @@ namespace IM_API.Application.EventHandlers.GroupInviteActionUpdateHandler
public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context) public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context)
{ {
var @event = context.Message; var @event = context.Message;
if(@event.Action == Models.GroupInviteState.Passed) if(@event.Action == Models.GroupRequestState.TargetPassed)
{ {
await _groupService.MakeGroupRequestAsync(@event.UserId, @event.InviteUserId,@event.GroupId); await _groupService.MakeGroupRequestAsync(@event.UserId, @event.InviteUserId,@event.GroupId);
} }
+29
View File
@@ -29,6 +29,7 @@ namespace IM_API.Configs
CreateMap<RegisterRequestDto, User>() CreateMap<RegisterRequestDto, User>()
.ForMember(dest => dest.Username,opt => opt.MapFrom(src => src.Username)) .ForMember(dest => dest.Username,opt => opt.MapFrom(src => src.Username))
.ForMember(dest => dest.Password,opt => opt.MapFrom(src => src.Password)) .ForMember(dest => dest.Password,opt => opt.MapFrom(src => src.Password))
.ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email))
.ForMember(dest => dest.Avatar,opt => opt.MapFrom(src => "https://ts1.tc.mm.bing.net/th/id/OIP-C.dl0WpkTP6E2J4FnhDC_jHwAAAA?rs=1&pid=ImgDetMain&o=7&rm=3")) .ForMember(dest => dest.Avatar,opt => opt.MapFrom(src => "https://ts1.tc.mm.bing.net/th/id/OIP-C.dl0WpkTP6E2J4FnhDC_jHwAAAA?rs=1&pid=ImgDetMain&o=7&rm=3"))
.ForMember(dest => dest.StatusEnum,opt => opt.MapFrom(src => UserStatus.Normal)) .ForMember(dest => dest.StatusEnum,opt => opt.MapFrom(src => UserStatus.Normal))
.ForMember(dest => dest.OnlineStatusEnum,opt => opt.MapFrom(src => UserOnlineStatus.Offline)) .ForMember(dest => dest.OnlineStatusEnum,opt => opt.MapFrom(src => UserOnlineStatus.Offline))
@@ -215,6 +216,34 @@ namespace IM_API.Configs
CreateMap<GroupMember, GroupMemberVo>() CreateMap<GroupMember, GroupMemberVo>()
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.Created)) .ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.Created))
.ForMember(dest => dest.Role, opt => opt.MapFrom(src => src.RoleEnum)); .ForMember(dest => dest.Role, opt => opt.MapFrom(src => src.RoleEnum));
//群更新模型
CreateMap<GroupUpdateDto, Group>()
.ForMember(dest => dest.Avatar, opt => opt.MapFrom(src => src.Avatar))
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.GroupName))
.ForMember(dest => dest.Announcement, opt => opt.MapFrom(src => src.Description))
.ForAllMembers(dest => dest.Ignore());
CreateMap<Group, GroupInfoVo>()
.ForMember(dest => dest.Auhority, opt => opt.MapFrom(src => src.AuhorityEnum))
.ForMember(dest => dest.AllMembersBanned, opt => opt.MapFrom(src => src.AllMembersBannedEnum))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusEnum))
;
//群通知模型转换
CreateMap<GroupRequest, GroupNotificationVo>()
.ForMember(dest => dest.UserId, opt => opt.MapFrom(src => src.UserId))
.ForMember(dest => dest.GroupId, opt => opt.MapFrom(src => src.GroupId))
.ForMember(dest => dest.InviteUser, opt => opt.MapFrom(src => src.InviteUserId))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StateEnum))
.ForMember(dest => dest.Description, opt => opt.MapFrom(src => src.Description))
.ForMember(dest => dest.RequestId, opt => opt.MapFrom(src => src.Id))
//.ForAllMembers(opt => opt.Ignore())
;
} }
} }
} }
@@ -77,5 +77,33 @@ namespace IM_API.Controllers
var members = await _groupService.GetGroupMembers(int.Parse(useridStr), groupId); var members = await _groupService.GetGroupMembers(int.Parse(useridStr), groupId);
return Ok(new BaseResponse<List<GroupMemberVo>>(members)); return Ok(new BaseResponse<List<GroupMemberVo>>(members));
} }
[HttpPost]
[ProducesResponseType(typeof(BaseResponse<GroupInfoVo>), StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateGroup([FromQuery]int groupId, [FromBody]GroupUpdateDto dto)
{
var useridStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
var groupinfo = await _groupService.UpdateGroupInfoAsync(int.Parse(useridStr), groupId, dto);
return Ok(new BaseResponse<GroupInfoVo>(groupinfo));
}
[HttpGet]
[ProducesResponseType(typeof(BaseResponse<GroupInfoVo>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetGroupInfo([FromQuery]int groupId)
{
var group = await _groupService.GetGroupInfoAsync(groupId);
return Ok(new BaseResponse<GroupInfoVo>(group));
}
[HttpGet]
[ProducesResponseType(typeof(BaseResponse<List<GroupNotificationVo>>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetGroupNotification([FromQuery]int groupId)
{
string userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var data = await _groupService.GetGroupNotificationAsync(int.Parse(userIdStr));
return Ok(new BaseResponse<List<GroupNotificationVo>>(data));
}
} }
} }
@@ -9,6 +9,6 @@ namespace IM_API.Domain.Events
public int InviteUserId { get; set; } public int InviteUserId { get; set; }
public int InviteId { get; set; } public int InviteId { get; set; }
public int GroupId { get; set; } public int GroupId { get; set; }
public GroupInviteState Action { get; set; } public GroupRequestState Action { get; set; }
} }
} }
@@ -4,6 +4,8 @@ namespace IM_API.Dtos.Auth
{ {
public class RegisterRequestDto public class RegisterRequestDto
{ {
[EmailAddress(ErrorMessage = "邮箱格式错误")]
public string Email { get; set; }
[Required(ErrorMessage = "用户名不能为空")] [Required(ErrorMessage = "用户名不能为空")]
[MaxLength(20, ErrorMessage = "用户名不能超过20字符")] [MaxLength(20, ErrorMessage = "用户名不能超过20字符")]
[RegularExpression(@"^[A-Za-z0-9]+$", ErrorMessage = "")] [RegularExpression(@"^[A-Za-z0-9]+$", ErrorMessage = "")]
@@ -0,0 +1,10 @@
namespace IM_API.Dtos.Group
{
public class GroupUpdateDto
{
public string? GroupName { get; set; }
public string? Description { get; set; }
public string? Avatar { get; set; }
}
}
@@ -5,6 +5,6 @@ namespace IM_API.Dtos.Group
public class HandleGroupInviteDto public class HandleGroupInviteDto
{ {
public int InviteId { get; set; } public int InviteId { get; set; }
public GroupInviteState Action { get; set; } public GroupRequestState Action { get; set; }
} }
} }
+36 -3
View File
@@ -39,11 +39,44 @@ namespace IM_API.Dtos
Message = codeDefine.Message; Message = codeDefine.Message;
Data = data; Data = data;
} }
public HubResponse(HubResponseType type, T? data)
{
Code = CodeDefine.SUCCESS.Code;
Method = "Event";
Type = type;
Data = data;
}
} }
public enum HubResponseType public enum HubResponseType
{ {
ChatMsg = 1, // 聊天内容 // --- 基础聊天 (10-19) ---
SystemNotice = 2, // 系统通知(如:申请好友成功) ChatMsg = 1, // 普通消息
ActionStatus = 3 // 状态变更(如:对方正在输入、已读回执)
// --- 系统与通知 (20-29) ---
SystemNotice = 2, // 通用系统通知
FriendRequest = 21, // 好友申请消息
FriendAccepted = 22, // 好友通过通知
UserInLine = 23, // 好友上线/下线通知
// --- 状态变更与交互 (30-39) ---
ActionStatus = 3, // 通用状态(正在输入等)
MsgReadReceipt = 31, // 已读回执
MsgRevoke = 32, // 消息撤回
MsgEdit = 33, // 消息二次编辑更新
// --- 群组管理 (40-49) ---
GroupInvited = 41, // 被邀请入群
GroupMemberUpdate = 42, // 群成员变动(进群/退群)
GroupAnnouncement = 43, // 群公告更新
GroupDismissed = 44, // 群组解散
// --- 实时通信控制 (50-59) ---
RTC_CallRequest = 51, // 音视频通话邀请
RTC_CallHandled = 52, // 音视频接听/挂断/取消状态
// --- 异常与安全 (90-99) ---
ErrorInternal = 91, // 服务器内部错误
TokenExpired = 92, // 登录过期,强制下线
KickedOut = 93 // 被挤下线(异地登录)
} }
} }
@@ -0,0 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/Environment/Hierarchy/Build/BuildTool/RecentDotNetCliExePaths/=_002Fhome_002Fnanxun_002Fdotnet_002Fdotnet/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/nanxun/dotnet/dotnet</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/nanxun/dotnet/sdk/10.0.201/MSBuild.dll</s:String></wpf:ResourceDictionary>
@@ -53,5 +53,15 @@ namespace IM_API.Interface.Services
Task MakeGroupRequestAsync(int userId,int? adminUserId,int groupId); Task MakeGroupRequestAsync(int userId,int? adminUserId,int groupId);
Task MakeGroupMemberAsync(int userId, int groupId, GroupMemberRole? role); Task MakeGroupMemberAsync(int userId, int groupId, GroupMemberRole? role);
Task<List<GroupMemberVo>> GetGroupMembers(int userId, int groupId); Task<List<GroupMemberVo>> GetGroupMembers(int userId, int groupId);
Task<GroupInfoVo> UpdateGroupInfoAsync(int userId, int groupId, GroupUpdateDto updateDto);
Task<GroupInfoVo> GetGroupInfoAsync(int groupId);
/// <summary>
/// 获取群聊通知
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
Task<List<GroupNotificationVo>> GetGroupNotificationAsync(int userId);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class updategroupannouncement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "groups",
keyColumn: "Announcement",
keyValue: null,
column: "Announcement",
value: "");
migrationBuilder.AlterColumn<string>(
name: "Announcement",
table: "groups",
type: "text",
nullable: false,
comment: "群公告",
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldComment: "群公告")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Announcement",
table: "groups",
type: "text",
nullable: true,
comment: "群公告",
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "text",
oldComment: "群公告")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class groupinviterequestmerge : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "group_invite");
migrationBuilder.AddColumn<int>(
name: "InviteUserId",
table: "group_request",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "InviteUserId",
table: "group_request");
migrationBuilder.RenameIndex(
name: "GroupId",
table: "group_request",
newName: "GroupId1");
migrationBuilder.CreateTable(
name: "group_invite",
columns: table => new
{
ID = table.Column<int>(type: "int(11)", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
GroupId = table.Column<int>(type: "int(11)", nullable: false, comment: "群聊编号"),
InvitedUser = table.Column<int>(type: "int(11)", nullable: true, comment: "被邀请用户"),
InviteUser = table.Column<int>(type: "int(11)", nullable: true, comment: "邀请用户"),
Created = table.Column<DateTimeOffset>(type: "datetime", nullable: true, comment: "创建时间"),
State = table.Column<sbyte>(type: "tinyint(4)", nullable: true, comment: "当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)")
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.ID);
table.ForeignKey(
name: "group_invite_ibfk_1",
column: x => x.InviteUser,
principalTable: "users",
principalColumn: "ID");
table.ForeignKey(
name: "group_invite_ibfk_2",
column: x => x.GroupId,
principalTable: "groups",
principalColumn: "ID");
table.ForeignKey(
name: "group_invite_ibfk_3",
column: x => x.InvitedUser,
principalTable: "users",
principalColumn: "ID");
})
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.CreateIndex(
name: "GroupId",
table: "group_invite",
column: "GroupId");
migrationBuilder.CreateIndex(
name: "InvitedUser",
table: "group_invite",
column: "InvitedUser");
migrationBuilder.CreateIndex(
name: "InviteUser",
table: "group_invite",
column: "InviteUser");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class updateuser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
table: "users",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Email",
table: "users",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Region",
table: "users",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
table: "users");
migrationBuilder.DropColumn(
name: "Email",
table: "users");
migrationBuilder.DropColumn(
name: "Region",
table: "users");
}
}
}
@@ -333,6 +333,7 @@ namespace IM_API.Migrations
.HasComment("全员禁言(0允许发言,2全员禁言)"); .HasComment("全员禁言(0允许发言,2全员禁言)");
b.Property<string>("Announcement") b.Property<string>("Announcement")
.IsRequired()
.HasColumnType("text") .HasColumnType("text")
.HasComment("群公告"); .HasComment("群公告");
@@ -392,50 +393,6 @@ namespace IM_API.Migrations
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci"); MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
}); });
modelBuilder.Entity("IM_API.Models.GroupInvite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int(11)")
.HasColumnName("ID");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset?>("Created")
.HasColumnType("datetime")
.HasComment("创建时间");
b.Property<int>("GroupId")
.HasColumnType("int(11)")
.HasComment("群聊编号");
b.Property<int?>("InviteUser")
.HasColumnType("int(11)")
.HasComment("邀请用户");
b.Property<int?>("InvitedUser")
.HasColumnType("int(11)")
.HasComment("被邀请用户");
b.Property<sbyte?>("State")
.HasColumnType("tinyint(4)")
.HasComment("当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)");
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex(new[] { "GroupId" }, "GroupId");
b.HasIndex(new[] { "InviteUser" }, "InviteUser");
b.HasIndex(new[] { "InvitedUser" }, "InvitedUser");
b.ToTable("group_invite", (string)null);
MySqlEntityTypeBuilderExtensions.HasCharSet(b, "utf8mb4");
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
});
modelBuilder.Entity("IM_API.Models.GroupMember", b => modelBuilder.Entity("IM_API.Models.GroupMember", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -502,6 +459,9 @@ namespace IM_API.Migrations
.HasColumnType("int(11)") .HasColumnType("int(11)")
.HasComment("群聊编号\r\n"); .HasComment("群聊编号\r\n");
b.Property<int?>("InviteUserId")
.HasColumnType("int");
b.Property<sbyte>("State") b.Property<sbyte>("State")
.HasColumnType("tinyint(4)") .HasColumnType("tinyint(4)")
.HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)"); .HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)");
@@ -515,8 +475,7 @@ namespace IM_API.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.HasIndex(new[] { "GroupId" }, "GroupId") b.HasIndex(new[] { "GroupId" }, "GroupId");
.HasDatabaseName("GroupId1");
b.ToTable("group_request", (string)null); b.ToTable("group_request", (string)null);
@@ -844,6 +803,12 @@ namespace IM_API.Migrations
.HasDefaultValueSql("'1970-01-01 00:00:00'") .HasDefaultValueSql("'1970-01-01 00:00:00'")
.HasComment("创建时间"); .HasComment("创建时间");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<string>("Email")
.HasColumnType("longtext");
b.Property<sbyte>("IsDeleted") b.Property<sbyte>("IsDeleted")
.HasColumnType("tinyint(4)") .HasColumnType("tinyint(4)")
.HasComment("软删除标识\r\n0:账号正常\r\n1:账号已删除"); .HasComment("软删除标识\r\n0:账号正常\r\n1:账号已删除");
@@ -863,6 +828,9 @@ namespace IM_API.Migrations
.HasColumnType("varchar(50)") .HasColumnType("varchar(50)")
.HasComment("密码"); .HasComment("密码");
b.Property<string>("Region")
.HasColumnType("longtext");
b.Property<sbyte>("Status") b.Property<sbyte>("Status")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(4)") .HasColumnType("tinyint(4)")
@@ -991,31 +959,6 @@ namespace IM_API.Migrations
b.Navigation("GroupMasterNavigation"); b.Navigation("GroupMasterNavigation");
}); });
modelBuilder.Entity("IM_API.Models.GroupInvite", b =>
{
b.HasOne("IM_API.Models.Group", "Group")
.WithMany("GroupInvites")
.HasForeignKey("GroupId")
.IsRequired()
.HasConstraintName("group_invite_ibfk_2");
b.HasOne("IM_API.Models.User", "InviteUserNavigation")
.WithMany("GroupInviteInviteUserNavigations")
.HasForeignKey("InviteUser")
.HasConstraintName("group_invite_ibfk_1");
b.HasOne("IM_API.Models.User", "InvitedUserNavigation")
.WithMany("GroupInviteInvitedUserNavigations")
.HasForeignKey("InvitedUser")
.HasConstraintName("group_invite_ibfk_3");
b.Navigation("Group");
b.Navigation("InviteUserNavigation");
b.Navigation("InvitedUserNavigation");
});
modelBuilder.Entity("IM_API.Models.GroupMember", b => modelBuilder.Entity("IM_API.Models.GroupMember", b =>
{ {
b.HasOne("IM_API.Models.Group", "Group") b.HasOne("IM_API.Models.Group", "Group")
@@ -1108,8 +1051,6 @@ namespace IM_API.Migrations
modelBuilder.Entity("IM_API.Models.Group", b => modelBuilder.Entity("IM_API.Models.Group", b =>
{ {
b.Navigation("GroupInvites");
b.Navigation("GroupMembers"); b.Navigation("GroupMembers");
b.Navigation("GroupRequests"); b.Navigation("GroupRequests");
@@ -1148,10 +1089,6 @@ namespace IM_API.Migrations
b.Navigation("FriendUsers"); b.Navigation("FriendUsers");
b.Navigation("GroupInviteInviteUserNavigations");
b.Navigation("GroupInviteInvitedUserNavigations");
b.Navigation("GroupMembers"); b.Navigation("GroupMembers");
b.Navigation("GroupRequests"); b.Navigation("GroupRequests");
+1 -2
View File
@@ -38,7 +38,7 @@ public partial class Group
/// <summary> /// <summary>
/// 群公告 /// 群公告
/// </summary> /// </summary>
public string? Announcement { get; set; } public string Announcement { get; set; } = "暂无群公告,点击编辑添加。";
/// <summary> /// <summary>
/// 群聊创建时间 /// 群聊创建时间
@@ -56,7 +56,6 @@ public partial class Group
public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow; public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow;
public virtual ICollection<GroupInvite> GroupInvites { get; set; } = new List<GroupInvite>();
public virtual User GroupMasterNavigation { get; set; } = null!; public virtual User GroupMasterNavigation { get; set; } = null!;
-11
View File
@@ -1,11 +0,0 @@
namespace IM_API.Models
{
public partial class GroupInvite
{
public GroupInviteState StateEnum
{
get => (GroupInviteState)State;
set => State = (sbyte)value;
}
}
}
-17
View File
@@ -1,17 +0,0 @@
namespace IM_API.Models
{
/// <summary>
/// 群邀请状态
/// </summary>
public enum GroupInviteState
{
/// <summary>
/// 待处理
/// </summary>
Pending = 0,
/// <summary>
/// 已同意
/// </summary>
Passed = 1
}
}
+15 -3
View File
@@ -7,12 +7,24 @@
/// </summary> /// </summary>
Pending = 0, Pending = 0,
/// <summary> /// <summary>
/// 已拒绝 /// 管理员已拒绝
/// </summary> /// </summary>
Declined = 1, Declined = 1,
/// <summary> /// <summary>
/// 已同意 /// 管理员已同意
/// </summary> /// </summary>
Passed = 2 Passed = 2,
/// <summary>
/// 待对方同意
/// </summary>
TargetPending = 3,
/// <summary>
/// 对方拒绝
/// </summary>
TargetDeclined = 4,
/// <summary>
/// 对方同意
/// </summary>
TargetPassed = 5
} }
} }
-41
View File
@@ -1,41 +0,0 @@
using System;
using System.Collections.Generic;
namespace IM_API.Models;
public partial class GroupInvite
{
public int Id { get; set; }
/// <summary>
/// 群聊编号
/// </summary>
public int GroupId { get; set; }
/// <summary>
/// 被邀请用户
/// </summary>
public int? InvitedUser { get; set; }
/// <summary>
/// 邀请用户
/// </summary>
public int? InviteUser { get; set; }
/// <summary>
/// 当前状态(0:待被邀请人同意
/// 1:被邀请人已同意)
/// </summary>
public sbyte? State { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTimeOffset? Created { get; set; }
public virtual Group Group { get; set; } = null!;
public virtual User? InviteUserNavigation { get; set; }
public virtual User? InvitedUserNavigation { get; set; }
}
+3 -1
View File
@@ -19,8 +19,10 @@ public partial class GroupRequest
/// </summary> /// </summary>
public int UserId { get; set; } public int UserId { get; set; }
public int? InviteUserId { get; set; }
/// <summary> /// <summary>
/// 申请状态(0:待管理员同意,1:已拒绝,2:已同意 /// 申请状态(0:待管理员同意,1:管理员已拒绝,2管理员已同意,3:待对方同意,4:对方拒绝
/// </summary> /// </summary>
public sbyte State { get; set; } public sbyte State { get; set; }
@@ -49,11 +49,6 @@ namespace IM_API.Models
entity.Ignore(e => e.AuhorityEnum); entity.Ignore(e => e.AuhorityEnum);
}); });
modelBuilder.Entity<GroupInvite>(entity =>
{
entity.Ignore(e => e.StateEnum);
});
modelBuilder.Entity<GroupMember>(entity => modelBuilder.Entity<GroupMember>(entity =>
{ {
entity.Ignore(e => e.RoleEnum); entity.Ignore(e => e.RoleEnum);
-48
View File
@@ -27,7 +27,6 @@ public partial class ImContext : DbContext
public virtual DbSet<Group> Groups { get; set; } public virtual DbSet<Group> Groups { get; set; }
public virtual DbSet<GroupInvite> GroupInvites { get; set; }
public virtual DbSet<GroupMember> GroupMembers { get; set; } public virtual DbSet<GroupMember> GroupMembers { get; set; }
@@ -362,53 +361,6 @@ public partial class ImContext : DbContext
.HasConstraintName("groups_ibfk_1"); .HasConstraintName("groups_ibfk_1");
}); });
modelBuilder.Entity<GroupInvite>(entity =>
{
entity.HasKey(e => e.Id).HasName("PRIMARY");
entity
.ToTable("group_invite")
.HasCharSet("utf8mb4")
.UseCollation("utf8mb4_general_ci");
entity.HasIndex(e => e.GroupId, "GroupId");
entity.HasIndex(e => e.InviteUser, "InviteUser");
entity.HasIndex(e => e.InvitedUser, "InvitedUser");
entity.Property(e => e.Id)
.HasColumnType("int(11)")
.HasColumnName("ID");
entity.Property(e => e.Created)
.HasComment("创建时间")
.HasColumnType("datetime");
entity.Property(e => e.GroupId)
.HasComment("群聊编号")
.HasColumnType("int(11)");
entity.Property(e => e.InviteUser)
.HasComment("邀请用户")
.HasColumnType("int(11)");
entity.Property(e => e.InvitedUser)
.HasComment("被邀请用户")
.HasColumnType("int(11)");
entity.Property(e => e.State)
.HasComment("当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)")
.HasColumnType("tinyint(4)");
entity.HasOne(d => d.Group).WithMany(p => p.GroupInvites)
.HasForeignKey(d => d.GroupId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("group_invite_ibfk_2");
entity.HasOne(d => d.InviteUserNavigation).WithMany(p => p.GroupInviteInviteUserNavigations)
.HasForeignKey(d => d.InviteUser)
.HasConstraintName("group_invite_ibfk_1");
entity.HasOne(d => d.InvitedUserNavigation).WithMany(p => p.GroupInviteInvitedUserNavigations)
.HasForeignKey(d => d.InvitedUser)
.HasConstraintName("group_invite_ibfk_3");
});
modelBuilder.Entity<GroupMember>(entity => modelBuilder.Entity<GroupMember>(entity =>
{ {
+12 -4
View File
@@ -23,6 +23,18 @@ public partial class User
/// 用户昵称 /// 用户昵称
/// </summary> /// </summary>
public string? NickName { get; set; } public string? NickName { get; set; }
/// <summary>
/// 用户邮箱
/// </summary>
public string? Email { get; set; }
/// <summary>
/// 用户签名
/// </summary>
public string? Description { get; set; } = "";
/// <summary>
/// 地区
/// </summary>
public string? Region { get; set; } = "未知地区";
/// <summary> /// <summary>
/// 用户在线状态 /// 用户在线状态
@@ -73,10 +85,6 @@ public partial class User
[JsonIgnore] [JsonIgnore]
public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>(); public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>();
[JsonIgnore] [JsonIgnore]
public virtual ICollection<GroupInvite> GroupInviteInviteUserNavigations { get; set; } = new List<GroupInvite>();
[JsonIgnore]
public virtual ICollection<GroupInvite> GroupInviteInvitedUserNavigations { get; set; } = new List<GroupInvite>();
[JsonIgnore]
public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>(); public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>();
[JsonIgnore] [JsonIgnore]
public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>(); public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>();
@@ -146,7 +146,8 @@ namespace IM_API.Services
public async Task MakeConversationAsync(int userAId, int userBId, ChatType chatType) public async Task MakeConversationAsync(int userAId, int userBId, ChatType chatType)
{ {
var userAcExist = await _context.Conversations.AnyAsync(x => x.UserId == userAId && x.TargetId == userBId); var userAcExist = await _context.Conversations.AnyAsync(
x => x.UserId == userAId && x.TargetId == userBId && x.ChatType == chatType);
if (userAcExist) return; if (userAcExist) return;
var streamKey = chatType == ChatType.PRIVATE ? var streamKey = chatType == ChatType.PRIVATE ?
StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId); StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId);
+115 -10
View File
@@ -79,6 +79,7 @@ namespace IM_API.Services
} }
} }
public Task DeleteGroupAsync(int userId, int groupId) public Task DeleteGroupAsync(int userId, int groupId)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
@@ -90,15 +91,15 @@ namespace IM_API.Services
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND); x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
//过滤非好友 //过滤非好友
var groupInviteIds = await validFriendshipAsync(userId, userIds); var groupInviteIds = await validFriendshipAsync(userId, userIds);
var inviteList = groupInviteIds.Select(id => new GroupInvite var inviteList = groupInviteIds.Select(id => new GroupRequest
{ {
Created = DateTime.UtcNow, Created = DateTime.UtcNow,
GroupId = group.Id, GroupId = group.Id,
InviteUser = userId, UserId = id,
InvitedUser = id, InviteUserId = userId,
StateEnum = GroupInviteState.Pending StateEnum = GroupRequestState.TargetPending
}).ToList(); }).ToList();
_context.GroupInvites.AddRange(inviteList); _context.GroupRequests.AddRange(inviteList);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteEvent await _endPoint.Publish(new GroupInviteEvent
{ {
@@ -132,7 +133,7 @@ namespace IM_API.Services
throw new NotImplementedException(); throw new NotImplementedException();
} }
public async Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page, int limit, bool desc) public async Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page = 1, int limit = 50, bool desc = false)
{ {
var query = _context.GroupMembers var query = _context.GroupMembers
.Where(x => x.UserId == userId) .Where(x => x.UserId == userId)
@@ -159,14 +160,18 @@ namespace IM_API.Services
_context.Groups.Update(group); _context.Groups.Update(group);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto) public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto)
{ {
var user = _userService.GetUserInfoAsync(userid); var user = _userService.GetUserInfoAsync(userid);
var inviteInfo = await _context.GroupInvites.FirstOrDefaultAsync(x => x.Id == dto.InviteId) var inviteInfo = await _context.GroupRequests
.FirstOrDefaultAsync(x => x.UserId == userid && x.StateEnum == GroupRequestState.TargetPending)
?? throw new BaseException(CodeDefine.INVALID_ACTION); ?? throw new BaseException(CodeDefine.INVALID_ACTION);
if (inviteInfo.InvitedUser != userid) throw new BaseException(CodeDefine.AUTH_FAILED); if (!(dto.Action == GroupRequestState.TargetPassed ||
dto.Action == GroupRequestState.TargetDeclined))
return;
inviteInfo.StateEnum = dto.Action; inviteInfo.StateEnum = dto.Action;
_context.GroupInvites.Update(inviteInfo); _context.GroupRequests.Update(inviteInfo);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteActionUpdateEvent await _endPoint.Publish(new GroupInviteActionUpdateEvent
{ {
@@ -176,12 +181,13 @@ namespace IM_API.Services
EventId = Guid.NewGuid(), EventId = Guid.NewGuid(),
GroupId = inviteInfo.GroupId, GroupId = inviteInfo.GroupId,
InviteId = inviteInfo.Id, InviteId = inviteInfo.Id,
InviteUserId = inviteInfo.InviteUser.Value, InviteUserId = inviteInfo.InviteUserId!.Value,
OperatorId = userid, OperatorId = userid,
UserId = userid UserId = userid
}); });
} }
public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto) public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto)
{ {
var user = _userService.GetUserInfoAsync(userid); var user = _userService.GetUserInfoAsync(userid);
@@ -266,5 +272,104 @@ namespace IM_API.Services
return user; return user;
}).ToList(); }).ToList();
} }
public async Task<GroupInfoVo> UpdateGroupInfoAsync(int userId, int groupId, GroupUpdateDto updateDto)
{
//判断群存在
var groupInfo = await _context.Groups.FirstOrDefaultAsync(x => x.Id == groupId);
if (groupInfo is null)
throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
//判断操作者权限
var memberInfo = await _context.GroupMembers
.FirstOrDefaultAsync(x => x.UserId == userId && x.GroupId == groupId);
if (memberInfo is null || memberInfo.RoleEnum == GroupMemberRole.Normal)
throw new BaseException(CodeDefine.NO_GROUP_PERMISSION);
groupInfo.Name = updateDto.GroupName ?? groupInfo.Name;
groupInfo.Avatar = updateDto.Avatar ?? groupInfo.Avatar;
groupInfo.Announcement = updateDto.Description ?? groupInfo.Announcement;
_context.Groups.Update(groupInfo);
await _context.SaveChangesAsync();
return _mapper.Map<GroupInfoVo>(groupInfo);
}
public async Task<GroupInfoVo> GetGroupInfoAsync(int groupId)
{
var groupInfo = await _context.Groups.FirstOrDefaultAsync(x => x.Id == groupId);
if (groupInfo is null)
throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
return _mapper.Map<GroupInfoVo>(groupInfo);
}
public async Task<List<GroupNotificationVo>> GetGroupNotificationAsync(int userId)
{
// 1. 查询群请求记录
var groupList = await _context.GroupMembers
.Where(x => x.UserId == userId &&
(x.Role == (sbyte)GroupMemberRole.Master || x.Role == (sbyte)GroupMemberRole.Administrator))
.Select(s => s.GroupId)
.ToListAsync();
var groupRequest = await _context.GroupRequests
.Where(x => groupList.Contains(x.GroupId) || x.UserId == userId || x.InviteUserId == userId)
.OrderByDescending(o => o.Id)
.ToListAsync();
if (!groupRequest.Any()) return new List<GroupNotificationVo>();
// 2. 收集所有需要的 ID 并去重
var userIds = groupRequest.Select(s => s.UserId).Distinct().ToList();
var inviteUserIds = groupRequest.Where(x => x.InviteUserId != null).Select(s => s.InviteUserId.Value).Distinct().ToList();
var groupIds = groupRequest.Select(s => s.GroupId).Distinct().ToList();
var userList = await _userService.GetUserInfoListAsync(userIds);
var inviteUserList = await _userService.GetUserInfoListAsync(inviteUserIds);
var groupInfoList = await _context.Groups
.Where(x => groupIds.Contains(x.Id))
.ToListAsync();
// 2. 转换为字典
var userDict = userList.ToDictionary(u => u.Id);
var inviteUserDict = inviteUserList.ToDictionary(u => u.Id);
var groupDict = groupInfoList.ToDictionary(g => g.Id);
// 3. 组装数据 (Select 逻辑不变)
return groupRequest.Select(g =>
{
var gnv = _mapper.Map<GroupNotificationVo>(g);
// 匹配用户信息
if (userDict.TryGetValue(g.UserId, out var u))
{
gnv.UserAvatar = u.Avatar;
gnv.NickName = u.NickName;
}
// 匹配邀请人信息
if (g.InviteUserId.HasValue && inviteUserDict.TryGetValue(g.InviteUserId.Value, out var i))
{
gnv.InviteUserAvatar = i.Avatar;
gnv.InviteUserNickname = i.NickName;
}
// 匹配群信息
if (groupDict.TryGetValue(g.GroupId, out var gi))
{
gnv.GroupAvatar = gi.Avatar;
gnv.GroupName = gi.Name;
}
return gnv;
}).ToList();
}
} }
} }
+8
View File
@@ -0,0 +1,8 @@
using IM_API.Dtos.Group;
namespace IM_API.VOs.Group
{
public class GroupInfoVo:GroupInfoDto
{
}
}
@@ -8,6 +8,6 @@ namespace IM_API.VOs.Group
public int InviteUserId { get; set; } public int InviteUserId { get; set; }
public int InvitedUserId { get; set; } public int InvitedUserId { get; set; }
public int InviteId { get; set; } public int InviteId { get; set; }
public GroupInviteState Action { get; set; } public GroupRequestState Action { get; set; }
} }
} }
@@ -0,0 +1,21 @@
using IM_API.Models;
namespace IM_API.VOs.Group
{
public class GroupNotificationVo
{
public int RequestId { get; set; }
public int? UserId { get; set; }
public string? NickName { get; set; }
public string? UserAvatar { get; set; }
public int GroupId { get; set; }
public string? GroupAvatar { get; set; }
public string? GroupName { get; set; }
public GroupRequestState Status { get; set; }
public string Description { get; set; }
public int? InviteUser { get; set; }
public string? InviteUserNickname { get; set; }
public string? InviteUserAvatar { get; set; }
}
}
+10 -2
View File
@@ -10,6 +10,9 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories { repositories {
// 👇 添加这一行,用于下载 Flutter 插件相关的依赖
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
maven { url = uri("https://maven.aliyun.com/repository/google") } maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") } maven { url = uri("https://maven.aliyun.com/repository/public") }
gradlePluginPortal() gradlePluginPortal()
@@ -21,6 +24,9 @@ pluginManagement {
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories { repositories {
// 👇 添加这一行,这是解决你报错的核心!强制项目从这里下载 Flutter 引擎
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
maven { url = uri("https://maven.aliyun.com/repository/google") } maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") } maven { url = uri("https://maven.aliyun.com/repository/public") }
gradlePluginPortal() gradlePluginPortal()
@@ -31,8 +37,10 @@ dependencyResolutionManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.1.1" apply false // 👇 升级到 8.2.1 修复 Java 21 兼容性 bug
id("org.jetbrains.kotlin.android") version "1.8.22" apply false id("com.android.application") version "8.2.1" apply false
// 👇 顺便把 Kotlin 版本也稍微升一下,避免后续出现旧版本警告
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
} }
include(":app") include(":app")
@@ -0,0 +1,9 @@
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor{
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// TODO: implement onRequest
super.onRequest(options, handler);
}
}
@@ -0,0 +1,21 @@
import 'package:app/core/network/auth_interceptor.dart';
import 'package:dio/dio.dart';
class Request {
static final Request _instance = Request._internal();
factory Request() => _instance;
late Dio dio;
Request._internal(){
BaseOptions options = BaseOptions(
baseUrl: "",
responseType: ResponseType.json,
contentType: 'application/json; charset=utf-8',
);
dio = Dio(options);
dio.interceptors.addAll([
AuthInterceptor()
]);
}
}
@@ -1,6 +1,7 @@
import 'package:app/features/auth/pages/login_page.dart'; import 'package:app/features/auth/pages/login_page.dart';
import 'package:app/features/auth/pages/register_page.dart';
import 'package:app/features/home/pages/index_page.dart'; import 'package:app/features/home/pages/index_page.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -10,6 +11,7 @@ final appRouter = GoRouter(
initialLocation: '/auth/login', initialLocation: '/auth/login',
routes: [ routes: [
GoRoute(path: '/auth/login', builder: (context, state) => const LoginPage()), GoRoute(path: '/auth/login', builder: (context, state) => const LoginPage()),
GoRoute(path: '/auth/register', builder: (context, state) => const RegisterPage()),
ShellRoute( ShellRoute(
builder: (context, state, child) { builder: (context, state, child) {
return MainPage(child: child); return MainPage(child: child);
@@ -1,6 +1,7 @@
import 'package:app/core/constants/app_colors.dart'; import 'package:app/core/constants/app_colors.dart';
import 'package:app/features/auth/pages/login_page.dart'; import 'package:app/features/auth/pages/login_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:app/core/router/app_router.dart';
class LoginPageState extends State<LoginPage> { class LoginPageState extends State<LoginPage> {
@override @override
@@ -11,10 +12,10 @@ class LoginPageState extends State<LoginPage> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
actions: [ actions: [
TextButton( // TextButton(
onPressed: () {}, // onPressed: () {},
child: const Text("找回密码", style: TextStyle(color: Colors.grey)), // child: const Text("找回密码", style: TextStyle(color: Colors.grey)),
), // ),
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
@@ -24,23 +25,9 @@ class LoginPageState extends State<LoginPage> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const SizedBox(height: 20), const SizedBox(height: 20),
// 1. 品牌Logo/头像区域
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColors.primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.person_rounded,
size: 60,
color: AppColors.primaryColor,
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( const Text(
"录您的聊天账号", "账号",
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -109,7 +96,9 @@ class LoginPageState extends State<LoginPage> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
TextButton( TextButton(
onPressed: () {}, onPressed: () {
appRouter.push("/auth/register");
},
child: const Text( child: const Text(
"注册账号", "注册账号",
style: TextStyle(color: Color(0xFF576B95)), style: TextStyle(color: Color(0xFF576B95)),
@@ -126,6 +115,13 @@ class LoginPageState extends State<LoginPage> {
style: TextStyle(color: Color(0xFF576B95)), style: TextStyle(color: Color(0xFF576B95)),
), ),
), ),
TextButton(
onPressed: () {},
child: const Text(
"找回密码",
style: TextStyle(color: Color(0xFF576B95)),
),
),
], ],
), ),
@@ -0,0 +1,156 @@
import 'package:app/features/auth/pages/register_page.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/constants/app_colors.dart';
class RegisterPageState extends State<RegisterPage> {
@override
Widget build(BuildContext context) {
// TODO: implement build
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
// TextButton(
// onPressed: () {},
// child: const Text("找回密码", style: TextStyle(color: Colors.grey)),
// ),
],
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 20),
const SizedBox(height: 24),
const Text(
"注册账号",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 60),
// 2. 账号输入框 (极简下划线风格)
TextField(
decoration: InputDecoration(
labelText: "用户名",
labelStyle: const TextStyle(color: Colors.grey, fontSize: 14),
floatingLabelStyle: const TextStyle(color: AppColors.primaryColor),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: AppColors.primaryColor, width: 2),
),
),
),
const SizedBox(height: 25),
// 3. 密码输入框
TextField(
obscureText: true,
decoration: InputDecoration(
labelText: "请输入密码",
labelStyle: const TextStyle(color: Colors.grey, fontSize: 14),
floatingLabelStyle: const TextStyle(color: AppColors.primaryColor),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: AppColors.primaryColor, width: 2),
),
),
),
const SizedBox(height: 60),
// 4. 登录按钮 (圆润大按钮)
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text(
"注 册",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
),
),
),
const SizedBox(height: 20),
// 5. 注册/切换登录方式
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () {},
child: const Text(
"已有账号?点我登录",
style: TextStyle(color: Color(0xFF576B95)),
), // 经典的链接蓝
),
const SizedBox(
height: 20,
child: VerticalDivider(color: Colors.grey),
),
TextButton(
onPressed: () {},
child: const Text(
"找回密码",
style: TextStyle(color: Color(0xFF576B95)),
),
),
],
),
const SizedBox(height: 80),
// 6. 底部协议 (社交App必有)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Checkbox(
value: true,
activeColor: AppColors.primaryColor,
onChanged: (v) {},
),
const Text(
"我已阅读并同意",
style: TextStyle(color: Colors.grey, fontSize: 12),
),
const Text(
"《用户协议》",
style: TextStyle(color: Color(0xFF576B95), fontSize: 12),
),
const Text(
"",
style: TextStyle(color: Colors.grey, fontSize: 12),
),
const Text(
"《隐私政策》",
style: TextStyle(color: Color(0xFF576B95), fontSize: 12),
),
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,12 @@
import 'package:app/features/auth/bloc/register_page_state.dart';
import 'package:flutter/cupertino.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<StatefulWidget> createState() {
// TODO: implement createState
return RegisterPageState();
}
}
+78 -30
View File
@@ -5,16 +5,16 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.13.0" version: "2.13.1"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
name: boolean_selector name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
characters: characters:
@@ -22,7 +22,7 @@ packages:
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
clock: clock:
@@ -30,7 +30,7 @@ packages:
description: description:
name: clock name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
collection: collection:
@@ -38,23 +38,39 @@ packages:
description: description:
name: collection name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_icons name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.8" version: "1.0.9"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
flutter: flutter:
@@ -67,7 +83,7 @@ packages:
description: description:
name: flutter_lints name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_test: flutter_test:
@@ -85,15 +101,23 @@ packages:
description: description:
name: go_router name: go_router
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896" sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "17.1.0" version: "17.1.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "11.0.2" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
@@ -101,7 +125,7 @@ packages:
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.0.10" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
@@ -109,7 +133,7 @@ packages:
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
lints: lints:
@@ -117,7 +141,7 @@ packages:
description: description:
name: lints name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging: logging:
@@ -125,7 +149,7 @@ packages:
description: description:
name: logging name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
matcher: matcher:
@@ -133,7 +157,7 @@ packages:
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.12.18" version: "0.12.18"
material_color_utilities: material_color_utilities:
@@ -141,7 +165,7 @@ packages:
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
meta: meta:
@@ -149,15 +173,23 @@ packages:
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
path: path:
dependency: transitive dependency: transitive
description: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
sky_engine: sky_engine:
@@ -170,7 +202,7 @@ packages:
description: description:
name: source_span name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.10.2" version: "1.10.2"
stack_trace: stack_trace:
@@ -178,7 +210,7 @@ packages:
description: description:
name: stack_trace name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.12.1" version: "1.12.1"
stream_channel: stream_channel:
@@ -186,7 +218,7 @@ packages:
description: description:
name: stream_channel name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
string_scanner: string_scanner:
@@ -194,7 +226,7 @@ packages:
description: description:
name: string_scanner name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
term_glyph: term_glyph:
@@ -202,7 +234,7 @@ packages:
description: description:
name: term_glyph name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
test_api: test_api:
@@ -210,15 +242,23 @@ packages:
description: description:
name: test_api name: test_api
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.7.8" version: "0.7.8"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
vm_service: vm_service:
@@ -226,9 +266,17 @@ packages:
description: description:
name: vm_service name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
sdks: sdks:
dart: ">=3.10.4 <4.0.0" dart: ">=3.10.4 <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.35.0"
+1
View File
@@ -38,6 +38,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
go_router: ^17.0.1 go_router: ^17.0.1
dio: ^5.9.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+2 -2
View File
@@ -4,5 +4,5 @@
# VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/ # VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
VITE_API_BASE_URL = http://192.168.5.116:7070/api VITE_API_BASE_URL = http://192.168.5.100:8009/api
VITE_SIGNALR_BASE_URL = http://192.168.5.116:7070/chat/ VITE_SIGNALR_BASE_URL = http://192.168.5.100:8009/chat
+1
View File
@@ -11,6 +11,7 @@
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
}, },
"runtimeArgs": ["--sourcemap"], "runtimeArgs": ["--sourcemap"],
"console": "integratedTerminal",
"env": { "env": {
"REMOTE_DEBUGGING_PORT": "9222" "REMOTE_DEBUGGING_PORT": "9222"
} }
+1 -1
View File
@@ -3,7 +3,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "vscode.typescript-language-features"
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
+573
View File
@@ -0,0 +1,573 @@
# IM_NEW 接口文档
> 源码基线:`IM_NEW/codex/api-alignment-fixes`(起点 `32177a7293ec9eb7bd731467953da3c51e561beb`
> 文档以控制器、DTO、枚举、序列化配置和 Nginx 网关配置为准;“当前实现注意事项”用于标明源码中的实际限制,不代表理想设计。
## 1. 通用约定
### 1.1 网关与路径大小写
- 网关默认端口:`8009`
- HTTP API 前缀:`/api`
- SignalR Hub`/chat`
- Nginx `location` 匹配区分大小写,因此应使用本文给出的全小写控制器前缀,例如 `/api/user/me``/api/User/Me` 在当前网关会返回 404。
### 1.2 认证
除登录、注册以及下文特别标注的接口外,请携带:
```http
Authorization: Bearer <JWT>
```
SignalR 客户端通过 `/chat?access_token=<JWT>` 完成握手;使用官方 SignalR 客户端时由 `accessTokenFactory` 自动添加。
Token 缺失、无效或过期时返回 **HTTP 401**,业务响应为:
```json
{ "code": 1006, "message": "认证失败", "data": null }
```
已认证但权限不足时返回 **HTTP 403**,业务码为 `1005`。新版前端在滚动发布期间仍兼容旧服务的 HTTP 200 + `code=1006`
### 1.3 统一响应
除文件内容下载外,接口通常返回:
```json
{
"code": 0,
"message": "成功",
"data": {}
}
```
- JSON 属性使用 camelCase。
- `code === 0` 表示业务成功。
- 参数校验失败也返回 HTTP 200,业务码为 `1003`
- 枚举通过 `JsonStringEnumConverter` 序列化为字符串,并接受字符串枚举请求值。
- 未处理异常统一返回 HTTP 500 + `code=1000`,响应头 `X-Correlation-ID` 可用于关联服务端日志。
## 2. 认证与用户
### 2.1 POST `/api/auth/login`
无需认证。请求:
```json
{ "userName": "user1", "password": "******" }
```
校验:`userName` 520 字符;`password` 非空且不超过 50 字符。
成功返回 `Result<LoginResponse>`
```json
{
"code": 0,
"message": "成功",
"data": {
"userId": "guid",
"token": "jwt",
"refreshToken": "string",
"expired": null,
"userName": "string",
"nickName": "string",
"avatar": "string|null",
"creationTime": "datetime-offset"
}
}
```
当前实现创建 `LoginResponse` 时将 `expired` 传为 `null`
### 2.2 POST `/api/auth/register`
无需认证。请求:
```json
{ "userName": "newuser", "password": "******", "nickName": "新用户" }
```
校验:`userName` 520 字符;`password` 650 字符;`nickName` 非空且不超过 50 字符。返回 `Result<UserResponse>`
### 2.3 POST `/api/auth/refresh`
无需 Access Token。请求:
```json
{ "refreshToken": "string" }
```
返回新的 `Result<LoginResponse>`
### 2.4 用户接口
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/user/me` | 无 | `Result<UserResponse>` |
| GET | `/api/user/find?userId={guid}` | query: `userId` | `Result<UserResponse>` |
| GET | `/api/user/findByUname?username={value}` | query: `username` | `Result<UserResponse>` |
| POST | `/api/user/update` | body: `UserUpdateRequest` | `Result<UserResponse>` |
| POST | `/api/user/getUsersByIds` | body: GUID 数组 | `Result<UserResponse[]>` |
`UserUpdateRequest` 的字段均可省略:
```json
{
"nickName": "string|null",
"region": "string|null",
"avatar": "string|null",
"description": "string|null"
}
```
`UserResponse`
```json
{
"id": "guid",
"userName": "string",
"nickName": "string",
"email": "string|null",
"phone": "string|null",
"region": "string",
"description": "string",
"avatar": "string|null",
"creationTime": "datetime-offset",
"deletion": "datetime-offset|null"
}
```
## 3. 好友与好友申请
所有接口均要求认证。
### 3.1 好友
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/friend/list` | 无 | `Result<FriendResponse[]>` |
| POST | `/api/friend/delete?friendId={guid}` | 好友关系记录 ID,不是对方用户 ID | `Result<object>`,成功时 `data: null` |
| POST | `/api/friend/block?friendId={guid}` | 好友关系记录 ID | `Result<object>` |
| GET | `/api/friend/checkFriend?userId={guid}&targetId={guid}` | 两个用户 ID | `Result<boolean>` |
`FriendResponse`
```json
{
"id": "friend-relation-guid",
"targetId": "peer-user-guid",
"avatar": "string|null",
"nickName": "string",
"remarkName": "string|null",
"createTime": "datetime",
"updateTime": "datetime|null",
"status": "Pending|Added|Declined|Blocked"
}
```
### 3.2 好友申请
#### POST `/api/friendRequest/add`
```json
{
"targetId": "guid",
"description": "string|null",
"remarkName": "string|null"
}
```
`targetId` 必填。返回 `Result<FriendRequestResponse>`
#### POST `/api/friendRequest/handle`
```json
{
"requestId": "guid",
"action": "Accpet|Reject|Block",
"remarkName": "string|null"
}
```
注意:后端枚举当前拼写为 `Accpet`,接受申请时 `remarkName` 必填。
#### GET `/api/friendRequest/list`
返回与当前用户相关的申请:`Result<FriendRequestResponse[]>`
`FriendRequestResponse.state``Pending``Declined``Passed``Blocked`
## 4. 群组
### 4.1 群信息
| 方法 | 路径 | 请求 | 返回 |
|---|---|---|---|
| GET | `/api/group/getAll` | 无 | `Result<GroupResponse[]>` |
| GET | `/api/group/getOne?groupId={guid}` | query: `groupId` | `Result<GroupResponse>` |
| POST | `/api/group/create` | `{ "name": "string|null" }` | `Result<GroupResponse>` |
| POST | `/api/group/update` | `GroupUpdateRequest` | `Result<GroupResponse>` |
| POST | `/api/group/dissolve?groupId={guid}` | 群 ID;仅群主 | `Result<object>` |
`name` 最大 20 字符。更新请求:
```json
{
"groupId": "guid",
"groupName": "string|null",
"avatar": "string|null",
"description": "string|null"
}
```
`description` 实际用于更新群公告 `announcement`
`GroupResponse`
```json
{
"id": "guid",
"name": "string",
"groupMaster": "guid",
"authority": "REQUIRE_CONSENT|ANYONE_CAN_JOIN|NOT_ALLOWED_TO_JOIN",
"allMembersBanned": false,
"status": "Normal|Blocked",
"announcement": "string",
"avatar": "string|null",
"maxSequenceId": 0,
"lastMessage": "string",
"lastSenderName": "string",
"created": "datetime-offset",
"updated": "datetime-offset"
}
```
`getAll` 按当前用户的有效群成员关系返回群,普通成员、管理员和群主均可看到已加入群。
### 4.2 群成员
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/groupMember/checkMember?userId={guid}&groupId={guid}` | 用户 ID、群 ID | `Result<boolean>` |
| GET | `/api/groupMember/list?groupId={guid}` | 群 ID | `Result<GroupMemberResponse[]>` |
| POST | `/api/groupMember/delete?memberId={guid}` | 群成员记录 ID | `Result<object>` |
| POST | `/api/groupMember/leave?groupId={guid}` | 群 ID | `Result<object>` |
`GroupMemberResponse.role``Normal``Administrator``Master`
`list``delete``leave` 均要求登录。`delete` 是管理操作,只能移除角色低于操作者的其他成员,禁止移除群主或自己;群主必须使用 `dissolve`,不能使用 `leave`
`checkMember` 是服务间内部接口,必须携带 `X-Internal-Api-Key`,不应通过公网网关暴露。
### 4.3 群邀请
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `/api/groupInvitation/send` | body: `{ "groupId": "guid", "userId": "guid" }` | `Result<GroupInvitationResponse>` |
| GET | `/api/groupInvitation/get?invitationId={guid}` | 邀请 ID | `Result<GroupInvitationResponse>` |
| POST | `/api/groupInvitation/handle?invitationId={guid}&action={value}` | `action=Accept|Reject` | `Result<object>` |
邀请状态:`Pending``Passed``Reject`
### 4.4 入群申请
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `/api/groupRequest/send` | body: `{ "groupId": "guid", "desc": "string|null" }` | `Result<GroupRequestResponse>` |
| POST | `/api/groupRequest/handle?requestId={guid}&action={value}` | `action=Accept|Reject` | `Result<object>` |
| GET | `/api/groupRequest/find?id={guid}` | 申请 ID | `Result<GroupRequestResponse>` |
| GET | `/api/groupRequest/list` | 无 | `Result<GroupRequestResponse[]>` |
`desc` 最大 20 字符;状态为 `Pending``Declined``Passed`
`list` 返回当前用户提交的申请,以及当前用户作为管理员或群主有权处理的群申请。
## 5. 会话与消息
所有接口均要求认证。
### 5.1 会话
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/conversation/list` | 无 | `Result<ConversationResponse[]>` |
| GET | `/api/conversation/get?id={guid}` | 会话 ID | `Result<ConversationResponse>` |
| POST | `/api/conversation/markRead?conversationId={guid}` | 会话 ID | `Result<object>` |
`ConversationResponse`
```json
{
"id": "guid",
"userId": "guid",
"targetId": "guid",
"targetAvatar": "string",
"targetName": "string",
"lastReadSequenceId": 0,
"unreadCount": 0,
"chatType": "PRIVATE|GROUP",
"lastMessage": "string",
"dateTime": "datetime"
}
```
### 5.2 POST `/api/message/send`
```json
{
"clientMsgId": "guid",
"targetId": "guid",
"chatType": "PRIVATE|GROUP",
"msgType": "Text|Image|Voice|Video|File|VoiceChat|VideoChat",
"quoteMessageId": "guid|null",
"ext": { "key": "value" },
"text": "string|null",
"url": "string|null",
"width": 0,
"height": 0,
"thumb": "string|null",
"duration": 0,
"fileId": "guid|null",
"fileName": "string|null",
"fileSize": 0,
"fileFormat": "mime/type|null"
}
```
- `clientMsgId``targetId` 必填。
- `Text` 要求 `text`
- `Image``Video``Voice` 至少提供 `url``fileId`
- `File` 要求 `fileId``fileName``fileSize``fileFormat`;服务端已支持构建和保存文件消息。
- `VoiceChat``VideoChat` 仍未实现,发送会返回 `2303`
成功返回 `Result<MessageResponse>`
```json
{
"id": "guid",
"clientMsgId": "guid",
"chatType": "PRIVATE",
"msgType": "Text",
"senderId": "guid",
"targetId": "guid",
"state": "Sent|Withdrwan",
"streamKey": "string",
"sequenceId": 1,
"creationTime": "datetime-offset",
"content": {
"fallback": "string",
"body": {},
"ext": {},
"quote": null
}
}
```
### 5.3 其他消息接口
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `/api/message/withDraw?msgId={guid}` | 消息 ID | `Result<object>` |
| GET | `/api/message/getMessages?conversationId={guid}&cursor={long?}&direction={int}&limit={int}` | 会话、游标、方向、条数 | `Result<GetMessagesResponse>` |
`GetMessagesResponse`
```json
{ "messages": [], "hasmore": false }
```
方向约定:`0` 查询 `SequenceId < cursor` 的历史消息;`1` 查询 `SequenceId > cursor` 的增量消息。两个方向都查询 `limit + 1` 条判断 `hasmore`,但响应最多返回 `limit` 条并按序列号升序;`limit` 范围为 1100。
## 6. 文件服务
所有接口均要求认证。
### 6.1 文件
#### POST `/api/file/simple-upload`
`multipart/form-data`
| 字段 | 类型 | 必填 |
|---|---|---|
| `file` | 文件 | 是 |
| `isPublic` | boolean | 是 |
服务端计算 MD5 并执行安全秒传:公开文件可复用,私有文件只允许同一所有者复用。返回 `Result<FileResponse>`
#### GET `/api/file/{id}`
返回文件信息 `Result<FileResponse>`
#### GET `/api/file/{id}/content`
返回鉴权后的二进制文件流,支持 HTTP Range,不使用 `Result<T>` 包装。无权限返回 HTTP 403,文件不存在返回 HTTP 404。
`FileResponse` 为扁平结构,不暴露存储桶、对象键等内部位置:
```json
{
"id": "guid",
"ownerId": "guid",
"fileName": "avatar.png",
"fileSize": 123,
"contentType": "image/png",
"state": "Uploaded",
"checkSum": "md5-hex",
"chatType": "PRIVATE|GROUP|null",
"targetId": "guid|null",
"isPublic": false,
"created": "datetime-offset",
"updated": "datetime-offset",
"url": "string|null"
}
```
`url` 仅在存储提供方能够生成公开地址时存在。
### 6.2 分片上传
#### POST `/api/fileTask/init`
```json
{
"conversationId": "guid",
"chatType": "PRIVATE|GROUP",
"targetId": "peer-or-group-guid",
"fileName": "string",
"fileSize": 123,
"contentType": "mime/type",
"checkSum": "md5"
}
```
返回:
```json
{
"taskId": "guid",
"uploadSessionId": "string",
"instant": false,
"uploadMode": "LocalMultipart|Presigned",
"totalPartCount": 1,
"partSizeBytes": 5242880,
"file": null
}
```
秒传命中时返回 `instant: true``uploadMode: "Instant"` 和最终 `file`;客户端直接使用该文件,不再调用 `complete`
#### GET `/api/fileTask/getuploadurl?sessionId={value}&partNum={n}`
返回 `Result<PresignedUrl>`,不是字符串:
```json
{
"url": "string",
"method": "PUT|POST",
"headers": {},
"expiresAt": "datetime-offset"
}
```
#### GET `/api/fileTask/progress?sessionId={value}`
```json
{
"sessionId": "string",
"taskId": "string",
"fileSize": 123,
"totalPartCount": 1,
"completedPartCount": 0,
"uploadedBytes": 0,
"progressPercent": 0
}
```
#### POST `/api/fileTask/local/parts/upload`
`multipart/form-data``sessionId``partNumber``file`。返回 `Result<CompleteUploadResult>`,其中包含 `location``eTag``size``checksum``versionId`
#### POST `/api/fileTask/complete`
```json
{
"sessionId": "string",
"parts": [
{ "partNumber": 1, "eTag": "string", "size": 123, "checksum": null }
]
}
```
成功接受异步合并时返回 HTTP 202 + `Result<UploadTaskResponse>`,其中 `state``Merging`
#### GET `/api/fileTask/status?taskId={guid}`
客户端轮询此接口。处理中返回 `Uploading|Merging`;失败返回 `state: "Failed"``failureReason`;完成后返回:
```json
{
"id": "upload-task-guid",
"uploaderId": "guid",
"conversationId": "guid",
"fileName": "string",
"fileSize": 123,
"contentType": "mime/type",
"storageLocation": {},
"state": "Completed",
"checkSum": "string",
"resultFileId": "guid",
"failureReason": null,
"file": { "id": "guid", "fileName": "string", "fileSize": 123, "url": null }
}
```
任务、分片、进度、完成和状态接口都会校验当前用户是上传者。私有文件的 `url` 为 null,使用 `/api/file/{id}/content` 鉴权读取。
## 7. SignalR
- Hub`/chat`
- 服务端事件:`ReceiveNewMessage`
- 当前 Hub 没有 `clearUnreadCount` 方法;清零未读应调用 HTTP `/api/conversation/markRead`
推送载荷与 HTTP `MessageResponse` 略有不同:
```json
{
"id": "guid",
"clientId": "guid",
"chatType": "PRIVATE|GROUP",
"msgType": "Text|Image|Voice|Video",
"senderId": "guid",
"targetId": "guid",
"state": "Sent|Withdrwan",
"streamKey": "string",
"sequenceId": 1,
"pushTimestamp": 0,
"content": {
"fallback": "string",
"body": {},
"ext": {},
"quote": null
}
}
```
## 8. 业务状态码
| 范围/代码 | 含义 |
|---|---|
| `0` | 成功 |
| `1000``1006` | 系统、超时、参数、数据库、权限、认证错误 |
| `2000``2004` | 用户不存在、已存在、密码错误、禁用、登录过期 |
| `2100``2107` | 好友申请、好友关系和操作错误 |
| `2200``2206` | 群不存在、已入群、群满、权限、邀请、申请、成员错误 |
| `2300``2303` | 消息发送、消息不存在、撤回、不支持的消息类型 |
| `2400``2403` | 文件上传、不存在、过大、类型不支持 |
| `3000``3004` | 管理后台错误 |
| `3100` | 会话不存在 |
| `3201``3206` | 分片不存在、合并失败、分片过小/数量不符、会话过期、分片号无效 |
完整名称与中文说明以 `IM.Commons/ResultCode.cs` 为准。
+100
View File
@@ -0,0 +1,100 @@
# 前后端 API 修复实施报告
## 1. 结论与基线
修复计划中的核心阻断项已完成代码落地:认证刷新、会话映射与未读、消息双向分页、群列表/审批/退群/解散、异步分片上传、普通文件消息、私有文件鉴权读取及多环境 CSP 已对齐。
| 项目 | 基线/结果 |
|---|---|
| 后端 | `IM_NEW/codex/api-alignment-fixes`,起点 `32177a7293ec9eb7bd731467953da3c51e561beb` |
| 前端 | `feature-nxdev@f1af6e6` 的现有工作区上增量修改,未覆盖用户原有未提交改动 |
| 后端构建 | `dotnet build IM_API_NEW.sln --no-restore`0 错误 |
| 前端构建 | `npm run build`:通过 |
| 前端测试 | `npm test`:1 个测试文件、3 个上传状态机测试全部通过 |
| 定向 ESLint | 本次涉及的前端文件使用 `npx eslint --quiet`0 错误 |
| 运行态联调 | 待部署迁移并启动 MySQL、Redis、RabbitMQ、Consul 和网关后执行 |
## 2. 已完成修复
### 认证与公共异常
- JWT Challenge/Forbidden 改为标准 HTTP 401/403,保留统一业务响应体。
- 前端同时兼容新版 HTTP 401 和旧版 HTTP 200 + `code=1006`
- Token 刷新采用单飞队列;刷新成功重放请求,失败拒绝全部排队请求并退出登录。
- 全局异常统一返回 HTTP 500 + `SYSTEM_ERROR`,通过 `X-Correlation-ID` 对应服务端日志。
- RabbitMQ 消费增加短间隔重试,降低数据库提交与消息消费竞态造成的偶发失败。
### 会话与消息
- `ConversationResponse.dateTime` 改为 `DateTimeOffset`,映射使用 `ModificationTime ?? CreationTime`
- 会话更新拆分为更新摘要、增加未读和推进已读序号,群聊与私聊均更新参与者会话。
- 会话创建消费者增加幂等判断。
- `direction=0/1` 分别使用 `< cursor``> cursor`,响应裁剪为 `limit` 并保持升序;`limit` 限制为 1100。
- 前端断线补消息携带本地最大 `sequenceId`,IndexedDB 保存完整结构化消息。
### 群组、审批和权限
- 群列表按有效成员关系查询,普通成员也能看到已加入群。
- 入群申请列表包含申请人自己的记录,以及管理员/群主可处理的目标群申请。
- 群详情和成员列表验证当前用户是有效成员。
- 成员移除严格比较角色,禁止移除自己、群主或同级/更高角色。
- 新增 `/api/groupMember/leave``/api/group/dissolve`;群主只能解散,其他成员可退出。
- 退出或解散通过事件软删除对应群会话;前端同步移除群、会话和 IndexedDB 缓存。
- 内部成员检查要求 `X-Internal-Api-Key`MessageService/FileService 使用服务端密钥调用。
### 文件上传与文件消息
- 初始化明确返回 `instant``uploadMode``totalPartCount``partSizeBytes` 和秒传 `file`
- 前端遵循服务端分片大小、预签名 method/headers;对象存储请求不携带业务 JWT。
- 任一分片失败会使整体失败,不再吞错后调用 complete。
- Vitest 覆盖秒传、正常分片异步完成和分片持续失败不得 complete。
- complete 返回 HTTP 202,前端轮询 `/api/fileTask/status`,拿到最终 `fileId` 后才发送消息。
- 消费者以 `SourceTaskId` 幂等创建最终文件,失败写入 `FailureReason`
- 上传地址、分片、进度、完成和状态都校验上传者;秒传按公开/私聊/群聊作用域复用。
- 文件响应扁平化并隐藏内部位置;私有文件通过 `/api/file/{id}/content` 鉴权读取并支持 Range。
- MessageService 支持 `File`,媒体消息支持稳定 `fileId`;前端可预览和下载普通文件消息。
- 小文件上传由服务端计算 MD5,文件名支持 255 字符。
### 环境与部署
- 开发/生产默认网关统一为 `localhost:8009`
- Electron CSP 根据 `VITE_API_BASE_URL``VITE_SIGNALR_BASE_URL` 在构建时生成,不再固定测试网 IP。
- Docker Compose 为 Group/Message/File 服务注入必填的 `IM_INTERNAL_API_KEY`
## 3. 数据库变更与影响面
| 服务 | 迁移 | 影响 |
|---|---|---|
| MessageService | `20260909000100_ApiAlignmentFixes` | 新增会话复合索引;不改消息数据 |
| GroupService | `20260909000200_ApiAlignmentFixes` | 新增成员、角色、申请查询索引和申请 ID 唯一索引 |
| FileService | `20260909000300_AsyncUploadResult` | 增加上传作用域、结果、失败原因、公开标记和来源任务;收紧字符串列并新增索引 |
迁移不物理删除业务数据。文件列由 `longtext` 收紧前,应检查历史值长度;`SourceTaskId` 建唯一索引前,应确认没有重复回填值。
## 4. 发布顺序
1. 备份三个服务数据库,执行历史字段长度与唯一性预检。
2. 设置同一个非空 `IM_INTERNAL_API_KEY`,配置 FileService 的公开桶/公开地址、存储、Redis 和 RabbitMQ 参数。
3. 先发布兼容新旧认证协议的前端。
4. 执行 Message、Group、File 三个迁移并发布后端。
5. 发布新版前端,确认 CSP 只包含当前环境的 API/SignalR 源。
6. 用两个隔离账号执行运行态验收。
## 5. 部署后验收清单
- Token 过期时并发请求只刷新一次;401/403 正确,刷新失败统一退出。
- 会话列表不再 500;私聊/群聊连续消息未读数正确,markRead 清零。
- 两个分页方向无重复、无漏页、每页不超过 limit;断线后只补新消息。
- 普通成员能看到群;群主/管理员能看到待审批申请;退群、解散、越权移人符合规则。
- 本地分片与预签名上传均完成 `init → parts → complete → status`;秒传直接返回最终文件。
- 图片、视频、语音和普通文件可发送、SignalR 接收、历史恢复、预览/下载。
- 私聊第三方不能读取文件;退群成员不能读取群文件;公开头像 URL 非空。
- 重复投递上传完成事件不会创建重复文件。
## 6. 当前保留项
- 本轮未启动完整依赖栈执行真实迁移和双账号端到端联调;这属于发布环境验证,不能由编译结果替代。
- 项目原有全量 ESLint 基线仍有历史问题;本次只保证涉及文件的 `--quiet` 定向检查为 0 错误。
- 后端仓库原先没有自动化测试项目;本轮完成全解决方案编译,数据库/MQ 行为仍以部署后集成验收为准。
- `VoiceChat``VideoChat` 消息仍不在本次实现范围。
- 私有文件采用鉴权内容接口而非暴露短时下载 URL,客户端必须携带 JWT 获取 Blob。
+37 -10
View File
@@ -1,16 +1,43 @@
import { resolve } from 'path' import { resolve } from 'path'
import { defineConfig } from 'electron-vite' import { defineConfig } from 'electron-vite'
import { loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({ export default defineConfig(({ mode }) => {
main: {}, const env = loadEnv(mode, process.cwd(), '')
preload: {}, // 从 VITE_API_BASE_URL 推导网关源(去掉末尾 /api)
renderer: { const apiBase = env.VITE_API_BASE_URL || 'http://localhost:8009/api'
resolve: { const signalRBase = env.VITE_SIGNALR_BASE_URL || 'http://localhost:8009/chat'
alias: { const gatewayOrigin = new URL(apiBase).origin
'@': resolve('src/renderer/src') const signalROrigin = new URL(signalRBase).origin
} const signalRWebSocketOrigin = signalROrigin.replace(/^http/, 'ws')
}, const cspPlugin = {
plugins: [vue()] name: 'environment-csp',
transformIndexHtml: (html) => html
.replaceAll('__API_ORIGIN__', gatewayOrigin)
.replaceAll('__SIGNALR_ORIGIN__', signalROrigin)
.replaceAll('__SIGNALR_WS_ORIGIN__', signalRWebSocketOrigin)
}
return {
main: {},
preload: {},
renderer: {
server: {
host: true,
// 开发环境代理,规避浏览器 CORS(Electron 内不受影响)
proxy: {
'/api': { target: gatewayOrigin, changeOrigin: true },
'/chat': { target: gatewayOrigin, changeOrigin: true, ws: true }
}
},
resolve: {
alias: {
'@': resolve('src/renderer/src')
}
},
plugins: [vue(), vueDevTools(), cspPlugin]
}
} }
}) })
+994 -7
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -11,6 +11,7 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "electron-vite build", "build": "electron-vite build",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win", "build:win": "npm run build && electron-builder --win",
@@ -25,6 +26,7 @@
"@vuelidate/core": "^2.0.3", "@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4", "@vuelidate/validators": "^2.0.4",
"axios": "^1.13.2", "axios": "^1.13.2",
"crypto": "^1.0.1",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"feather-icons": "^4.29.2", "feather-icons": "^4.29.2",
"hevue-img-preview": "^7.1.3", "hevue-img-preview": "^7.1.3",
@@ -47,6 +49,8 @@
"eslint-plugin-vue": "^10.6.2", "eslint-plugin-vue": "^10.6.2",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"vite": "^7.2.6", "vite": "^7.2.6",
"vite-plugin-vue-devtools": "^8.0.7",
"vitest": "^5.0.0",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-eslint-parser": "^10.2.0" "vue-eslint-parser": "^10.2.0"
} }
+13
View File
@@ -0,0 +1,13 @@
import { app } from "electron";
import path from 'path';
export const CACHE_ROOT = path.join(app.getPath('userData'), 'resource_cache')
export const PROTOCOL_HEAD = 'ql-im://'
export const DIRS = {
Image: 'images',
Video: 'videos',
Voice: 'voices',
File: 'files',
}
+35
View File
@@ -0,0 +1,35 @@
import path from "path";
import { FILE_TYPE } from "../renderer/src/constants/fileTypeDefine";
import { DIRS, CACHE_ROOT, PROTOCOL_HEAD } from "./cacheDir";
import crypto from 'crypto'
import fs from 'fs-extra'
import axios from "axios";
export const getCacheResorce = async (url, type = FILE_TYPE.Image) => {
const hash = crypto.createHash('md5').update(url).digest('hex')
const subDir = hash.substring(0,2);
const targetPath = path.join(DIRS[type], subDir)
const filePath = path.join(targetPath, hash)
if(await fs.pathExists(path.join(CACHE_ROOT, filePath))){
return PROTOCOL_HEAD + filePath.replaceAll('\\', '/')
}
await fs.ensureDir(path.join(CACHE_ROOT, targetPath))
const writer = fs.createWriteStream(path.join(CACHE_ROOT, filePath))
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(PROTOCOL_HEAD + filePath.replaceAll('\\', '/')));
writer.on('error', reject);
});
}
+13
View File
@@ -0,0 +1,13 @@
import { net, protocol } from 'electron'
import { CACHE_ROOT } from './cacheDir'
import path from 'path'
export const addProtocolHandler = () => {
protocol.handle('ql-im', (request) => {
const url = request.url.replace('ql-im://', '')
const filePath = path.join(CACHE_ROOT, url.replaceAll('/', '\\'))
return net.fetch(`file://${filePath}`)
})
}
+58 -7
View File
@@ -1,22 +1,28 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron' import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path' import path from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { registerWindowHandler } from './ipcHandlers/window' import { registerWindowHandler } from './ipcHandlers/window'
import { createTry } from './trayHandler' import { createTry } from './trayHandler'
import { registerCacheHandler } from './ipcHandlers/cache'
import { addProtocolHandler } from '../cache/protocolReg'
import { CACHE_ROOT } from '../cache/cacheDir'
import fs from 'fs-extra'
let mainWindow = null
function createWindow() { function createWindow() {
// Create the browser window. // Create the browser window.
const mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 900, width: 900,
height: 670, height: 670,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
frame:false, frame:false,
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设 ...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
icon: join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览 icon: path.join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: path.join(__dirname, '../preload/index.js'),
sandbox: false sandbox: false
} }
}) })
@@ -24,7 +30,7 @@ function createWindow() {
createTry(mainWindow); createTry(mainWindow);
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
mainWindow.show() // mainWindow.show()
}) })
@@ -38,7 +44,7 @@ function createWindow() {
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else { } else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')) mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
} }
} }
@@ -49,6 +55,8 @@ app.whenReady().then(() => {
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId('com.electron') electronApp.setAppUserModelId('com.electron')
addProtocolHandler()
// Default open or close DevTools by F12 in development // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -59,7 +67,50 @@ app.whenReady().then(() => {
// IPC test // IPC test
ipcMain.on('ping', () => console.log('pong')) ipcMain.on('ping', () => console.log('pong'))
registerWindowHandler() // 开机自启
ipcMain.on('setting-autoStart', (_event, enable) => {
app.setLoginItemSettings({ openAtLogin: !!enable })
})
// 清理磁盘文件缓存
ipcMain.handle('cache-clear-disk', async () => {
try {
await fs.emptyDir(CACHE_ROOT)
return { success: true }
} catch (e) {
return { success: false, error: e.message }
}
})
// 获取磁盘缓存大小
ipcMain.handle('cache-disk-size', async () => {
try {
let total = 0
const walk = async (dir) => {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const e of entries) {
const p = path.join(dir, e.name)
if (e.isDirectory()) { await walk(p) }
else { try { total += (await fs.stat(p)).size } catch { /* Ignore files removed during traversal. */ } }
}
}
if (await fs.pathExists(CACHE_ROOT)) await walk(CACHE_ROOT)
return { success: true, size: total }
} catch {
return { success: false, size: 0 }
}
})
// 新消息任务栏/托盘闪动
ipcMain.on('window-flash', () => {
if (mainWindow && !mainWindow.isFocused()) {
mainWindow.flashFrame(true)
// 也可以设置托盘图标高亮
}
})
registerWindowHandler()
registerCacheHandler()
createWindow() createWindow()
@@ -0,0 +1,8 @@
import { ipcMain } from "electron";
import { getCacheResorce } from "../../cache/cacheHandler";
export function registerCacheHandler(){
ipcMain.handle('cache-get', (event, url, type) => {
return getCacheResorce(url, type)
})
}
+13 -5
View File
@@ -6,7 +6,8 @@ import { is } from '@electron-toolkit/utils'
export function registerWindowHandler() { export function registerWindowHandler() {
const windowMapData = new Map() const windowMapData = new Map()
ipcMain.on('window-action', (event, action) => { //**窗口控件操作 */
ipcMain.on('window-action', (event, action, data) => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return if (!win) return
const actions = { const actions = {
@@ -20,14 +21,21 @@ export function registerWindowHandler() {
win.destroy() win.destroy()
} }
}, },
isMaximized: () => win.isMaximized() isMaximized: () => win.isMaximized(),
changeSize: () => {
win.setSize(data.width, data.height, true)
win.setResizable(data.resizable)
win.center()
},
show: () => win.show()
} }
actions[action]?.() actions[action]?.()
}) })
ipcMain.on('window-new', (event, { route, data }) => { /**新开窗口 */
ipcMain.on('window-new', (event, { route, data, width = 900, height=670 }) => {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 900, width: width,
height: 670, height: height,
show: true, show: true,
autoHideMenuBar: true, autoHideMenuBar: true,
frame: false, frame: false,
-1
View File
@@ -1,6 +1,5 @@
import { app, Tray, Menu, nativeImage } from 'electron' import { app, Tray, Menu, nativeImage } from 'electron'
import path from 'path' import path from 'path'
import { useRouter } from 'vue-router';
let tray = null; let tray = null;
+11 -2
View File
@@ -8,9 +8,18 @@ const api = {
maximize: () => ipcRenderer.send('window-action', 'maximize'), maximize: () => ipcRenderer.send('window-action', 'maximize'),
close: () => ipcRenderer.send('window-action', 'close'), close: () => ipcRenderer.send('window-action', 'close'),
closeThis: () => ipcRenderer.send('window-action', 'closeThis'), closeThis: () => ipcRenderer.send('window-action', 'closeThis'),
show: () => ipcRenderer.send('window-action', 'show'),
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'), isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }), newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }),
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId) getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId),
setMainSize: (width, height, resizable = true) =>
ipcRenderer.send('window-action', 'changeSize', { width, height, resizable }),
flash: () => ipcRenderer.send('window-flash')
},
cache: {
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type),
clearDisk: () => ipcRenderer.invoke('cache-clear-disk'),
diskSize: () => ipcRenderer.invoke('cache-disk-size')
} }
} }
+4 -4
View File
@@ -5,13 +5,13 @@
<title>Electron</title> <title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; content="default-src 'self' ql-im:;
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
connect-src 'self' http://localhost:5202 ws://localhost:5202 http://192.168.5.116:7070 ws://192.168.5.116:7070; connect-src 'self' __API_ORIGIN__ __SIGNALR_ORIGIN__ __SIGNALR_WS_ORIGIN__;
img-src 'self' data: blob: https: http:; img-src 'self' data: blob: https: http: ql-im:;
font-src 'self' data:; font-src 'self' data:;
media-src 'self' blob:;"> media-src 'self' blob: __API_ORIGIN__ ql-im:">
</head> </head>
<body> <body>
-1
View File
@@ -8,7 +8,6 @@
<script setup> <script setup>
import Alert from '@/components/messages/Alert.vue'; import Alert from '@/components/messages/Alert.vue';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
//import { useSignalRStore } from './stores/signalr'; //import { useSignalRStore } from './stores/signalr';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,35 @@
<template>
<img :src="src" :class="$attrs.class" v-bind="filteredAttrs" @error="onErr" />
</template>
<script setup>
import { computed, ref, useAttrs, watch } from 'vue';
import default_avatar from '@/assets/default_avatar.png';
import loading_img from '@/assets/loading_img.png';
const attrs = useAttrs();
const filteredAttrs = computed(() => {
const rest = { ...attrs };
delete rest.class;
return rest;
});
const props = defineProps({
rawUrl: { type: [String, null], default: null },
noAvatar: { type: Boolean, default: false }
});
const fallbackImg = props.noAvatar ? loading_img : default_avatar;
const error = ref(false);
const src = computed(() => {
if (error.value) return props.noAvatar ? loading_img : default_avatar;
if (props.rawUrl && props.rawUrl !== '') {
return props.rawUrl;
}
return fallbackImg;
});
const onErr = () => { error.value = true; };
watch(() => props.rawUrl, () => { error.value = false; });
</script>
@@ -0,0 +1,252 @@
<template>
<div class="v-ui-dropdown" ref="dropdownRef">
<button
type="button"
class="v-ui-dropdown-toggle"
:class="{ 'is-open': isOpen }"
@click.stop="toggleDropdown"
role="button"
aria-haspopup="listbox"
:aria-expanded="isOpen"
:disabled="disable"
>
<span class="v-ui-selected-text">{{ selectedLabel }}</span>
<span class="v-ui-arrow-icon" :class="{ 'v-ui-arrow-up': isOpen }">
<svg viewBox="0 0 1024 1024" width="1em" height="1em">
<path d="M831.872 340.864L512 652.672 192.128 340.864a31.936 31.936 0 0 0-45.248 0 32 32 0 0 0 0 45.248l342.144 333.76a31.936 31.936 0 0 0 45.248 0l342.144-333.76a32 32 0 0 0-45.248-45.248z" fill="currentColor"></path>
</svg>
</span>
</button>
<transition name="v-ui-dropdown-grow">
<ul
v-show="isOpen"
class="v-ui-dropdown-menu"
role="listbox"
:aria-activedescendant="modelValue"
>
<li
v-for="option in options"
:key="option.value"
class="v-ui-dropdown-item"
:class="{ 'is-selected': option.value === modelValue }"
@click.stop="selectOption(option)"
role="option"
:aria-selected="option.value === modelValue"
>
<span class="v-ui-item-label">{{ option.label }}</span>
<span v-if="option.value === modelValue" class="v-ui-check-icon">
<svg viewBox="0 0 1024 1024" width="1em" height="1em">
<path d="M358.4 716.8l-204.8-204.8-51.2 51.2 256 256 512-512-51.2-51.2-460.8 460.8z" fill="currentColor"></path>
</svg>
</span>
</li>
</ul>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
// 定义 Props (逻辑未变)
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
options: {
type: Array,
required: true,
// 期望格式: [{ label: '选项一', value: 1 }, { label: '选项二', value: 2 }]
},
placeholder: {
type: String,
default: '请选择...'
},
disable: {
type: Boolean,
default: false
}
})
// 定义 Emits (支持 v-model, 逻辑未变)
const emit = defineEmits(['update:modelValue', 'change'])
const isOpen = ref(false)
const dropdownRef = ref(null)
// 计算当前选中的文本 (逻辑未变)
const selectedLabel = computed(() => {
const selected = props.options.find(opt => opt.value === props.modelValue)
return selected ? selected.label : props.placeholder
})
// 切换下拉菜单状态 (逻辑未变)
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
// 选中选项 (逻辑未变)
const selectOption = (option) => {
emit('update:modelValue', option.value)
emit('change', option)
isOpen.value = false
}
// 点击组件外部区域关闭菜单 (逻辑未变)
const handleClickOutside = (event) => {
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
isOpen.value = false
}
}
// 挂载和卸载全局点击事件监听 (逻辑未变)
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
/* 使用加强型前缀和特异性选择器防止污染 */
.v-ui-dropdown {
position: relative; /* 强制相对定位 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
display: inline-block; /* 防止父容器布局冲突 */
}
/* 针对 ul 和 li 进行强制 reset,防止全局样式干扰 */
.v-ui-dropdown ul {
list-style: none;
padding: 0;
margin: 0;
}
.v-ui-dropdown li {
list-style: none;
}
/* 触发按钮:白底、靛蓝色边框的现代 Filled 风格 */
.v-ui-dropdown-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 16px;
background-color: #007aff; /* 强制微灰底色,与纯白背景区分 */
border: 1px solid #e4e4e7; /* 浅灰边框,避免融合 */
border-radius: 12px;
color: #000000; /* 极深灰 */
font-weight: 500;
cursor: pointer;
user-select: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.v-ui-dropdown-toggle:hover {
background-color: #cacaff;
border-color: #d1d5db;
}
/* 展开状态下 */
.v-ui-dropdown-toggle.is-open {
background-color: #cacaff;
border-color: #4f46e5; /* 靛蓝色主色 */
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); /* Focus 环 */
}
.v-ui-selected-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 10px;
text-align: left;
}
.v-ui-arrow-icon {
display: flex;
font-size: 12px;
color: #000000;
transition: transform 0.3s ease, color 0.2s ease;
}
.v-ui-arrow-up {
transform: rotate(180deg);
color: #4f46e5;
}
/* 下拉菜单:纯白底色,强化悬浮阴影 */
.v-ui-dropdown .v-ui-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin-top: 8px;
padding: 6px;
background-color: #ffffff;
border: 1px solid #e4e4e7;
border-radius: 12px;
/* 强阴影是白色背景上脱颖而出的秘诀 */
box-shadow: 0 12px 32px -4px rgba(0, 0, 0, 0.12), 0 4px 12px -4px rgba(0, 0, 0, 0.08);
z-index: 1000; /* 确保在最上层 */
max-height: 240px;
overflow-y: auto;
}
/* 菜单项样式 */
.v-ui-dropdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
color: #18181b;
transition: all 0.2s ease;
margin-bottom: 2px; /* Item 呼吸感 */
}
.v-ui-dropdown-item:hover {
background-color: #f4f4f5;
}
/* 选中项的样式 */
.v-ui-dropdown-item.is-selected {
background-color: #eef2ff; /* 极淡的靛蓝色 */
color: #4f46e5;
font-weight: 600;
}
.v-ui-item-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 10px;
}
.v-ui-check-icon {
display: flex;
font-size: 14px;
color: #4f46e5;
}
/* 过渡动画 */
.v-ui-dropdown-grow-enter-active,
.v-ui-dropdown-grow-leave-active {
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.2, 0, 0, 1);
transform-origin: top center;
}
.v-ui-dropdown-grow-enter-from,
.v-ui-dropdown-grow-leave-to {
opacity: 0;
transform: scaleY(0.95) translateY(-8px);
}
</style>
@@ -31,7 +31,7 @@ export default {
</script> </script>
<script setup> <script setup>
import { defineProps, useAttrs, onMounted } from 'vue'; import { defineProps, useAttrs } from 'vue';
const props = defineProps({ const props = defineProps({
// 按钮样式变体:primary, secondary, danger, text // 按钮样式变体:primary, secondary, danger, text
@@ -184,4 +184,4 @@ const attrs = useAttrs()
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
</style> </style>
@@ -5,7 +5,7 @@
<rect width="10" height="1" fill="currentColor" /> <rect width="10" height="1" fill="currentColor" />
</svg> </svg>
</button> </button>
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原"> <button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原" :disabled="!props.resizable">
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"> <svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
<rect x="1.5" y="1.5" width="7" height="7" /> <rect x="1.5" y="1.5" width="7" height="7" />
</svg> </svg>
@@ -14,7 +14,7 @@
<rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" /> <rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" />
</svg> </svg>
</button> </button>
<button class="control-btn close" @click="close" title="关闭"> <button class="control-btn close" @click="close" title="关闭" >
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2" <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"
stroke-linecap="round"> stroke-linecap="round">
<path d="M1 1L9 9M9 1L1 9" /> <path d="M1 1L9 9M9 1L1 9" />
@@ -27,11 +27,19 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref, defineEmits, defineProps } from 'vue'
import { isElectron } from '../utils/electronHelper' import { isElectron } from '../utils/electronHelper'
const isMaximized = ref(false) const isMaximized = ref(false)
const emits = defineEmits(['close'])
const props = defineProps({
resizable: {
type: Boolean,
default: true
}
})
function minimize() { function minimize() {
window.api.window.minimize(); window.api.window.minimize();
} }
@@ -41,6 +49,8 @@ function toggleMaximize() {
} }
function close() { function close() {
window.api.window.close() window.api.window.close()
emits('close')
emits('close')
} }
</script> </script>
@@ -20,7 +20,7 @@
</template> </template>
<script setup> <script setup>
import { ref, defineProps, onMounted, defineEmits } from 'vue'; import { ref, defineProps, defineEmits } from 'vue';
const props = defineProps({ const props = defineProps({
menuList: { menuList: {
@@ -177,4 +177,4 @@ const vClickOutside = {
font-size: 13px; /* 稍微增大字号,更容易阅读 */ font-size: 13px; /* 稍微增大字号,更容易阅读 */
font-weight: 500; /* 增加字重,更有质感 */ font-weight: 500; /* 增加字重,更有质感 */
} }
</style> </style>
@@ -1,71 +1,90 @@
<template> <template>
<div v-for="c in props.contacts" <div v-for="c in props.contacts" :key="c.id" class="list-item" :class="{ active: activeContactId === c.id }"
:key="c.id" @click="routeUserInfo(c.id)">
class="list-item" <AsyncImage :raw-url="c.avatar" class="avatar-std" />
:class="{active: activeContactId === c.id}" <div class="info">
@click="routeUserInfo(c.id)"> <div class="name">{{ c.remarkName || c.nickName }}</div>
<img :src="c.userInfo.avatar" class="avatar-std" /> </div>
<div class="info"> </div>
<div class="name">{{ c.remarkName }}</div> <div v-if="!props.contacts || props.contacts.length === 0" class="empty-placeholder">
</div> <i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
</div> <p class="empty-text">暂无好友</p>
</div>
</template> </template>
<script setup> <script setup>
import { defineProps, ref } from 'vue'; import { defineProps, computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import feather from 'feather-icons';
import AsyncImage from '../AsyncImage.vue';
const router = useRouter() const router = useRouter()
const route = useRoute()
const activeContactId = ref(null) const activeContactId = computed(() => route.params.id)
const props = defineProps({ const props = defineProps({
contacts: { contacts: {
type:String, type: Array,
required: true default: () => []
} }
}) })
const routeUserInfo = (id) => { const routeUserInfo = (id) => {
router.push(`/contacts/info/${id}`); router.push(`/contacts/info/${id}`);
activeContactId.value = id;
} }
</script> </script>
<style scoped> <style scoped>
.list-item { .empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 20px;
color: #bbb;
}
.empty-placeholder i { color: #ccc; margin-bottom: 10px; line-height: 0; }
.empty-text { font-size: 13px; color: #999; margin: 0; }
.list-item {
display: flex; display: flex;
padding: 10px 12px; padding: 10px 12px;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
text-decoration: none; /* 去除下划线 */ text-decoration: none;
color: inherit; /* 继承父元素的文本颜色 */ /* 去除下划线 */
outline: none; /* 去除点击时的蓝框 */ color: inherit;
-webkit-tap-highlight-color: transparent; /* 移动端点击高亮 */ /* 继承父元素的文本颜色 */
outline: none;
/* 去除点击时的蓝框 */
-webkit-tap-highlight-color: transparent;
/* 移动端点击高亮 */
} }
/* 去除 hover、active 等状态的效果 */ /* 去除 hover、active 等状态的效果 */
a:hover, a:hover,
a:active, a:active,
a:focus { a:focus {
text-decoration: none; text-decoration: none;
color: inherit; /* 保持颜色不变 */ color: inherit;
/* 保持颜色不变 */
cursor: pointer; cursor: pointer;
} }
.list-item:hover { background: #e2e2e2; } .list-item:hover {
.list-item.active { background: #c6c6c6; } background: #e2e2e2;
.avatar-std {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
} }
.list-item.active {
background: #c6c6c6;
}
:deep(.avatar-std) { width: 36px; height: 36px; border-radius: 4px; flex-shrink: 0; }
.icon-box { .icon-box {
width: 36px; width: 36px;
height: 36px; height: 36px;
@@ -76,7 +95,16 @@ a:focus {
color: #fff; color: #fff;
font-size: 16px; font-size: 16px;
} }
.icon-box.orange { background: #faad14; }
.icon-box.green { background: #52c41a; } .icon-box.orange {
.icon-box.blue { background: #1890ff; } background: #faad14;
</style> }
.icon-box.green {
background: #52c41a;
}
.icon-box.blue {
background: #1890ff;
}
</style>
@@ -1,12 +1,12 @@
<template> <template>
<WindowControls/> <div></div>
</template> </template>
<script setup> <script setup>
import { previewImages } from 'hevue-img-preview/v3' import { previewImages } from 'hevue-img-preview/v3'
import {onMounted, ref } from 'vue'; import {onMounted, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import WindowControls from '../WindowControls.vue'; // import WindowControls from '../WindowControls.vue';
const route = useRoute(); const route = useRoute();
@@ -20,7 +20,7 @@ onMounted(async () => {
imageList.value = data.imageList; imageList.value = data.imageList;
index.value = data.index; index.value = data.index;
previewImages({ previewImages({
imgList: imageList.value.map(m => m.content.url), imgList: imageList.value.map(m => m.url || (m.content?.body?.url) || m.localUrl),
nowImgIndex: index, nowImgIndex: index,
clickMaskCLose: false, clickMaskCLose: false,
disabledImgRightClick:true, disabledImgRightClick:true,
@@ -0,0 +1,148 @@
<template>
<Teleport to="body">
<div class="video-overlay">
<div class="video-dialog" :style="isElectron() ? 'width:100vw;height:100vh' : ''">
<WindowControls v-if="isElectron()" @close="windowCloseHandler"/>
<div class="close-bar" v-if="!isElectron()">
<span>正在播放视频</span>
<button class="close-btn" @click="webCloseHandler">&times;</button>
</div>
<div class="player-wrapper" :class="{'electron-play-container': isElectron()}">
<vue3-video-player
v-if="videoLoaded"
:src="videoInfo"
poster="https://xxx.jpg"
:controls="true"
:autoplay="true"
/>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { onMounted, ref, defineEmits } from 'vue';
import { useRoute } from 'vue-router';
import { isElectron } from '../../utils/electronHelper';
import WindowControls from '../WindowControls.vue';
const props = defineProps({
videoData: {
type: String
}
})
const emits = defineEmits(['close'])
const route = useRoute();
const videoInfo = ref(null);
const videoLoaded = ref(false)
const winId = ref(null)
const windowCloseHandler = () => {
window.api.window.closeThis()
}
const webCloseHandler = () => {
emits('close')
}
onMounted(async () => {
if (isElectron()) {
winId.value = route.query.winId;
const data = await window.api.window.getWindowData(winId.value);
videoInfo.value = data;
videoLoaded.value = true;
}else{
videoInfo.value = props.videoData
videoLoaded.value = true;
}
});
</script>
<style scoped>
/* 遮罩层:全屏、黑色半透明、固定定位 */
.video-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
/* 确保在最顶层 */
}
/* 播放器弹窗主体 */
.video-dialog {
position: relative;
width: 90%;
/* max-width: 1000px; */
background: #000;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
/* 顶部状态栏(包含关闭按钮) */
.close-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
background: #1a1a1a;
color: #eee;
font-size: 14px;
}
.close-btn {
background: none;
border: none;
color: #fff;
font-size: 28px;
cursor: pointer;
line-height: 1;
transition: transform 0.2s;
}
.close-btn:hover {
transform: scale(1.2);
color: #ff4d4f;
}
.player-wrapper {
width: 100%;
aspect-ratio: 16 / 9;
/* 锁定 16:9 比例 */
background: #000;
}
/* 进场动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.electron-play-container {
height: calc(100vh - 30px);
}
</style>
@@ -1,41 +1,46 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, watch } from 'vue';
import { useContactStore } from '@/stores/contact'; import { friendService } from '../../services/friend';
import { groupService } from '@/services/group'; import AsyncImage from '../AsyncImage.vue';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import { useMessage } from '../messages/useAlert';
const contactStore = useContactStore(); const props = defineProps({
const message = useMessage(); modelValue: Boolean,
type: { type: String, default: 'CreateGroup' },
const props = defineProps({ modelValue: Boolean }); title: { type: String, default: '创建群聊' },
/** 已在群的 userId 数组 */
const friends = ref([]) excludeIds: { type: Array, default: () => [] }
const groupName = ref('');
const selected = ref(new Set()); // 使用 Set 处理选中逻辑更简洁
const toggle = (id) => {
selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id);
};
const submit = async () => {
const res = await groupService.createGroup({
name: groupName.value,
avatar: "https://baidu.com",
userIDs: [...selected.value]
}); });
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){ const emits = defineEmits(['submit']);
message.show('群聊创建成功。');
}else{
message.error(res.message);
}
};
onMounted(async () =>{ const friends = ref([]);
friends.value = contactStore.contacts; const groupName = ref('');
}) const selected = ref(new Set());
// 纯同步过滤
const excludeSet = computed(() => new Set(props.excludeIds || []));
const available = computed(() =>
(friends.value || []).filter(f => !excludeSet.value.has(f.targetId))
);
const toggle = (id) => { selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id); };
const submit = () => emits('submit', selected.value, groupName.value);
watch(() => props.modelValue, async (v) => {
if (!v) return;
selected.value = new Set();
friends.value = [];
try {
const res = await friendService.getFriendList();
friends.value = (res.data || []).map(f => ({
...f,
friendId: f.targetId,
avatar: f.avatar,
nickName: f.remarkName || f.nickName,
}));
} catch { /* ignore */ }
});
</script> </script>
<template> <template>
@@ -43,25 +48,26 @@ onMounted(async () =>{
<div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)"> <div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)">
<div class="mini-modal"> <div class="mini-modal">
<header> <header>
<span>发起群聊</span> <span>{{ props.title }}</span>
<button @click="$emit('update:modelValue', false)"></button> <button @click="$emit('update:modelValue', false)"></button>
</header> </header>
<main> <main>
<input v-model="groupName" placeholder="群组名称..." class="mini-input" /> <input v-if="props.type == 'CreateGroup'" v-model="groupName" placeholder="群组名称..." class="mini-input" />
<div class="list"> <div class="list">
<div v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item"> <div v-for="f in available" :key="f.friendId" class="item" @click="toggle(f.friendId)">
<img :src="f.userInfo.avatar" class="avatar" /> <AsyncImage :raw-url="f.avatar" class="avatar" />
<span class="name">{{ f.remarkName }}</span> <span class="name">{{ f.nickName }}</span>
<input type="checkbox" :checked="selected.has(f.friendId)" /> <input type="checkbox" :checked="selected.has(f.friendId)" />
</div> </div>
<div v-if="available.length === 0 && friends.length > 0" class="empty-hint">暂无可邀请的好友</div>
<div v-if="friends.length === 0" class="empty-hint">加载中...</div>
</div> </div>
</main> </main>
<footer> <footer>
<button @click="submit" :disabled="!groupName || !selected.size" class="btn"> <button @click="submit" :disabled="(!groupName && props.type == 'CreateGroup') || !selected.size" class="btn">
创建 ({{ selected.size }}) {{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
</button> </button>
</footer> </footer>
</div> </div>
@@ -70,44 +76,19 @@ onMounted(async () =>{
</template> </template>
<style scoped> <style scoped>
.overlay { .overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
position: fixed; inset: 0; background: rgba(0,0,0,0.4); .mini-modal { background: white; width: 300px; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
display: flex; align-items: center; justify-content: center; z-index: 999; header { padding: 12px 16px; display: flex; justify-content: space-between; background: #f9f9f9; font-weight: bold; font-size: 14px; }
}
.mini-modal {
background: white; width: 300px; border-radius: 12px; overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
header {
padding: 12px 16px; display: flex; justify-content: space-between;
background: #f9f9f9; font-weight: bold; font-size: 14px;
}
header button { background: none; border: none; cursor: pointer; color: #999; } header button { background: none; border: none; cursor: pointer; color: #999; }
main { padding: 12px; } main { padding: 12px; }
.mini-input { width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee; border-radius: 4px; box-sizing: border-box; outline: none; }
.mini-input {
width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee;
border-radius: 4px; box-sizing: border-box; outline: none;
}
.list { max-height: 200px; overflow-y: auto; } .list { max-height: 200px; overflow-y: auto; }
.item { display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px; }
.item {
display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px;
}
.item:hover { background: #f5f5f5; } .item:hover { background: #f5f5f5; }
:deep(.avatar) { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; flex-shrink: 0; }
.avatar { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; }
.name { flex: 1; font-size: 14px; } .name { flex: 1; font-size: 14px; }
.empty-hint { text-align: center; padding: 20px; color: #999; font-size: 13px; }
footer { padding: 12px; } footer { padding: 12px; }
.btn { .btn { width: 100%; padding: 10px; background: #07c160; color: white; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; }
width: 100%; padding: 10px; background: #07c160; color: white;
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
}
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; } .btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
</style> </style>
@@ -1,32 +1,47 @@
<template> <template>
<div class="pure-group-list"> <div class="pure-group-list">
<div <div
v-for="group in groups" v-for="group in groups"
:key="group.id" :key="group.id"
class="group-item" class="group-item"
:class="{ active: activeId === group.id }" :class="{ active: activeGroupId === group.id }"
@click="activeId = group.id; $emit('select', group)" @click="routeGroupInfo(group)"
> >
<img :src="group.avatar" class="group-avatar" /> <img :src="group.avatar" class="group-avatar" />
<span class="group-name">{{ group.name }}</span> <span class="group-name">{{ group.name }}</span>
</div> </div>
<div v-if="!groups || groups.length === 0" class="empty-placeholder">
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
<p class="empty-text">暂无群聊</p>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import feather from 'feather-icons';
defineProps({ defineProps({
groups: { groups: {
type: Array, type: Array,
default: () => [] default: () => []
// 数据结构仅需: { id, name, avatar } // 数据结构: { id, name, avatar }
} }
}); });
const emit = defineEmits(['select']); const emit = defineEmits(['select']);
const activeId = ref(null); const router = useRouter();
const route = useRoute();
const activeGroupId = computed(() => route.params.id);
const routeGroupInfo = (group) => {
emit('select', group);
router.push(`/contacts/group/${group.id}`);
};
</script> </script>
<style scoped> <style scoped>
@@ -68,6 +83,16 @@ const activeId = ref(null);
background-color: #f0f0f0; background-color: #f0f0f0;
} }
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 20px;
color: #bbb;
}
.empty-placeholder i { color: #ccc; margin-bottom: 10px; line-height: 0; }
.empty-text { font-size: 13px; color: #999; margin: 0; }
.group-name { .group-name {
flex: 1; flex: 1;
font-size: 14px; font-size: 14px;
@@ -1,51 +1,56 @@
<template> <template>
<transition name="slide"> <aside class="group-info-sidebar">
<aside class="group-info-sidebar">
<div class="sidebar-scroll-content"> <div class="sidebar-scroll-content">
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card header-section"> <section v-if="chatType == CHAT_TYPE.GROUP" class="info-card header-section">
<div class="avatar-wrapper"> <div class="avatar-wrapper">
<img :src="groupData.targetAvatar" class="group-main-avatar" /> <input type="file" style="display: none;" ref="input" @change="fileUploadHandler">
<img :src="groupData.targetAvatar" class="group-main-avatar" @click="uploadGroupAvatar"/>
<div class="edit-badge" v-if="isAdmin" v-html="feather.icons['camera'].toSvg({width:15, height: 15})"> <div class="edit-badge" v-if="isAdmin" v-html="feather.icons['camera'].toSvg({width:15, height: 15})">
</div> </div>
</div> </div>
<h2 class="group-name">{{ groupData.targetName }}</h2> <h2 class="group-name">{{ groupData.targetName }}</h2>
<p class="group-id">群ID: {{ groupData.id }}</p> <p class="group-id">群ID: {{ groupData.targetId }}</p>
</section> </section>
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card"> <section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
<div class="section-header"> <div class="section-header">
<h3 class="section-label">群公告</h3> <h3 class="section-label">群公告</h3>
<button v-if="isAdmin" class="text-link">编辑</button> <button v-if="isAdmin" class="text-link" @click="editingAnnouncement = !editingAnnouncement">{{ editingAnnouncement ? '完成' : '编辑' }}</button>
</div> </div>
<div class="announcement-box"> <div v-if="editingAnnouncement" class="announcement-edit">
{{ groupData.announcement || '暂无群公告,点击编辑添加。' }} <textarea v-model="announcementText" class="announce-input" rows="3" placeholder="输入群公告..."></textarea>
<button class="save-announce-btn" @click="saveAnnouncement">保存公告</button>
</div>
<div v-else class="announcement-box">
{{ groupInfo?.announcement || '暂无群公告,点击编辑添加。' }}
</div> </div>
</section> </section>
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card"> <section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
<div class="section-header"> <div class="section-header">
<h3 class="section-label">群成员 <span class="count-tag">{{ groupData.members?.length || 0 }}</span></h3> <h3 class="section-label">群成员 <span class="count-tag">{{ groupInfo.members?.length || 0 }}</span></h3>
<button class="text-link" @click="$emit('viewAll')">查看全部</button> <button class="text-link" @click="showAllMembers = !showAllMembers">{{ showAllMembers ? '收起' : '查看全部' }}</button>
</div> </div>
<div class="member-grid"> <div class="member-grid">
<div class="member-item add-btn"> <div class="member-item add-btn">
<div class="member-avatar-box dashed"> <div class="member-avatar-box dashed" @click="inviteHandler">
<span>+</span> <span>+</span>
</div> </div>
<span class="member-nick">邀请</span> <span class="member-nick">邀请</span>
</div> </div>
<div <div
v-for="member in groupData.members?.slice(0, 11)" v-for="member in (showAllMembers ? groupInfo.members : groupInfo.members?.slice(0, 11))"
:key="member.id" :key="member.id"
class="member-item" class="member-item"
@click="handleMemberClick(member)"
> >
<div class="member-avatar-box"> <div class="member-avatar-box">
<img :src="member.avatar" class="member-img" /> <async-image :raw-url="member.avatar" class="member-img"/>
<span v-if="member.role === 'admin'" class="role-badge"></span> <span v-if="member.role === GROUP_MEMBER_ROLE.ADMIN || member.role === GROUP_MEMBER_ROLE.MASTER" class="role-badge"></span>
</div> </div>
<span class="member-nick">{{ member.nickname }}</span> <span class="member-nick">{{ member.nickname || member.groupNickName }}</span>
</div> </div>
</div> </div>
</section> </section>
@@ -53,34 +58,45 @@
<section class="info-card settings-list"> <section class="info-card settings-list">
<div class="setting-item"> <div class="setting-item">
<span>置顶聊天</span> <span>置顶聊天</span>
<input type="checkbox" class="ios-switch" /> <input type="checkbox" class="ios-switch" :checked="isPinned" @change="togglePin" />
</div> </div>
<div class="setting-item"> <div class="setting-item">
<span>消息免打扰</span> <span>消息免打扰</span>
<input type="checkbox" class="ios-switch" /> <input type="checkbox" class="ios-switch" :checked="isMuted" @change="toggleMute" />
</div> </div>
<div class="setting-item arrow"> <div class="setting-item arrow" @click="$emit('searchInChat')">
<span>查找聊天记录</span> <span>查找聊天记录</span>
</div> </div>
</section> </section>
<div class="danger-zone"> <div v-if="chatType == CHAT_TYPE.GROUP" class="danger-zone">
<button class="danger-btn">删除并退出</button> <button class="danger-btn" @click="handleExitGroup">{{ isMaster ? '解散群组' : '删除并退出' }}</button>
</div> </div>
</div> </div>
</aside> <create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友"
</transition> :excludeIds="groupMemberIds" @submit="inviteUserHandler"/>
</aside>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import { MESSAGE_TYPE } from '../../constants/MessageType'; import { CHAT_TYPE } from '../../constants/MessageType';
import feather from 'feather-icons'; import feather from 'feather-icons';
import { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
import { uploadService } from '../../services/upload/uploadService';
import { groupService } from '../../services/group';
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
import { useMessage } from './useAlert';
import CreateGroup from '../groups/CreateGroup.vue';
import AsyncImage from '../AsyncImage.vue';
import { useAuthStore } from '../../stores/auth';
import { useConversationStore } from '../../stores/conversation';
import { useGroupStore } from '../../stores/group';
const props = defineProps({ const props = defineProps({
chatType: { chatType: {
type: String, type: String,
default: MESSAGE_TYPE.GROUP default: CHAT_TYPE.GROUP
}, },
groupData: { groupData: {
type: Object, type: Object,
@@ -93,20 +109,169 @@ const props = defineProps({
id: i, id: i,
nickname: `成员 ${i + 1}`, nickname: `成员 ${i + 1}`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`, avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
role: i === 0 ? 'admin' : 'member' role: i === 0 ? 'Master' : 'Normal'
})) }))
}) })
}, },
currentUserId: [String, Number]
}); });
defineEmits(['close', 'viewAll']); const input = useTemplateRef('input')
// 判断当前用户是否为管理员 const message = useMessage();
const isAdmin = computed(() => { const conversationStore = useConversationStore();
// 逻辑:在 members 数组中找到当前用户并检查 role const groupStore = useGroupStore();
return true; // 演示用,默认 true
}); const groupInfo = ref({
members: []
})
const groupInviteModal = ref(false)
const groupMemberIds = computed(() => (groupInfo.value?.members || []).map(m => m.userId))
const editingAnnouncement = ref(false)
const announcementText = ref('')
const showAllMembers = ref(false)
// --- pin / mute (Task 17, persisted in localStorage) ---
const storageKey = computed(() => `conv_${props.groupData?.targetId || props.groupData?.id}`)
const isPinned = ref(false)
const isMuted = ref(false)
const togglePin = () => {
isPinned.value = !isPinned.value
localStorage.setItem(storageKey.value + '_pin', isPinned.value ? '1' : '0')
message.success(isPinned.value ? '已置顶' : '已取消置顶')
}
const toggleMute = () => {
isMuted.value = !isMuted.value
localStorage.setItem(storageKey.value + '_mute', isMuted.value ? '1' : '0')
message.success(isMuted.value ? '已免打扰' : '已取消免打扰')
}
const emit = defineEmits(['close', 'viewAll', 'searchInChat']);
// --- current user / role ---
const currentUserId = computed(() => useAuthStore().userInfo?.id)
const currentMember = computed(() =>
(groupInfo.value?.members || []).find(member => member.userId === currentUserId.value)
)
const isMaster = computed(() => currentMember.value?.role === GROUP_MEMBER_ROLE.MASTER)
const isAdmin = computed(() =>
currentMember.value?.role === GROUP_MEMBER_ROLE.MASTER ||
currentMember.value?.role === GROUP_MEMBER_ROLE.ADMIN
)
// --- announcement ---
const saveAnnouncement = async () => {
const res = await groupService.updateGroupInfo({
groupId: props.groupData.targetId,
description: announcementText.value
})
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
groupInfo.value.announcement = announcementText.value
editingAnnouncement.value = false
message.success('公告已更新')
} else {
message.error(res.message || '更新失败')
}
}
// --- member management ---
const handleMemberClick = async (member) => {
if (!isAdmin.value || member.userId === currentUserId.value) return
if (!confirm(`确定要移除成员 ${member.nickname || member.groupNickName} 吗?`)) return
const res = await groupService.deleteMember(member.id)
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
groupInfo.value.members = groupInfo.value.members.filter(m => m.id !== member.id)
message.success('已移除')
} else {
message.error(res.message || '移除失败')
}
}
// --- exit group ---
const handleExitGroup = async () => {
const groupId = props.groupData.targetId
const actionText = isMaster.value ? '解散该群组' : '退出该群组'
if (!confirm(`确定要${actionText}吗?${isMaster.value ? '此操作会移除所有成员。' : ''}`)) return
try {
const res = isMaster.value
? await groupService.dissolveGroup(groupId)
: await groupService.leaveGroup(groupId)
if (res.code === 0) {
await conversationStore.removeConversation(props.groupData.id)
groupStore.removeGroup(groupId)
message.success(isMaster.value ? '群组已解散' : '已退出群组')
emit('close')
} else {
message.error(res.message || `${actionText}失败`)
}
} catch {
message.error(`${actionText}失败`)
}
}
const uploadGroupAvatar = () => {
input.value.click()
}
const fileUploadHandler = async (e) => {
const file = e.target.files[0];
const { data } = await uploadService.uploadSmallFile(file, true);
const res = await groupService.updateGroupInfo({
groupId: props.groupData.targetId,
avatar: data.url
})
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
message.success('头像更新成功')
}else{
message.error(res.message)
}
}
const inviteHandler = async () => {
// 打开弹窗前确保成员数据已加载
try {
const mRes = await groupService.getGroupMember(props.groupData.targetId)
if (mRes.code === SYSTEM_BASE_STATUS.SUCCESS && mRes.data) {
groupInfo.value.members = mRes.data
}
} catch (error) {
console.error('加载群成员失败:', error)
}
groupInviteModal.value = true
}
const inviteUserHandler = async (selectedUsers) => {
const userIds = [...selectedUsers];
let allSuccess = true;
for (const userId of userIds) {
const res = await groupService.inviteUser(props.groupData.targetId, userId);
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) allSuccess = false;
}
if (allSuccess) {
message.success('邀请成功')
} else {
message.error('部分邀请发送失败')
}
}
watch(
() => props.groupData.id,
async (newVal, oldVal) => {
if (props.chatType == CHAT_TYPE.GROUP && newVal != oldVal) {
groupInfo.value = (await groupService.getGroupInfo(props.groupData.targetId)).data
groupInfo.value.members = (await groupService.getGroupMember(props.groupData.targetId)).data
// restore pin/mute state
isPinned.value = localStorage.getItem(storageKey.value + '_pin') === '1'
isMuted.value = localStorage.getItem(storageKey.value + '_mute') === '1'
}
},
{ immediate: true }
)
onMounted(async () => {
})
</script> </script>
<style scoped> <style scoped>
@@ -118,7 +283,7 @@ const isAdmin = computed(() => {
right: 0; right: 0;
width: 320px; width: 320px;
background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */ background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */
z-index: 1000; z-index: 100;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05); box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -274,7 +439,7 @@ const isAdmin = computed(() => {
height: 48px; height: 48px;
} }
.member-img { :deep(.member-img) {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 12px; border-radius: 12px;
@@ -378,6 +543,11 @@ const isAdmin = computed(() => {
.ios-switch:checked::before { transform: translateX(18px); } .ios-switch:checked::before { transform: translateX(18px); }
/* 公告编辑 */
.announcement-edit { display: flex; flex-direction: column; gap: 8px; }
.announce-input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; resize: vertical; box-sizing: border-box; }
.save-announce-btn { align-self: flex-end; padding: 6px 16px; background: #007aff; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
/* 动画 */ /* 动画 */
.slide-enter-active, .slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .slide-enter-active, .slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.slide-enter-from, .slide-leave-to { transform: translateX(100%); opacity: 0.5; } .slide-enter-from, .slide-leave-to { transform: translateX(100%); opacity: 0.5; }
@@ -17,9 +17,9 @@ const props = defineProps({
} }
}); });
let player = new Player({ new Player({
id: 'Video', id: 'Video',
url: props.m.content.url, url: props.m.url || (props.m.content?.body?.url) || '',
controlPlugins: [ controlPlugins: [
volume, volume,
playbackRate playbackRate
@@ -2,8 +2,9 @@
import { ref, reactive } from 'vue'; import { ref, reactive } from 'vue';
import { friendService } from '@/services/friend'; import { friendService } from '@/services/friend';
import { useMessage } from '../messages/useAlert'; import { useMessage } from '../messages/useAlert';
import AsyncImage from '../AsyncImage.vue';
const props = defineProps({ modelValue: Boolean }); defineProps({ modelValue: Boolean });
const emit = defineEmits(['update:modelValue', 'success']); const emit = defineEmits(['update:modelValue', 'success']);
const message = useMessage(); const message = useMessage();
@@ -43,7 +44,7 @@ const onSearch = async () => {
const submitAdd = async () => { const submitAdd = async () => {
submitting.value = true; submitting.value = true;
const res = await friendService.requestFriend({ const res = await friendService.requestFriend({
toUserId: userResult.value.id, targetId: userResult.value.id,
remarkName: form.remark, remarkName: form.remark,
description: form.description description: form.description
}); });
@@ -74,10 +75,10 @@ const submitAdd = async () => {
</div> </div>
<div v-if="userResult" class="result-card"> <div v-if="userResult" class="result-card">
<img :src="userResult.avatar" class="mini-avatar" /> <AsyncImage :raw-url="userResult.avatar" class="mini-avatar" />
<div class="info"> <div class="info">
<div class="name">{{ userResult.nickName }}</div> <div class="name">{{ userResult.nickName }}</div>
<div class="id">ID: {{ userResult.username }}</div> <div class="id">ID: {{ userResult.userName }}</div>
</div> </div>
<button class="next-btn" @click="step = 2">添加</button> <button class="next-btn" @click="step = 2">添加</button>
</div> </div>
@@ -146,7 +147,7 @@ main { padding: 12px; }
margin-top: 12px; display: flex; align-items: center; margin-top: 12px; display: flex; align-items: center;
padding: 10px; background: #f9f9f9; border-radius: 8px; padding: 10px; background: #f9f9f9; border-radius: 8px;
} }
.mini-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; } :deep(.mini-avatar) { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; flex-shrink: 0; }
.info { flex: 1; } .info { flex: 1; }
.name { font-size: 14px; font-weight: bold; color: #333; } .name { font-size: 14px; font-weight: bold; color: #333; }
.id { font-size: 11px; color: #999; } .id { font-size: 11px; color: #999; }
@@ -172,4 +173,4 @@ footer { padding: 0 12px 12px; }
.submit-btn:disabled { opacity: 0.6; } .submit-btn:disabled { opacity: 0.6; }
.empty { text-align: center; padding: 20px; font-size: 12px; color: #ccc; } .empty { text-align: center; padding: 20px; font-size: 12px; color: #ccc; }
</style> </style>
@@ -1,27 +1,26 @@
<template> <template>
<teleport to="body"> <teleport to="body">
<transition name="fade"> <transition name="fade">
<div <div
v-if="isVisible" v-if="isVisible"
ref="cardRef"
class="im-hover-card" class="im-hover-card"
:style="cardStyle" :style="cardStyle"
@mouseenter="clearTimer"
@mouseleave="hide"
> >
<div class="card-inner"> <div class="card-inner">
<div class="user-profile"> <div class="user-profile">
<div class="info-text"> <div class="info-text">
<h4 class="nickname">{{ currentUser.name }}</h4> <h4 class="nickname">{{ currentUser.name }}</h4>
<p class="detail-item"> <p class="detail-item">
<span class="label">微信号</span> <span class="label">用户名</span>
<span class="value">{{ currentUser.id }}</span> <span class="value">{{ currentUser.userName || currentUser.name || currentUser.id }}</span>
</p> </p>
<p class="detail-item"> <p class="detail-item">
<span class="label"> </span> <span class="label">地区</span>
<span class="value">{{ currentUser.region || '未知' }}</span> <span class="value">{{ currentUser.region || '未知' }}</span>
</p> </p>
</div> </div>
<img :src="currentUser.avatar" class="avatar-square" /> <AsyncImage :raw-url="currentUser.avatar" class="avatar-square" />
</div> </div>
<div class="user-bio"> <div class="user-bio">
@@ -39,7 +38,8 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive } from 'vue'; import { ref, reactive, onMounted, onUnmounted } from 'vue';
import AsyncImage from '../AsyncImage.vue';
const isVisible = ref(false); const isVisible = ref(false);
const currentUser = ref({}); const currentUser = ref({});
@@ -48,30 +48,30 @@ const cardStyle = reactive({
top: '0px', top: '0px',
left: '0px' left: '0px'
}); });
const cardRef = ref(null);
let timer = null;
const show = (el, data) => {
clearTimer();
currentUser.value = data;
const rect = el.getBoundingClientRect();
// IM 习惯:通常在头像右侧或下方弹出
// 这里设置为在头像中心水平对齐,下方弹出
cardStyle.top = `${rect.bottom + 8}px`;
cardStyle.left = `${rect.left}px`;
isVisible.value = true;
};
const hide = () => { const hide = () => {
timer = setTimeout(() => { isVisible.value = false;
isVisible.value = false;
}, 300);
}; };
const clearTimer = () => { const onDocumentClick = (e) => {
if (timer) clearTimeout(timer); if (!isVisible.value) return;
// 点击卡片内部不关闭
if (cardRef.value && cardRef.value.contains(e.target)) return;
hide();
};
onMounted(() => document.addEventListener('click', onDocumentClick, true));
onUnmounted(() => document.removeEventListener('click', onDocumentClick, true));
const show = (el, data) => {
currentUser.value = data;
const rect = el.getBoundingClientRect();
cardStyle.top = `${rect.bottom + 8}px`;
cardStyle.left = `${rect.left}px`;
isVisible.value = true;
}; };
const onAdd = () => { const onAdd = () => {
@@ -133,12 +133,7 @@ defineExpose({ show, hide });
color: #555; color: #555;
} }
.avatar-square { :deep(.avatar-square) { width: 60px; height: 60px; border-radius: 4px; flex-shrink: 0; }
width: 60px;
height: 60px;
border-radius: 4px;
object-fit: cover;
}
/* 签名区 */ /* 签名区 */
.user-bio { .user-bio {
@@ -192,4 +187,4 @@ defineExpose({ show, hide });
.fade-enter-from, .fade-leave-to { .fade-enter-from, .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>
@@ -0,0 +1,44 @@
export const GROUP_MEMBER_ROLE = Object.freeze({
NORMAL: 'Normal',
ADMIN: 'Administrator',
MASTER: 'Master'
})
/**
* 群入群请求状态 (对应后端 string)
* State: "Pending" 待管理员同意, "Declined" 已拒绝, "Passed" 已通过
*/
export const GROUP_REQUEST_STATUS = Object.freeze({
PENDING: 'Pending',
DECLINED: 'Declined',
PASSED: 'Passed',
})
/**
* 群邀请状态 (对应后端 string)
* State: "Pending" 待被邀请人同意, "Passed" 已同意, "Reject" 拒绝
*/
export const GROUP_INVITATION_STATUS = Object.freeze({
PENDING: 'Pending',
ACCEPTED: 'Passed',
REJECTED: 'Reject',
})
export const GROUP_REQUEST_ACTION = Object.freeze({
ACCEPT: 'Accept',
REJECT: 'Reject'
})
export const getGroupRequestStatusTxt = (status) => {
switch (status) {
case GROUP_REQUEST_STATUS.PENDING:
return '待管理员处理';
case GROUP_REQUEST_STATUS.DECLINED:
return '管理员已拒绝';
case GROUP_REQUEST_STATUS.PASSED:
return '管理员已同意';
default:
return '未知状态';
}
}
@@ -1,4 +1,24 @@
/** 会话类型 (对应后端 ChatType string) */
export const CHAT_TYPE = Object.freeze({
PRIVATE: 'PRIVATE',
GROUP: 'GROUP'
})
/** 消息类型 (对应后端 MsgType string) */
export const MSG_TYPE = Object.freeze({
Text: 'Text',
Image: 'Image',
Voice: 'Voice',
Video: 'Video',
File: 'File',
VoiceCall: 'VoiceChat',
VideoCall: 'VideoChat'
})
/**
* @deprecated 请使用 CHAT_TYPE
*/
export const MESSAGE_TYPE = Object.freeze({ export const MESSAGE_TYPE = Object.freeze({
PRIVATE: 'PRIVATE', PRIVATE: 'PRIVATE',
GROUP: 'GROUP' GROUP: 'GROUP'
}) })
@@ -1,40 +1,20 @@
import { MSG_TYPE } from './MessageType'
export const getMessageType = (fileType) => { export const getMessageType = (fileType) => {
if (!fileType) return FILE_TYPE.File; // 兜底处理 if (!fileType) return MSG_TYPE.File;
// 处理图片 if (fileType.startsWith('image/')) return MSG_TYPE.Image;
if (fileType.startsWith('image/')) { if (fileType.startsWith('audio/')) return MSG_TYPE.Voice;
return FILE_TYPE.Image; if (fileType.startsWith('video/')) return MSG_TYPE.Video;
}
// 处理音频(录音消息) return MSG_TYPE.File;
if (fileType.startsWith('audio/')) {
return FILE_TYPE.Voice;
}
// 处理视频
if (fileType.startsWith('video/')) {
return FILE_TYPE.Video;
}
// 常见文档类型的特殊处理(可选)
const documentTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
];
if (documentTypes.includes(fileType)) {
return FILE_TYPE.File;
}
// 其他所有情况统一归类为文件
return FILE_TYPE.File;
}; };
/** @deprecated 请使用 MSG_TYPE。保留仅为 AsyncImage/cache 等通用缓存层兼容。 */
export const FILE_TYPE = Object.freeze({ export const FILE_TYPE = Object.freeze({
Image: 'Image', Image: 'Image',
Video: 'Video', Video: 'Video',
Voice: 'Voice', Voice: 'Voice',
File: 'File' File: 'File',
TEXT: 'Text'
}); });
@@ -1,17 +1,19 @@
export const FRIEND_ACTIONS = Object.freeze({ export const FRIEND_ACTIONS = Object.freeze({
/**接受 */ /**同意 */
Accept: 'Accept', Accept: 'Accpet',
/**拒绝 */ /**拒绝 */
Reject: 'Reject' Reject: 'Reject',
/**拉黑 */
Block: 'Block'
}); });
export const FRIEND_REQUEST_STATUS = Object.freeze({ export const FRIEND_REQUEST_STATUS = Object.freeze({
/**待处理 */ /**待通过 */
Pending: 'Pending', Pending: 'Pending',
/**通过 */
Passed: 'Passed',
/**已拒绝 */ /**已拒绝 */
Declined: 'Declined', Declined: 'Declined',
/**已同意 */
Passed: 'Passed',
/**已拉黑 */ /**已拉黑 */
Blocked: 'Blocked' Blocked: 'Blocked'
}) })
@@ -0,0 +1,26 @@
export const GROUP_REQUEST_TYPE = Object.freeze({
//**邀请对方 */
INVITE: 'invite',
/**被邀请 */
INVITED: 'invited',
/**入群请求 */
IS_GROUP: 'is-group',
//**我的申请入群 */
IS_USER: 'is-user',
})
export const getTypeText = (type) => {
switch(type){
case GROUP_REQUEST_TYPE.INVITE:
return '邀请好友入群';
case GROUP_REQUEST_TYPE.INVITED:
return '邀请你入群';
case GROUP_REQUEST_TYPE.IS_GROUP:
return '申请入群';
case GROUP_REQUEST_TYPE.IS_USER:
return '我的申请入群';
default:
'未知状态'
}
}
@@ -0,0 +1,34 @@
export const NOTIFICATION_TYPE = Object.freeze({
// --- 基础聊天 (10-19) ---
ChatMsg: 1,
FileMsg: 11,
EmojiSticker: 12,
ForwardMsg: 13,
// --- 系统与通知 (20-29) ---
SystemNotice: 2,
FriendRequest: 21,
FriendAccepted: 22,
UserInLine: 23,
// --- 状态变更与交互 (30-39) ---
ActionStatus: 3,
MsgReadReceipt: 31,
MsgRevoke: 32,
MsgEdit: 33,
// --- 群组管理 (40-49) ---
GroupInvited: 41,
GroupMemberUpdate: 42,
GroupAnnouncement: 43,
GroupDismissed: 44,
// --- 实时通信控制 (50-59) ---
RTC_CallRequest: 51,
RTC_CallHandled: 52,
// --- 异常与安全 (90-99) ---
ErrorInternal: 91,
TokenExpired: 92,
KickedOut: 93
})
@@ -1,14 +1,50 @@
import { useConversationStore } from "@/stores/conversation" import { useConversationStore } from "@/stores/conversation"
import { CHAT_TYPE } from "../constants/MessageType";
export const messageHandler = (msg) => { export const messageHandler = async (msg) => {
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const conversation = conversationStore.conversations.find(x => (x.targetId == msg.senderId || x.targetId == msg.receiverId) && msg.chatType == x.chatType);
conversation.lastMessage = msg.content; let conversation = conversationStore.conversations.find(x => {
if (conversation.targetId == msg.receiverId) { if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
conversation.unreadCount = 0; return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
} else { (x.targetId === msg.senderId || x.targetId === msg.targetId);
conversation.unreadCount += 1; }
if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
x.targetId === msg.targetId;
}
return false;
});
// 会话不存在 → 从服务器拉取
if (!conversation) {
await conversationStore.fetchConversationsFromServier();
conversation = conversationStore.conversations.find(x => {
if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
(x.targetId === msg.senderId || x.targetId === msg.targetId);
}
if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
x.targetId === msg.targetId;
}
return false;
});
} }
conversation.dateTime = new Date().toISOString(); if (!conversation) return;
const contentText = typeof msg.content === 'object'
? (msg.content?.body?.text || msg.content?.fallback || '')
: msg.content;
conversation.lastMessage = contentText;
// 不是我发的消息 → 未读+1
const isFromMe = (conversation.userId && conversation.userId === msg.senderId);
if (!isFromMe) {
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
}
conversation.dateTime = msg.pushTimestamp
? new Date(msg.pushTimestamp).toISOString()
: (msg.creationTime || new Date().toISOString());
} }
@@ -11,6 +11,7 @@ const message = useMessage();
const routes = [ const routes = [
{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') }, { path: '/auth/login', component: () => import('@/views/auth/Login.vue') },
{ path: '/auth/register', component: () => import('@/views/auth/Register.vue') },
{ {
path: '/', path: '/',
component: MainView, component: MainView,
@@ -53,10 +54,21 @@ const routes = [
component: () => import('@/views/contact/UserInfoContent.vue'), component: () => import('@/views/contact/UserInfoContent.vue'),
props: true props: true
}, },
{
path: '/contacts/group/:id',
name: 'groupInfo',
component: () => import('@/views/contact/GroupInfoContent.vue'),
props: true
},
{ {
path: '/contacts/requests', path: '/contacts/requests',
name: 'friendRequests', name: 'friendRequests',
component: () => import('@/views/contact/FriendRequestList.vue') component: () => import('@/views/contact/FriendRequestList.vue')
},
{
path: '/contacts/grouphandle',
name: 'grouphandle',
component: () => import('@/views/contact/GroupRequest.vue')
} }
] ]
}, },
@@ -72,6 +84,9 @@ const routes = [
{ path: '/test', component: TestView }, { path: '/test', component: TestView },
{ {
path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue') path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
},
{
path: '/videopre', component: () => import('@/components/electron/VideoPreview.vue')
} }
] ]
+128 -76
View File
@@ -1,97 +1,149 @@
import axios from 'axios' import axios from 'axios'
import { useMessage } from '@/components/messages/useAlert'; import { ref } from 'vue'
import router from '@/router'; import { useMessage } from '@/components/messages/useAlert'
import { useAuthStore } from '@/stores/auth'; import router from '@/router'
import { authService } from './auth'; import { useAuthStore } from '@/stores/auth'
import { authService } from './auth'
const message = useMessage(); const message = useMessage()
let waitqueue = []; let waitqueue = []
let isRefreshing = false; let isRefreshing = false
const authURL = ['/auth/login', '/auth/register', '/auth/refresh']; const authURL = ['/auth/login', '/auth/register', '/auth/refresh']
// 全局网络状态:null=正常,string=错误消息
export const networkError = ref(null)
let recoveryTimer = null
const setNetworkError = (msg) => {
networkError.value = msg
// 10 秒后如果没恢复,自动清除(下次请求成功也会清除)
clearTimeout(recoveryTimer)
recoveryTimer = setTimeout(() => { networkError.value = null }, 10000)
}
const clearNetworkError = () => {
networkError.value = null
clearTimeout(recoveryTimer)
}
const pushLoginElectron = () => {
window.api.window.close()
window.api.window.newWindow('/auth/login', null, 420, 540)
}
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8009/api'),
timeout: 10000, timeout: 10000,
headers: { headers: {}
}
}) })
api.interceptors.request.use( api.interceptors.request.use(
config => { (config) => {
const authStore = useAuthStore(); const authStore = useAuthStore()
const token = authStore.token; const token = authStore.token
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`
} }
return config; return config
}, },
err => { (err) => {
return Promise.reject(err); return Promise.reject(err)
} }
) )
const redirectToLogin = () => {
const authStore = useAuthStore()
authStore.logout()
message.error('登录已失效,请重新登录。')
if (window.api?.window) pushLoginElectron()
else router.push('/auth/login')
}
const rejectWaitingRequests = (error) => {
waitqueue.forEach(({ reject }) => reject(error))
waitqueue = []
}
const refreshAndRetry = async (config, originalError) => {
const authStore = useAuthStore()
if (!config || authURL.some((x) => config.url?.includes(x)) || config._retry) {
redirectToLogin()
return Promise.reject(originalError)
}
config._retry = true
if (isRefreshing) {
return new Promise((resolve, reject) => {
waitqueue.push({
resolve: (token) => {
config.headers.Authorization = `Bearer ${token}`
resolve(api(config))
},
reject,
})
})
}
const refreshToken = authStore.refreshToken
if (!refreshToken) {
redirectToLogin()
return Promise.reject(originalError)
}
isRefreshing = true
try {
const res = await authService.refresh(refreshToken)
if (res.code !== 0 || !res.data?.token) throw new Error(res.message || '刷新登录状态失败')
authStore.setLoginInfo(res.data)
waitqueue.forEach(({ resolve }) => resolve(authStore.token))
waitqueue = []
config.headers.Authorization = `Bearer ${authStore.token}`
return api(config)
} catch (error) {
rejectWaitingRequests(error)
redirectToLogin()
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
api.interceptors.response.use( api.interceptors.response.use(
response => { (response) => {
return response.data; // 任何成功的响应都清除网络错误状态
clearNetworkError()
// 滚动发布期间兼容旧后端的 HTTP 200 + code=1006。
if (response.data?.code === 1006) {
return refreshAndRetry(response.config, response.data)
}
return response.data
}, },
async err => { async (err) => {
const authStore = useAuthStore(); const { config, response } = err
const { config, response } = err;
if (response) {
switch (response.status) {
case 401:
if (authURL.some(x => config.url.includes(x))) {
authStore.logout();
message.error('未登录,请登录后操作。');
router.push('/auth/login')
break;
}
if (config._retry) {
break;
}
config._retry = true; // 无响应 → 网络不通
// 已经在刷新 → 排队 if (!response) {
if (isRefreshing) { setNetworkError('网络连接异常')
return new Promise(resolve => { return Promise.reject(err)
waitqueue.push(token => {
config.headers.Authorization = `Bearer ${token}`
resolve(api(config))
})
})
}
isRefreshing = true;
const refreshToken = authStore.refreshToken;
if (refreshToken != null && refreshToken != '') {
const res = await authService.refresh(refreshToken)
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
waitqueue.forEach(cb => cb(authStore.token));
waitqueue = [];
config.headers.Authorization = `Bearer ${authStore.token}`
return api(config)
}
authStore.logout();
message.error('未登录,请登录后操作。');
router.push('/auth/login')
break;
case 400:
if (response.data && response.data.code == 1003) {
message.error(response.data.message);
break;
}
default:
message.error('请求错误,请检查网络。');
break;
}
return Promise.reject(err);
} else {
message.error('请求错误,请检查网络。');
return Promise.reject(err);
} }
switch (response.status) {
case 401:
return refreshAndRetry(config, err)
case 400:
if (response.data && response.data.code == 1003) {
message.error(response.data.message)
}
break
default:
// 其他 HTTP 错误用 toast(用户主动操作触发,需要即时反馈)
message.error(response.data?.message || `请求错误 (${response.status})`)
break
}
return Promise.reject(err)
} }
) )
@@ -100,5 +152,5 @@ export const request = {
post: (url, data, config) => api.post(url, data, config), post: (url, data, config) => api.post(url, data, config),
put: (url, data, config) => api.put(url, data, config), put: (url, data, config) => api.put(url, data, config),
delete: (url, config) => api.delete(url, config), delete: (url, config) => api.delete(url, config),
instance: api, instance: api
}; }
@@ -3,21 +3,22 @@ import { request } from "./api";
export const authService = { export const authService = {
/** /**
* 用户登录接口 * 用户登录接口
* @param {*} data * @param {{ userName, password }} data
* @returns * @returns Result<LoginResponse>
*/ */
login: (data) => request.post('/auth/login', data), login: (data) => request.post('/auth/login', data),
/** /**
* 用户注册 * 用户注册
* @param {*} data * @param {{ userName, password, nickName }} data
* @returns * @returns Result<UserResponse>
*/ */
register: (data) => request.post('/auth/register', data), register: (data) => request.post('/auth/register', data),
/** /**
* 刷新用户凭证 * 刷新用户凭证
* @param {*} data * @param {string} refreshToken
* @returns * @returns Result<LoginResponse>
*/ */
refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken }) refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken })
} }

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