merge: integrate feature-nxdev API fixes
This commit is contained in:
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.AssemblyConfigurationAttribute("Debug")]
|
||||
[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.AssemblyTitleAttribute("IMTest")]
|
||||
[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.
Binary file not shown.
+15
@@ -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/
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
+6
@@ -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>
|
||||
+1
-1
@@ -22,7 +22,7 @@ namespace IM_API.Application.EventHandlers.FriendAddHandler
|
||||
var usersList = new List<string> {
|
||||
@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,
|
||||
Content = "您有新的好友关系已添加",
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ namespace IM_API.Application.EventHandlers.GroupInviteActionUpdateHandler
|
||||
public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace IM_API.Configs
|
||||
CreateMap<RegisterRequestDto, User>()
|
||||
.ForMember(dest => dest.Username,opt => opt.MapFrom(src => src.Username))
|
||||
.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.StatusEnum,opt => opt.MapFrom(src => UserStatus.Normal))
|
||||
.ForMember(dest => dest.OnlineStatusEnum,opt => opt.MapFrom(src => UserOnlineStatus.Offline))
|
||||
@@ -215,6 +216,34 @@ namespace IM_API.Configs
|
||||
CreateMap<GroupMember, GroupMemberVo>()
|
||||
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.Created))
|
||||
.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);
|
||||
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 InviteId { 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
|
||||
{
|
||||
[EmailAddress(ErrorMessage = "邮箱格式错误")]
|
||||
public string Email { get; set; }
|
||||
[Required(ErrorMessage = "用户名不能为空")]
|
||||
[MaxLength(20, ErrorMessage = "用户名不能超过20字符")]
|
||||
[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 int InviteId { get; set; }
|
||||
public GroupInviteState Action { get; set; }
|
||||
public GroupRequestState Action { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,44 @@ namespace IM_API.Dtos
|
||||
Message = codeDefine.Message;
|
||||
Data = data;
|
||||
}
|
||||
public HubResponse(HubResponseType type, T? data)
|
||||
{
|
||||
Code = CodeDefine.SUCCESS.Code;
|
||||
Method = "Event";
|
||||
Type = type;
|
||||
Data = data;
|
||||
}
|
||||
}
|
||||
public enum HubResponseType
|
||||
{
|
||||
ChatMsg = 1, // 聊天内容
|
||||
SystemNotice = 2, // 系统通知(如:申请好友成功)
|
||||
ActionStatus = 3 // 状态变更(如:对方正在输入、已读回执)
|
||||
// --- 基础聊天 (10-19) ---
|
||||
ChatMsg = 1, // 普通消息
|
||||
|
||||
// --- 系统与通知 (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 MakeGroupMemberAsync(int userId, int groupId, GroupMemberRole? role);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+1174
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1101
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全员禁言)");
|
||||
|
||||
b.Property<string>("Announcement")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasComment("群公告");
|
||||
|
||||
@@ -392,50 +393,6 @@ namespace IM_API.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -502,6 +459,9 @@ namespace IM_API.Migrations
|
||||
.HasColumnType("int(11)")
|
||||
.HasComment("群聊编号\r\n");
|
||||
|
||||
b.Property<int?>("InviteUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<sbyte>("State")
|
||||
.HasColumnType("tinyint(4)")
|
||||
.HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)");
|
||||
@@ -515,8 +475,7 @@ namespace IM_API.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex(new[] { "GroupId" }, "GroupId")
|
||||
.HasDatabaseName("GroupId1");
|
||||
b.HasIndex(new[] { "GroupId" }, "GroupId");
|
||||
|
||||
b.ToTable("group_request", (string)null);
|
||||
|
||||
@@ -844,6 +803,12 @@ namespace IM_API.Migrations
|
||||
.HasDefaultValueSql("'1970-01-01 00:00:00'")
|
||||
.HasComment("创建时间");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<sbyte>("IsDeleted")
|
||||
.HasColumnType("tinyint(4)")
|
||||
.HasComment("软删除标识\r\n0:账号正常\r\n1:账号已删除");
|
||||
@@ -863,6 +828,9 @@ namespace IM_API.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.HasComment("密码");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<sbyte>("Status")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(4)")
|
||||
@@ -991,31 +959,6 @@ namespace IM_API.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("IM_API.Models.Group", "Group")
|
||||
@@ -1108,8 +1051,6 @@ namespace IM_API.Migrations
|
||||
|
||||
modelBuilder.Entity("IM_API.Models.Group", b =>
|
||||
{
|
||||
b.Navigation("GroupInvites");
|
||||
|
||||
b.Navigation("GroupMembers");
|
||||
|
||||
b.Navigation("GroupRequests");
|
||||
@@ -1148,10 +1089,6 @@ namespace IM_API.Migrations
|
||||
|
||||
b.Navigation("FriendUsers");
|
||||
|
||||
b.Navigation("GroupInviteInviteUserNavigations");
|
||||
|
||||
b.Navigation("GroupInviteInvitedUserNavigations");
|
||||
|
||||
b.Navigation("GroupMembers");
|
||||
|
||||
b.Navigation("GroupRequests");
|
||||
|
||||
@@ -38,7 +38,7 @@ public partial class Group
|
||||
/// <summary>
|
||||
/// 群公告
|
||||
/// </summary>
|
||||
public string? Announcement { get; set; }
|
||||
public string Announcement { get; set; } = "暂无群公告,点击编辑添加。";
|
||||
|
||||
/// <summary>
|
||||
/// 群聊创建时间
|
||||
@@ -56,7 +56,6 @@ public partial class Group
|
||||
public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
public virtual ICollection<GroupInvite> GroupInvites { get; set; } = new List<GroupInvite>();
|
||||
|
||||
public virtual User GroupMasterNavigation { get; set; } = null!;
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace IM_API.Models
|
||||
{
|
||||
public partial class GroupInvite
|
||||
{
|
||||
public GroupInviteState StateEnum
|
||||
{
|
||||
get => (GroupInviteState)State;
|
||||
set => State = (sbyte)value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace IM_API.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// 群邀请状态
|
||||
/// </summary>
|
||||
public enum GroupInviteState
|
||||
{
|
||||
/// <summary>
|
||||
/// 待处理
|
||||
/// </summary>
|
||||
Pending = 0,
|
||||
/// <summary>
|
||||
/// 已同意
|
||||
/// </summary>
|
||||
Passed = 1
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,24 @@
|
||||
/// </summary>
|
||||
Pending = 0,
|
||||
/// <summary>
|
||||
/// 已拒绝
|
||||
/// 管理员已拒绝
|
||||
/// </summary>
|
||||
Declined = 1,
|
||||
/// <summary>
|
||||
/// 已同意
|
||||
/// 管理员已同意
|
||||
/// </summary>
|
||||
Passed = 2
|
||||
Passed = 2,
|
||||
/// <summary>
|
||||
/// 待对方同意
|
||||
/// </summary>
|
||||
TargetPending = 3,
|
||||
/// <summary>
|
||||
/// 对方拒绝
|
||||
/// </summary>
|
||||
TargetDeclined = 4,
|
||||
/// <summary>
|
||||
/// 对方同意
|
||||
/// </summary>
|
||||
TargetPassed = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -19,8 +19,10 @@ public partial class GroupRequest
|
||||
/// </summary>
|
||||
public int UserId { get; set; }
|
||||
|
||||
public int? InviteUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请状态(0:待管理员同意,1:已拒绝,2:已同意)
|
||||
/// 申请状态(0:待管理员同意,1:管理员已拒绝,2:管理员已同意,3:待对方同意,4:对方拒绝)
|
||||
/// </summary>
|
||||
public sbyte State { get; set; }
|
||||
|
||||
|
||||
@@ -49,11 +49,6 @@ namespace IM_API.Models
|
||||
entity.Ignore(e => e.AuhorityEnum);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GroupInvite>(entity =>
|
||||
{
|
||||
entity.Ignore(e => e.StateEnum);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GroupMember>(entity =>
|
||||
{
|
||||
entity.Ignore(e => e.RoleEnum);
|
||||
|
||||
@@ -27,7 +27,6 @@ public partial class ImContext : DbContext
|
||||
|
||||
public virtual DbSet<Group> Groups { get; set; }
|
||||
|
||||
public virtual DbSet<GroupInvite> GroupInvites { get; set; }
|
||||
|
||||
public virtual DbSet<GroupMember> GroupMembers { get; set; }
|
||||
|
||||
@@ -362,53 +361,6 @@ public partial class ImContext : DbContext
|
||||
.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 =>
|
||||
{
|
||||
|
||||
@@ -23,6 +23,18 @@ public partial class User
|
||||
/// 用户昵称
|
||||
/// </summary>
|
||||
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>
|
||||
/// 用户在线状态
|
||||
@@ -73,10 +85,6 @@ public partial class User
|
||||
[JsonIgnore]
|
||||
public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>();
|
||||
[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>();
|
||||
[JsonIgnore]
|
||||
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)
|
||||
{
|
||||
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;
|
||||
var streamKey = chatType == ChatType.PRIVATE ?
|
||||
StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId);
|
||||
|
||||
@@ -79,6 +79,7 @@ namespace IM_API.Services
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Task DeleteGroupAsync(int userId, int groupId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
@@ -90,15 +91,15 @@ namespace IM_API.Services
|
||||
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
|
||||
//过滤非好友
|
||||
var groupInviteIds = await validFriendshipAsync(userId, userIds);
|
||||
var inviteList = groupInviteIds.Select(id => new GroupInvite
|
||||
var inviteList = groupInviteIds.Select(id => new GroupRequest
|
||||
{
|
||||
Created = DateTime.UtcNow,
|
||||
GroupId = group.Id,
|
||||
InviteUser = userId,
|
||||
InvitedUser = id,
|
||||
StateEnum = GroupInviteState.Pending
|
||||
UserId = id,
|
||||
InviteUserId = userId,
|
||||
StateEnum = GroupRequestState.TargetPending
|
||||
}).ToList();
|
||||
_context.GroupInvites.AddRange(inviteList);
|
||||
_context.GroupRequests.AddRange(inviteList);
|
||||
await _context.SaveChangesAsync();
|
||||
await _endPoint.Publish(new GroupInviteEvent
|
||||
{
|
||||
@@ -132,7 +133,7 @@ namespace IM_API.Services
|
||||
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
|
||||
.Where(x => x.UserId == userId)
|
||||
@@ -159,14 +160,18 @@ namespace IM_API.Services
|
||||
_context.Groups.Update(group);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto)
|
||||
{
|
||||
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);
|
||||
if (inviteInfo.InvitedUser != userid) throw new BaseException(CodeDefine.AUTH_FAILED);
|
||||
if (!(dto.Action == GroupRequestState.TargetPassed ||
|
||||
dto.Action == GroupRequestState.TargetDeclined))
|
||||
return;
|
||||
inviteInfo.StateEnum = dto.Action;
|
||||
_context.GroupInvites.Update(inviteInfo);
|
||||
_context.GroupRequests.Update(inviteInfo);
|
||||
await _context.SaveChangesAsync();
|
||||
await _endPoint.Publish(new GroupInviteActionUpdateEvent
|
||||
{
|
||||
@@ -176,12 +181,13 @@ namespace IM_API.Services
|
||||
EventId = Guid.NewGuid(),
|
||||
GroupId = inviteInfo.GroupId,
|
||||
InviteId = inviteInfo.Id,
|
||||
InviteUserId = inviteInfo.InviteUser.Value,
|
||||
InviteUserId = inviteInfo.InviteUserId!.Value,
|
||||
OperatorId = userid,
|
||||
UserId = userid
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto)
|
||||
{
|
||||
var user = _userService.GetUserInfoAsync(userid);
|
||||
@@ -266,5 +272,104 @@ namespace IM_API.Services
|
||||
return user;
|
||||
}).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();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 InvitedUserId { 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,6 +10,9 @@ pluginManagement {
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
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/public") }
|
||||
gradlePluginPortal()
|
||||
@@ -21,6 +24,9 @@ pluginManagement {
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||
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/public") }
|
||||
gradlePluginPortal()
|
||||
@@ -31,8 +37,10 @@ dependencyResolutionManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.1.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
|
||||
// 👇 升级到 8.2.1 修复 Java 21 兼容性 bug
|
||||
id("com.android.application") version "8.2.1" apply false
|
||||
// 👇 顺便把 Kotlin 版本也稍微升一下,避免后续出现旧版本警告
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
|
||||
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/register_page.dart';
|
||||
import 'package:app/features/home/pages/index_page.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -10,6 +11,7 @@ final appRouter = GoRouter(
|
||||
initialLocation: '/auth/login',
|
||||
routes: [
|
||||
GoRoute(path: '/auth/login', builder: (context, state) => const LoginPage()),
|
||||
GoRoute(path: '/auth/register', builder: (context, state) => const RegisterPage()),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
return MainPage(child: child);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:app/core/constants/app_colors.dart';
|
||||
import 'package:app/features/auth/pages/login_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:app/core/router/app_router.dart';
|
||||
|
||||
class LoginPageState extends State<LoginPage> {
|
||||
@override
|
||||
@@ -11,10 +12,10 @@ class LoginPageState extends State<LoginPage> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text("找回密码", style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
// TextButton(
|
||||
// onPressed: () {},
|
||||
// child: const Text("找回密码", style: TextStyle(color: Colors.grey)),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
@@ -24,23 +25,9 @@ class LoginPageState extends State<LoginPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
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 Text(
|
||||
"登录您的聊天账号",
|
||||
"登陆账号",
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -109,7 +96,9 @@ class LoginPageState extends State<LoginPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
onPressed: () {
|
||||
appRouter.push("/auth/register");
|
||||
},
|
||||
child: const Text(
|
||||
"注册账号",
|
||||
style: TextStyle(color: Color(0xFF576B95)),
|
||||
@@ -126,6 +115,13 @@ class LoginPageState extends State<LoginPage> {
|
||||
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
@@ -5,16 +5,16 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
@@ -22,7 +22,7 @@ packages:
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
@@ -30,7 +30,7 @@ packages:
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
@@ -38,23 +38,39 @@ packages:
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
@@ -67,7 +83,7 @@ packages:
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
@@ -85,15 +101,23 @@ packages:
|
||||
description:
|
||||
name: go_router
|
||||
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
@@ -101,7 +125,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
@@ -109,7 +133,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
@@ -117,7 +141,7 @@ packages:
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
@@ -125,7 +149,7 @@ packages:
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
@@ -133,7 +157,7 @@ packages:
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
@@ -141,7 +165,7 @@ packages:
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
@@ -149,15 +173,23 @@ packages:
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
@@ -170,7 +202,7 @@ packages:
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
@@ -178,7 +210,7 @@ packages:
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
@@ -186,7 +218,7 @@ packages:
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
@@ -194,7 +226,7 @@ packages:
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
@@ -202,7 +234,7 @@ packages:
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
@@ -210,15 +242,23 @@ packages:
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
@@ -226,9 +266,17 @@ packages:
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dart: ">=3.10.4 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
go_router: ^17.0.1
|
||||
dio: ^5.9.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
+2
-2
@@ -4,5 +4,5 @@
|
||||
# VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
|
||||
|
||||
|
||||
VITE_API_BASE_URL = http://192.168.5.116:7070/api
|
||||
VITE_SIGNALR_BASE_URL = http://192.168.5.116:7070/chat/
|
||||
VITE_API_BASE_URL = http://192.168.5.100:8009/api
|
||||
VITE_SIGNALR_BASE_URL = http://192.168.5.100:8009/chat
|
||||
|
||||
Vendored
+1
@@ -11,6 +11,7 @@
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
|
||||
@@ -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` 5–20 字符;`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` 5–20 字符;`password` 6–50 字符;`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` 范围为 1–100。
|
||||
|
||||
## 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` 为准。
|
||||
@@ -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` 限制为 1–100。
|
||||
- 前端断线补消息携带本地最大 `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。
|
||||
@@ -1,16 +1,43 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import { loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
// 从 VITE_API_BASE_URL 推导网关源(去掉末尾 /api)
|
||||
const apiBase = env.VITE_API_BASE_URL || 'http://localhost:8009/api'
|
||||
const signalRBase = env.VITE_SIGNALR_BASE_URL || 'http://localhost:8009/chat'
|
||||
const gatewayOrigin = new URL(apiBase).origin
|
||||
const signalROrigin = new URL(signalRBase).origin
|
||||
const signalRWebSocketOrigin = signalROrigin.replace(/^http/, 'ws')
|
||||
const cspPlugin = {
|
||||
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()]
|
||||
plugins: [vue(), vueDevTools(), cspPlugin]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Generated
+994
-7
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
"axios": "^1.13.2",
|
||||
"crypto": "^1.0.1",
|
||||
"electron-updater": "^6.3.9",
|
||||
"feather-icons": "^4.29.2",
|
||||
"hevue-img-preview": "^7.1.3",
|
||||
@@ -47,6 +49,8 @@
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"prettier": "^3.7.4",
|
||||
"vite": "^7.2.6",
|
||||
"vite-plugin-vue-devtools": "^8.0.7",
|
||||
"vitest": "^5.0.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
|
||||
Vendored
+13
@@ -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
@@ -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
@@ -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}`)
|
||||
})
|
||||
}
|
||||
@@ -1,22 +1,28 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { join } from 'path'
|
||||
import path from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerWindowHandler } from './ipcHandlers/window'
|
||||
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() {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
frame:false,
|
||||
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
|
||||
icon: join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
|
||||
icon: path.join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
@@ -24,7 +30,7 @@ function createWindow() {
|
||||
createTry(mainWindow);
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
// mainWindow.show()
|
||||
})
|
||||
|
||||
|
||||
@@ -38,7 +44,7 @@ function createWindow() {
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} 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
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
addProtocolHandler()
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
@@ -59,7 +67,50 @@ app.whenReady().then(() => {
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
// 开机自启
|
||||
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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { is } from '@electron-toolkit/utils'
|
||||
export function registerWindowHandler() {
|
||||
const windowMapData = new Map()
|
||||
|
||||
ipcMain.on('window-action', (event, action) => {
|
||||
//**窗口控件操作 */
|
||||
ipcMain.on('window-action', (event, action, data) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
const actions = {
|
||||
@@ -20,14 +21,21 @@ export function registerWindowHandler() {
|
||||
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]?.()
|
||||
})
|
||||
ipcMain.on('window-new', (event, { route, data }) => {
|
||||
/**新开窗口 */
|
||||
ipcMain.on('window-new', (event, { route, data, width = 900, height=670 }) => {
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
width: width,
|
||||
height: height,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
frame: false,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { app, Tray, Menu, nativeImage } from 'electron'
|
||||
import path from 'path'
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
let tray = null;
|
||||
|
||||
|
||||
@@ -8,9 +8,18 @@ const api = {
|
||||
maximize: () => ipcRenderer.send('window-action', 'maximize'),
|
||||
close: () => ipcRenderer.send('window-action', 'close'),
|
||||
closeThis: () => ipcRenderer.send('window-action', 'closeThis'),
|
||||
show: () => ipcRenderer.send('window-action', 'show'),
|
||||
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
|
||||
newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }),
|
||||
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId)
|
||||
newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }),
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<title>Electron</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self';
|
||||
content="default-src 'self' ql-im:;
|
||||
script-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;
|
||||
img-src 'self' data: blob: https: http:;
|
||||
connect-src 'self' __API_ORIGIN__ __SIGNALR_ORIGIN__ __SIGNALR_WS_ORIGIN__;
|
||||
img-src 'self' data: blob: https: http: ql-im:;
|
||||
font-src 'self' data:;
|
||||
media-src 'self' blob:;">
|
||||
media-src 'self' blob: __API_ORIGIN__ ql-im:">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<script setup>
|
||||
import Alert from '@/components/messages/Alert.vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
//import { useSignalRStore } from './stores/signalr';
|
||||
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 setup>
|
||||
import { defineProps, useAttrs, onMounted } from 'vue';
|
||||
import { defineProps, useAttrs } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
// 按钮样式变体:primary, secondary, danger, text
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<rect width="10" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</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">
|
||||
<rect x="1.5" y="1.5" width="7" height="7" />
|
||||
</svg>
|
||||
@@ -14,7 +14,7 @@
|
||||
<rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
</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"
|
||||
stroke-linecap="round">
|
||||
<path d="M1 1L9 9M9 1L1 9" />
|
||||
@@ -27,11 +27,19 @@
|
||||
|
||||
<script setup>
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { ref, defineEmits, defineProps } from 'vue'
|
||||
import { isElectron } from '../utils/electronHelper'
|
||||
|
||||
const isMaximized = ref(false)
|
||||
|
||||
const emits = defineEmits(['close'])
|
||||
const props = defineProps({
|
||||
resizable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
function minimize() {
|
||||
window.api.window.minimize();
|
||||
}
|
||||
@@ -41,6 +49,8 @@ function toggleMaximize() {
|
||||
}
|
||||
function close() {
|
||||
window.api.window.close()
|
||||
emits('close')
|
||||
emits('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, onMounted, defineEmits } from 'vue';
|
||||
import { ref, defineProps, defineEmits } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
menuList: {
|
||||
|
||||
@@ -1,50 +1,68 @@
|
||||
<template>
|
||||
<div v-for="c in props.contacts"
|
||||
:key="c.id"
|
||||
class="list-item"
|
||||
:class="{active: activeContactId === c.id}"
|
||||
<div v-for="c in props.contacts" :key="c.id" class="list-item" :class="{ active: activeContactId === c.id }"
|
||||
@click="routeUserInfo(c.id)">
|
||||
<img :src="c.userInfo.avatar" class="avatar-std" />
|
||||
<AsyncImage :raw-url="c.avatar" class="avatar-std" />
|
||||
<div class="info">
|
||||
<div class="name">{{ c.remarkName }}</div>
|
||||
<div class="name">{{ c.remarkName || c.nickName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!props.contacts || props.contacts.length === 0" class="empty-placeholder">
|
||||
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
|
||||
<p class="empty-text">暂无好友</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { defineProps, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import feather from 'feather-icons';
|
||||
import AsyncImage from '../AsyncImage.vue';
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const activeContactId = ref(null)
|
||||
const activeContactId = computed(() => route.params.id)
|
||||
|
||||
const props = defineProps({
|
||||
contacts: {
|
||||
type:String,
|
||||
required: true
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const routeUserInfo = (id) => {
|
||||
router.push(`/contacts/info/${id}`);
|
||||
activeContactId.value = id;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<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;
|
||||
padding: 10px 12px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none; /* 去除下划线 */
|
||||
color: inherit; /* 继承父元素的文本颜色 */
|
||||
outline: none; /* 去除点击时的蓝框 */
|
||||
-webkit-tap-highlight-color: transparent; /* 移动端点击高亮 */
|
||||
text-decoration: none;
|
||||
/* 去除下划线 */
|
||||
color: inherit;
|
||||
/* 继承父元素的文本颜色 */
|
||||
outline: none;
|
||||
/* 去除点击时的蓝框 */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* 移动端点击高亮 */
|
||||
}
|
||||
|
||||
/* 去除 hover、active 等状态的效果 */
|
||||
@@ -52,20 +70,21 @@ a:hover,
|
||||
a:active,
|
||||
a:focus {
|
||||
text-decoration: none;
|
||||
color: inherit; /* 保持颜色不变 */
|
||||
color: inherit;
|
||||
/* 保持颜色不变 */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-item:hover { background: #e2e2e2; }
|
||||
.list-item.active { background: #c6c6c6; }
|
||||
|
||||
.avatar-std {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
.list-item:hover {
|
||||
background: #e2e2e2;
|
||||
}
|
||||
|
||||
.list-item.active {
|
||||
background: #c6c6c6;
|
||||
}
|
||||
|
||||
:deep(.avatar-std) { width: 36px; height: 36px; border-radius: 4px; flex-shrink: 0; }
|
||||
|
||||
.icon-box {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -76,7 +95,16 @@ a:focus {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
.icon-box.orange { background: #faad14; }
|
||||
.icon-box.green { background: #52c41a; }
|
||||
.icon-box.blue { background: #1890ff; }
|
||||
|
||||
.icon-box.orange {
|
||||
background: #faad14;
|
||||
}
|
||||
|
||||
.icon-box.green {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.icon-box.blue {
|
||||
background: #1890ff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<WindowControls/>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { previewImages } from 'hevue-img-preview/v3'
|
||||
import {onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import WindowControls from '../WindowControls.vue';
|
||||
// import WindowControls from '../WindowControls.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -20,7 +20,7 @@ onMounted(async () => {
|
||||
imageList.value = data.imageList;
|
||||
index.value = data.index;
|
||||
previewImages({
|
||||
imgList: imageList.value.map(m => m.content.url),
|
||||
imgList: imageList.value.map(m => m.url || (m.content?.body?.url) || m.localUrl),
|
||||
nowImgIndex: index,
|
||||
clickMaskCLose: false,
|
||||
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">×</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>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useContactStore } from '@/stores/contact';
|
||||
import { groupService } from '@/services/group';
|
||||
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
||||
import { useMessage } from '../messages/useAlert';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { friendService } from '../../services/friend';
|
||||
import AsyncImage from '../AsyncImage.vue';
|
||||
|
||||
const contactStore = useContactStore();
|
||||
const message = useMessage();
|
||||
|
||||
const props = defineProps({ modelValue: Boolean });
|
||||
|
||||
const friends = ref([])
|
||||
|
||||
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]
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
type: { type: String, default: 'CreateGroup' },
|
||||
title: { type: String, default: '创建群聊' },
|
||||
/** 已在群的 userId 数组 */
|
||||
excludeIds: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
|
||||
message.show('群聊创建成功。');
|
||||
}else{
|
||||
message.error(res.message);
|
||||
}
|
||||
};
|
||||
const emits = defineEmits(['submit']);
|
||||
|
||||
onMounted(async () =>{
|
||||
friends.value = contactStore.contacts;
|
||||
})
|
||||
const friends = ref([]);
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -43,25 +48,26 @@ onMounted(async () =>{
|
||||
<div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)">
|
||||
<div class="mini-modal">
|
||||
<header>
|
||||
<span>发起群聊</span>
|
||||
<span>{{ props.title }}</span>
|
||||
<button @click="$emit('update:modelValue', false)">✕</button>
|
||||
</header>
|
||||
|
||||
<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 v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item">
|
||||
<img :src="f.userInfo.avatar" class="avatar" />
|
||||
<span class="name">{{ f.remarkName }}</span>
|
||||
<div v-for="f in available" :key="f.friendId" class="item" @click="toggle(f.friendId)">
|
||||
<AsyncImage :raw-url="f.avatar" class="avatar" />
|
||||
<span class="name">{{ f.nickName }}</span>
|
||||
<input type="checkbox" :checked="selected.has(f.friendId)" />
|
||||
</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>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<button @click="submit" :disabled="!groupName || !selected.size" class="btn">
|
||||
创建 ({{ selected.size }})
|
||||
<button @click="submit" :disabled="(!groupName && props.type == 'CreateGroup') || !selected.size" class="btn">
|
||||
{{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -70,44 +76,19 @@ onMounted(async () =>{
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 999;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
|
||||
.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; }
|
||||
|
||||
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; }
|
||||
|
||||
.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; }
|
||||
|
||||
.avatar { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; }
|
||||
:deep(.avatar) { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; flex-shrink: 0; }
|
||||
.name { flex: 1; font-size: 14px; }
|
||||
|
||||
.empty-hint { text-align: center; padding: 20px; color: #999; font-size: 13px; }
|
||||
footer { padding: 12px; }
|
||||
.btn {
|
||||
width: 100%; padding: 10px; background: #07c160; color: white;
|
||||
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
|
||||
}
|
||||
.btn { 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; }
|
||||
</style>
|
||||
@@ -4,29 +4,44 @@
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="group-item"
|
||||
:class="{ active: activeId === group.id }"
|
||||
@click="activeId = group.id; $emit('select', group)"
|
||||
:class="{ active: activeGroupId === group.id }"
|
||||
@click="routeGroupInfo(group)"
|
||||
>
|
||||
<img :src="group.avatar" class="group-avatar" />
|
||||
|
||||
<span class="group-name">{{ group.name }}</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import feather from 'feather-icons';
|
||||
|
||||
defineProps({
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
// 数据结构仅需: { id, name, avatar }
|
||||
// 数据结构: { id, name, avatar }
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -68,6 +83,16 @@ const activeId = ref(null);
|
||||
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 {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<aside class="group-info-sidebar">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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 v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
|
||||
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
|
||||
<div class="section-header">
|
||||
<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 class="announcement-box">
|
||||
{{ groupData.announcement || '暂无群公告,点击编辑添加。' }}
|
||||
<div v-if="editingAnnouncement" class="announcement-edit">
|
||||
<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>
|
||||
</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">
|
||||
<h3 class="section-label">群成员 <span class="count-tag">{{ groupData.members?.length || 0 }}</span></h3>
|
||||
<button class="text-link" @click="$emit('viewAll')">查看全部</button>
|
||||
<h3 class="section-label">群成员 <span class="count-tag">{{ groupInfo.members?.length || 0 }}</span></h3>
|
||||
<button class="text-link" @click="showAllMembers = !showAllMembers">{{ showAllMembers ? '收起' : '查看全部' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="member-grid">
|
||||
<div class="member-item add-btn">
|
||||
<div class="member-avatar-box dashed">
|
||||
<div class="member-avatar-box dashed" @click="inviteHandler">
|
||||
<span>+</span>
|
||||
</div>
|
||||
<span class="member-nick">邀请</span>
|
||||
</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"
|
||||
class="member-item"
|
||||
@click="handleMemberClick(member)"
|
||||
>
|
||||
<div class="member-avatar-box">
|
||||
<img :src="member.avatar" class="member-img" />
|
||||
<span v-if="member.role === 'admin'" class="role-badge"></span>
|
||||
<async-image :raw-url="member.avatar" class="member-img"/>
|
||||
<span v-if="member.role === GROUP_MEMBER_ROLE.ADMIN || member.role === GROUP_MEMBER_ROLE.MASTER" class="role-badge"></span>
|
||||
</div>
|
||||
<span class="member-nick">{{ member.nickname }}</span>
|
||||
<span class="member-nick">{{ member.nickname || member.groupNickName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -53,34 +58,45 @@
|
||||
<section class="info-card settings-list">
|
||||
<div class="setting-item">
|
||||
<span>置顶聊天</span>
|
||||
<input type="checkbox" class="ios-switch" />
|
||||
<input type="checkbox" class="ios-switch" :checked="isPinned" @change="togglePin" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<span>消息免打扰</span>
|
||||
<input type="checkbox" class="ios-switch" />
|
||||
<input type="checkbox" class="ios-switch" :checked="isMuted" @change="toggleMute" />
|
||||
</div>
|
||||
<div class="setting-item arrow">
|
||||
<div class="setting-item arrow" @click="$emit('searchInChat')">
|
||||
<span>查找聊天记录</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="danger-zone">
|
||||
<button class="danger-btn">删除并退出</button>
|
||||
<div v-if="chatType == CHAT_TYPE.GROUP" class="danger-zone">
|
||||
<button class="danger-btn" @click="handleExitGroup">{{ isMaster ? '解散群组' : '删除并退出' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友"
|
||||
:excludeIds="groupMemberIds" @submit="inviteUserHandler"/>
|
||||
</aside>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_TYPE } from '../../constants/MessageType';
|
||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { CHAT_TYPE } from '../../constants/MessageType';
|
||||
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({
|
||||
chatType: {
|
||||
type: String,
|
||||
default: MESSAGE_TYPE.GROUP
|
||||
default: CHAT_TYPE.GROUP
|
||||
},
|
||||
groupData: {
|
||||
type: Object,
|
||||
@@ -93,20 +109,169 @@ const props = defineProps({
|
||||
id: i,
|
||||
nickname: `成员 ${i + 1}`,
|
||||
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 isAdmin = computed(() => {
|
||||
// 逻辑:在 members 数组中找到当前用户并检查 role
|
||||
return true; // 演示用,默认 true
|
||||
});
|
||||
const message = useMessage();
|
||||
const conversationStore = useConversationStore();
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -118,7 +283,7 @@ const isAdmin = computed(() => {
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */
|
||||
z-index: 1000;
|
||||
z-index: 100;
|
||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -274,7 +439,7 @@ const isAdmin = computed(() => {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.member-img {
|
||||
:deep(.member-img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
@@ -378,6 +543,11 @@ const isAdmin = computed(() => {
|
||||
|
||||
.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-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',
|
||||
url: props.m.content.url,
|
||||
url: props.m.url || (props.m.content?.body?.url) || '',
|
||||
controlPlugins: [
|
||||
volume,
|
||||
playbackRate
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { ref, reactive } from 'vue';
|
||||
import { friendService } from '@/services/friend';
|
||||
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 message = useMessage();
|
||||
|
||||
@@ -43,7 +44,7 @@ const onSearch = async () => {
|
||||
const submitAdd = async () => {
|
||||
submitting.value = true;
|
||||
const res = await friendService.requestFriend({
|
||||
toUserId: userResult.value.id,
|
||||
targetId: userResult.value.id,
|
||||
remarkName: form.remark,
|
||||
description: form.description
|
||||
});
|
||||
@@ -74,10 +75,10 @@ const submitAdd = async () => {
|
||||
</div>
|
||||
|
||||
<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="name">{{ userResult.nickName }}</div>
|
||||
<div class="id">ID: {{ userResult.username }}</div>
|
||||
<div class="id">ID: {{ userResult.userName }}</div>
|
||||
</div>
|
||||
<button class="next-btn" @click="step = 2">添加</button>
|
||||
</div>
|
||||
@@ -146,7 +147,7 @@ main { padding: 12px; }
|
||||
margin-top: 12px; display: flex; align-items: center;
|
||||
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; }
|
||||
.name { font-size: 14px; font-weight: bold; color: #333; }
|
||||
.id { font-size: 11px; color: #999; }
|
||||
|
||||
@@ -3,25 +3,24 @@
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
ref="cardRef"
|
||||
class="im-hover-card"
|
||||
:style="cardStyle"
|
||||
@mouseenter="clearTimer"
|
||||
@mouseleave="hide"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<div class="user-profile">
|
||||
<div class="info-text">
|
||||
<h4 class="nickname">{{ currentUser.name }}</h4>
|
||||
<p class="detail-item">
|
||||
<span class="label">微信号:</span>
|
||||
<span class="value">{{ currentUser.id }}</span>
|
||||
<span class="label">用户名:</span>
|
||||
<span class="value">{{ currentUser.userName || currentUser.name || currentUser.id }}</span>
|
||||
</p>
|
||||
<p class="detail-item">
|
||||
<span class="label">地 区:</span>
|
||||
<span class="label">地区:</span>
|
||||
<span class="value">{{ currentUser.region || '未知' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<img :src="currentUser.avatar" class="avatar-square" />
|
||||
<AsyncImage :raw-url="currentUser.avatar" class="avatar-square" />
|
||||
</div>
|
||||
|
||||
<div class="user-bio">
|
||||
@@ -39,7 +38,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||
import AsyncImage from '../AsyncImage.vue';
|
||||
|
||||
const isVisible = ref(false);
|
||||
const currentUser = ref({});
|
||||
@@ -48,32 +48,32 @@ const cardStyle = reactive({
|
||||
top: '0px',
|
||||
left: '0px'
|
||||
});
|
||||
const cardRef = ref(null);
|
||||
|
||||
let timer = null;
|
||||
const hide = () => {
|
||||
isVisible.value = false;
|
||||
};
|
||||
|
||||
const onDocumentClick = (e) => {
|
||||
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) => {
|
||||
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 = () => {
|
||||
timer = setTimeout(() => {
|
||||
isVisible.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
const onAdd = () => {
|
||||
console.log('申请添加好友:', currentUser.value.id);
|
||||
// 这里写你的逻辑
|
||||
@@ -133,12 +133,7 @@ defineExpose({ show, hide });
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.avatar-square {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
:deep(.avatar-square) { width: 60px; height: 60px; border-radius: 4px; flex-shrink: 0; }
|
||||
|
||||
/* 签名区 */
|
||||
.user-bio {
|
||||
|
||||
@@ -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,3 +1,23 @@
|
||||
/** 会话类型 (对应后端 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({
|
||||
PRIVATE: 'PRIVATE',
|
||||
GROUP: 'GROUP'
|
||||
|
||||
@@ -1,40 +1,20 @@
|
||||
import { MSG_TYPE } from './MessageType'
|
||||
|
||||
export const getMessageType = (fileType) => {
|
||||
if (!fileType) return FILE_TYPE.File; // 兜底处理
|
||||
if (!fileType) return MSG_TYPE.File;
|
||||
|
||||
// 处理图片
|
||||
if (fileType.startsWith('image/')) {
|
||||
return FILE_TYPE.Image;
|
||||
}
|
||||
if (fileType.startsWith('image/')) return MSG_TYPE.Image;
|
||||
if (fileType.startsWith('audio/')) return MSG_TYPE.Voice;
|
||||
if (fileType.startsWith('video/')) return MSG_TYPE.Video;
|
||||
|
||||
// 处理音频(录音消息)
|
||||
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;
|
||||
return MSG_TYPE.File;
|
||||
};
|
||||
|
||||
/** @deprecated 请使用 MSG_TYPE。保留仅为 AsyncImage/cache 等通用缓存层兼容。 */
|
||||
export const FILE_TYPE = Object.freeze({
|
||||
Image: 'Image',
|
||||
Video: 'Video',
|
||||
Voice: 'Voice',
|
||||
File: 'File'
|
||||
File: 'File',
|
||||
TEXT: 'Text'
|
||||
});
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
export const FRIEND_ACTIONS = Object.freeze({
|
||||
/**接受 */
|
||||
Accept: 'Accept',
|
||||
/**同意 */
|
||||
Accept: 'Accpet',
|
||||
/**拒绝 */
|
||||
Reject: 'Reject'
|
||||
Reject: 'Reject',
|
||||
/**拉黑 */
|
||||
Block: 'Block'
|
||||
});
|
||||
|
||||
export const FRIEND_REQUEST_STATUS = Object.freeze({
|
||||
/**待处理 */
|
||||
/**待通过 */
|
||||
Pending: 'Pending',
|
||||
/**通过 */
|
||||
Passed: 'Passed',
|
||||
/**已拒绝 */
|
||||
Declined: 'Declined',
|
||||
/**已同意 */
|
||||
Passed: 'Passed',
|
||||
/**已拉黑 */
|
||||
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 { CHAT_TYPE } from "../constants/MessageType";
|
||||
|
||||
export const messageHandler = (msg) => {
|
||||
export const messageHandler = async (msg) => {
|
||||
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;
|
||||
if (conversation.targetId == msg.receiverId) {
|
||||
conversation.unreadCount = 0;
|
||||
} else {
|
||||
conversation.unreadCount += 1;
|
||||
|
||||
let 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);
|
||||
}
|
||||
conversation.dateTime = new Date().toISOString();
|
||||
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;
|
||||
});
|
||||
}
|
||||
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 = [
|
||||
{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') },
|
||||
{ path: '/auth/register', component: () => import('@/views/auth/Register.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: MainView,
|
||||
@@ -53,10 +54,21 @@ const routes = [
|
||||
component: () => import('@/views/contact/UserInfoContent.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/contacts/group/:id',
|
||||
name: 'groupInfo',
|
||||
component: () => import('@/views/contact/GroupInfoContent.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/contacts/requests',
|
||||
name: 'friendRequests',
|
||||
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: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
|
||||
},
|
||||
{
|
||||
path: '/videopre', component: () => import('@/components/electron/VideoPreview.vue')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,97 +1,149 @@
|
||||
import axios from 'axios'
|
||||
import { useMessage } from '@/components/messages/useAlert';
|
||||
import router from '@/router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { authService } from './auth';
|
||||
import { ref } from 'vue'
|
||||
import { useMessage } from '@/components/messages/useAlert'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authService } from './auth'
|
||||
|
||||
const message = useMessage();
|
||||
const message = useMessage()
|
||||
|
||||
let waitqueue = [];
|
||||
let isRefreshing = false;
|
||||
const authURL = ['/auth/login', '/auth/register', '/auth/refresh'];
|
||||
let waitqueue = []
|
||||
let isRefreshing = false
|
||||
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({
|
||||
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,
|
||||
headers: {
|
||||
|
||||
}
|
||||
headers: {}
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const authStore = useAuthStore();
|
||||
const token = authStore.token;
|
||||
(config) => {
|
||||
const authStore = useAuthStore()
|
||||
const token = authStore.token
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
err => {
|
||||
return Promise.reject(err);
|
||||
(err) => {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
async err => {
|
||||
const authStore = useAuthStore();
|
||||
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;
|
||||
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;
|
||||
// 已经在刷新 → 排队
|
||||
config._retry = true
|
||||
if (isRefreshing) {
|
||||
return new Promise(resolve => {
|
||||
waitqueue.push(token => {
|
||||
return new Promise((resolve, reject) => {
|
||||
waitqueue.push({
|
||||
resolve: (token) => {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
resolve(api(config))
|
||||
},
|
||||
reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
const refreshToken = authStore.refreshToken;
|
||||
if (refreshToken != null && refreshToken != '') {
|
||||
const refreshToken = authStore.refreshToken
|
||||
if (!refreshToken) {
|
||||
redirectToLogin()
|
||||
return Promise.reject(originalError)
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
try {
|
||||
const res = await authService.refresh(refreshToken)
|
||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
||||
waitqueue.forEach(cb => cb(authStore.token));
|
||||
waitqueue = [];
|
||||
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
|
||||
}
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
case 400:
|
||||
if (response.data && response.data.code == 1003) {
|
||||
message.error(response.data.message);
|
||||
break;
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// 任何成功的响应都清除网络错误状态
|
||||
clearNetworkError()
|
||||
// 滚动发布期间兼容旧后端的 HTTP 200 + code=1006。
|
||||
if (response.data?.code === 1006) {
|
||||
return refreshAndRetry(response.config, response.data)
|
||||
}
|
||||
default:
|
||||
message.error('请求错误,请检查网络。');
|
||||
break;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
} else {
|
||||
message.error('请求错误,请检查网络。');
|
||||
return Promise.reject(err);
|
||||
return response.data
|
||||
},
|
||||
async (err) => {
|
||||
const { config, response } = err
|
||||
|
||||
// 无响应 → 网络不通
|
||||
if (!response) {
|
||||
setNetworkError('网络连接异常')
|
||||
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),
|
||||
put: (url, data, config) => api.put(url, data, config),
|
||||
delete: (url, config) => api.delete(url, config),
|
||||
instance: api,
|
||||
};
|
||||
instance: api
|
||||
}
|
||||
|
||||
@@ -3,21 +3,22 @@ import { request } from "./api";
|
||||
export const authService = {
|
||||
/**
|
||||
* 用户登录接口
|
||||
* @param {*} data
|
||||
* @returns
|
||||
* @param {{ userName, password }} data
|
||||
* @returns Result<LoginResponse>
|
||||
*/
|
||||
login: (data) => request.post('/auth/login', data),
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* @param {*} data
|
||||
* @returns
|
||||
* @param {{ userName, password, nickName }} data
|
||||
* @returns Result<UserResponse>
|
||||
*/
|
||||
register: (data) => request.post('/auth/register', data),
|
||||
|
||||
/**
|
||||
* 刷新用户凭证
|
||||
* @param {*} data
|
||||
* @returns
|
||||
* @param {string} refreshToken
|
||||
* @returns Result<LoginResponse>
|
||||
*/
|
||||
refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken })
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user