Compare commits
17
Commits
c1fe603a34
...
c6ba7ce4c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ba7ce4c2 | ||
|
|
0b3772dc32 | ||
|
|
465361dd10 | ||
|
|
f1af6e6bc6 | ||
|
|
8d952578de | ||
|
|
cf8be0bff5 | ||
|
|
f7772a9c5a | ||
|
|
feed24f378 | ||
|
|
20013ba6bd | ||
|
|
536b360127 | ||
|
|
9e7609d6b4 | ||
|
|
d1db5e6490 | ||
|
|
eb8455e141 | ||
|
|
f7223dc590 | ||
|
|
e38c15ed92 | ||
|
|
faaaaaa1fa | ||
|
|
fef4bbda70 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2ecaa28091b41de707825db3628d380b62fa727f")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8d952578de8d3725b4fcad34056ce8cc01e73665")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
|
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
ed4980dfc7aff253176b260ed9015f9a80b52e92cbf3095eff3ed06865ea6e0d
|
894ce5acc8690cb041ab936657887727ab47ceff5777afb7e84a8a31d3de838c
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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> {
|
var usersList = new List<string> {
|
||||||
@event.RequestUserId.ToString(), @event.ResponseUserId.ToString()
|
@event.RequestUserId.ToString(), @event.ResponseUserId.ToString()
|
||||||
};
|
};
|
||||||
var res = new HubResponse<MessageBaseDto>("Event", new MessageBaseDto()
|
var res = new HubResponse<MessageBaseDto>(HubResponseType.FriendAccepted, new MessageBaseDto()
|
||||||
{
|
{
|
||||||
ChatType = ChatType.PRIVATE,
|
ChatType = ChatType.PRIVATE,
|
||||||
Content = "您有新的好友关系已添加",
|
Content = "您有新的好友关系已添加",
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ namespace IM_API.Application.EventHandlers.GroupInviteActionUpdateHandler
|
|||||||
public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context)
|
public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context)
|
||||||
{
|
{
|
||||||
var @event = context.Message;
|
var @event = context.Message;
|
||||||
if(@event.Action == Models.GroupInviteState.Passed)
|
if(@event.Action == Models.GroupRequestState.TargetPassed)
|
||||||
{
|
{
|
||||||
await _groupService.MakeGroupRequestAsync(@event.UserId, @event.InviteUserId,@event.GroupId);
|
await _groupService.MakeGroupRequestAsync(@event.UserId, @event.InviteUserId,@event.GroupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ namespace IM_API.Configs
|
|||||||
CreateMap<RegisterRequestDto, User>()
|
CreateMap<RegisterRequestDto, User>()
|
||||||
.ForMember(dest => dest.Username,opt => opt.MapFrom(src => src.Username))
|
.ForMember(dest => dest.Username,opt => opt.MapFrom(src => src.Username))
|
||||||
.ForMember(dest => dest.Password,opt => opt.MapFrom(src => src.Password))
|
.ForMember(dest => dest.Password,opt => opt.MapFrom(src => src.Password))
|
||||||
|
.ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email))
|
||||||
.ForMember(dest => dest.Avatar,opt => opt.MapFrom(src => "https://ts1.tc.mm.bing.net/th/id/OIP-C.dl0WpkTP6E2J4FnhDC_jHwAAAA?rs=1&pid=ImgDetMain&o=7&rm=3"))
|
.ForMember(dest => dest.Avatar,opt => opt.MapFrom(src => "https://ts1.tc.mm.bing.net/th/id/OIP-C.dl0WpkTP6E2J4FnhDC_jHwAAAA?rs=1&pid=ImgDetMain&o=7&rm=3"))
|
||||||
.ForMember(dest => dest.StatusEnum,opt => opt.MapFrom(src => UserStatus.Normal))
|
.ForMember(dest => dest.StatusEnum,opt => opt.MapFrom(src => UserStatus.Normal))
|
||||||
.ForMember(dest => dest.OnlineStatusEnum,opt => opt.MapFrom(src => UserOnlineStatus.Offline))
|
.ForMember(dest => dest.OnlineStatusEnum,opt => opt.MapFrom(src => UserOnlineStatus.Offline))
|
||||||
@@ -215,6 +216,34 @@ namespace IM_API.Configs
|
|||||||
CreateMap<GroupMember, GroupMemberVo>()
|
CreateMap<GroupMember, GroupMemberVo>()
|
||||||
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.Created))
|
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.Created))
|
||||||
.ForMember(dest => dest.Role, opt => opt.MapFrom(src => src.RoleEnum));
|
.ForMember(dest => dest.Role, opt => opt.MapFrom(src => src.RoleEnum));
|
||||||
|
|
||||||
|
//群更新模型
|
||||||
|
|
||||||
|
CreateMap<GroupUpdateDto, Group>()
|
||||||
|
.ForMember(dest => dest.Avatar, opt => opt.MapFrom(src => src.Avatar))
|
||||||
|
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.GroupName))
|
||||||
|
.ForMember(dest => dest.Announcement, opt => opt.MapFrom(src => src.Description))
|
||||||
|
.ForAllMembers(dest => dest.Ignore());
|
||||||
|
|
||||||
|
CreateMap<Group, GroupInfoVo>()
|
||||||
|
.ForMember(dest => dest.Auhority, opt => opt.MapFrom(src => src.AuhorityEnum))
|
||||||
|
.ForMember(dest => dest.AllMembersBanned, opt => opt.MapFrom(src => src.AllMembersBannedEnum))
|
||||||
|
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusEnum))
|
||||||
|
;
|
||||||
|
|
||||||
|
//群通知模型转换
|
||||||
|
CreateMap<GroupRequest, GroupNotificationVo>()
|
||||||
|
.ForMember(dest => dest.UserId, opt => opt.MapFrom(src => src.UserId))
|
||||||
|
.ForMember(dest => dest.GroupId, opt => opt.MapFrom(src => src.GroupId))
|
||||||
|
.ForMember(dest => dest.InviteUser, opt => opt.MapFrom(src => src.InviteUserId))
|
||||||
|
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StateEnum))
|
||||||
|
.ForMember(dest => dest.Description, opt => opt.MapFrom(src => src.Description))
|
||||||
|
.ForMember(dest => dest.RequestId, opt => opt.MapFrom(src => src.Id))
|
||||||
|
//.ForAllMembers(opt => opt.Ignore())
|
||||||
|
;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,33 @@ namespace IM_API.Controllers
|
|||||||
var members = await _groupService.GetGroupMembers(int.Parse(useridStr), groupId);
|
var members = await _groupService.GetGroupMembers(int.Parse(useridStr), groupId);
|
||||||
return Ok(new BaseResponse<List<GroupMemberVo>>(members));
|
return Ok(new BaseResponse<List<GroupMemberVo>>(members));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(BaseResponse<GroupInfoVo>), StatusCodes.Status200OK)]
|
||||||
|
|
||||||
|
public async Task<IActionResult> UpdateGroup([FromQuery]int groupId, [FromBody]GroupUpdateDto dto)
|
||||||
|
{
|
||||||
|
var useridStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
||||||
|
var groupinfo = await _groupService.UpdateGroupInfoAsync(int.Parse(useridStr), groupId, dto);
|
||||||
|
return Ok(new BaseResponse<GroupInfoVo>(groupinfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(BaseResponse<GroupInfoVo>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetGroupInfo([FromQuery]int groupId)
|
||||||
|
{
|
||||||
|
var group = await _groupService.GetGroupInfoAsync(groupId);
|
||||||
|
return Ok(new BaseResponse<GroupInfoVo>(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(BaseResponse<List<GroupNotificationVo>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetGroupNotification([FromQuery]int groupId)
|
||||||
|
{
|
||||||
|
string userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
||||||
|
var data = await _groupService.GetGroupNotificationAsync(int.Parse(userIdStr));
|
||||||
|
return Ok(new BaseResponse<List<GroupNotificationVo>>(data));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ namespace IM_API.Domain.Events
|
|||||||
public int InviteUserId { get; set; }
|
public int InviteUserId { get; set; }
|
||||||
public int InviteId { get; set; }
|
public int InviteId { get; set; }
|
||||||
public int GroupId { get; set; }
|
public int GroupId { get; set; }
|
||||||
public GroupInviteState Action { get; set; }
|
public GroupRequestState Action { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace IM_API.Dtos.Auth
|
|||||||
{
|
{
|
||||||
public class RegisterRequestDto
|
public class RegisterRequestDto
|
||||||
{
|
{
|
||||||
|
[EmailAddress(ErrorMessage = "邮箱格式错误")]
|
||||||
|
public string Email { get; set; }
|
||||||
[Required(ErrorMessage = "用户名不能为空")]
|
[Required(ErrorMessage = "用户名不能为空")]
|
||||||
[MaxLength(20, ErrorMessage = "用户名不能超过20字符")]
|
[MaxLength(20, ErrorMessage = "用户名不能超过20字符")]
|
||||||
[RegularExpression(@"^[A-Za-z0-9]+$", ErrorMessage = "用户名只能为英文或数字")]
|
[RegularExpression(@"^[A-Za-z0-9]+$", ErrorMessage = "用户名只能为英文或数字")]
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace IM_API.Dtos.Group
|
||||||
|
{
|
||||||
|
public class GroupUpdateDto
|
||||||
|
{
|
||||||
|
public string? GroupName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Avatar { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,6 @@ namespace IM_API.Dtos.Group
|
|||||||
public class HandleGroupInviteDto
|
public class HandleGroupInviteDto
|
||||||
{
|
{
|
||||||
public int InviteId { get; set; }
|
public int InviteId { get; set; }
|
||||||
public GroupInviteState Action { get; set; }
|
public GroupRequestState Action { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,44 @@ namespace IM_API.Dtos
|
|||||||
Message = codeDefine.Message;
|
Message = codeDefine.Message;
|
||||||
Data = data;
|
Data = data;
|
||||||
}
|
}
|
||||||
|
public HubResponse(HubResponseType type, T? data)
|
||||||
|
{
|
||||||
|
Code = CodeDefine.SUCCESS.Code;
|
||||||
|
Method = "Event";
|
||||||
|
Type = type;
|
||||||
|
Data = data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public enum HubResponseType
|
public enum HubResponseType
|
||||||
{
|
{
|
||||||
ChatMsg = 1, // 聊天内容
|
// --- 基础聊天 (10-19) ---
|
||||||
SystemNotice = 2, // 系统通知(如:申请好友成功)
|
ChatMsg = 1, // 普通消息
|
||||||
ActionStatus = 3 // 状态变更(如:对方正在输入、已读回执)
|
|
||||||
|
// --- 系统与通知 (20-29) ---
|
||||||
|
SystemNotice = 2, // 通用系统通知
|
||||||
|
FriendRequest = 21, // 好友申请消息
|
||||||
|
FriendAccepted = 22, // 好友通过通知
|
||||||
|
UserInLine = 23, // 好友上线/下线通知
|
||||||
|
|
||||||
|
// --- 状态变更与交互 (30-39) ---
|
||||||
|
ActionStatus = 3, // 通用状态(正在输入等)
|
||||||
|
MsgReadReceipt = 31, // 已读回执
|
||||||
|
MsgRevoke = 32, // 消息撤回
|
||||||
|
MsgEdit = 33, // 消息二次编辑更新
|
||||||
|
|
||||||
|
// --- 群组管理 (40-49) ---
|
||||||
|
GroupInvited = 41, // 被邀请入群
|
||||||
|
GroupMemberUpdate = 42, // 群成员变动(进群/退群)
|
||||||
|
GroupAnnouncement = 43, // 群公告更新
|
||||||
|
GroupDismissed = 44, // 群组解散
|
||||||
|
|
||||||
|
// --- 实时通信控制 (50-59) ---
|
||||||
|
RTC_CallRequest = 51, // 音视频通话邀请
|
||||||
|
RTC_CallHandled = 52, // 音视频接听/挂断/取消状态
|
||||||
|
|
||||||
|
// --- 异常与安全 (90-99) ---
|
||||||
|
ErrorInternal = 91, // 服务器内部错误
|
||||||
|
TokenExpired = 92, // 登录过期,强制下线
|
||||||
|
KickedOut = 93 // 被挤下线(异地登录)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/Environment/Hierarchy/Build/BuildTool/RecentDotNetCliExePaths/=_002Fhome_002Fnanxun_002Fdotnet_002Fdotnet/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/nanxun/dotnet/dotnet</s:String>
|
||||||
|
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/nanxun/dotnet/sdk/10.0.201/MSBuild.dll</s:String></wpf:ResourceDictionary>
|
||||||
@@ -53,5 +53,15 @@ namespace IM_API.Interface.Services
|
|||||||
Task MakeGroupRequestAsync(int userId,int? adminUserId,int groupId);
|
Task MakeGroupRequestAsync(int userId,int? adminUserId,int groupId);
|
||||||
Task MakeGroupMemberAsync(int userId, int groupId, GroupMemberRole? role);
|
Task MakeGroupMemberAsync(int userId, int groupId, GroupMemberRole? role);
|
||||||
Task<List<GroupMemberVo>> GetGroupMembers(int userId, int groupId);
|
Task<List<GroupMemberVo>> GetGroupMembers(int userId, int groupId);
|
||||||
|
Task<GroupInfoVo> UpdateGroupInfoAsync(int userId, int groupId, GroupUpdateDto updateDto);
|
||||||
|
|
||||||
|
Task<GroupInfoVo> GetGroupInfoAsync(int groupId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取群聊通知
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<List<GroupNotificationVo>> GetGroupNotificationAsync(int userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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全员禁言)");
|
.HasComment("全员禁言(0允许发言,2全员禁言)");
|
||||||
|
|
||||||
b.Property<string>("Announcement")
|
b.Property<string>("Announcement")
|
||||||
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasComment("群公告");
|
.HasComment("群公告");
|
||||||
|
|
||||||
@@ -392,50 +393,6 @@ namespace IM_API.Migrations
|
|||||||
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
|
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("IM_API.Models.GroupInvite", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int(11)")
|
|
||||||
.HasColumnName("ID");
|
|
||||||
|
|
||||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("Created")
|
|
||||||
.HasColumnType("datetime")
|
|
||||||
.HasComment("创建时间");
|
|
||||||
|
|
||||||
b.Property<int>("GroupId")
|
|
||||||
.HasColumnType("int(11)")
|
|
||||||
.HasComment("群聊编号");
|
|
||||||
|
|
||||||
b.Property<int?>("InviteUser")
|
|
||||||
.HasColumnType("int(11)")
|
|
||||||
.HasComment("邀请用户");
|
|
||||||
|
|
||||||
b.Property<int?>("InvitedUser")
|
|
||||||
.HasColumnType("int(11)")
|
|
||||||
.HasComment("被邀请用户");
|
|
||||||
|
|
||||||
b.Property<sbyte?>("State")
|
|
||||||
.HasColumnType("tinyint(4)")
|
|
||||||
.HasComment("当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)");
|
|
||||||
|
|
||||||
b.HasKey("Id")
|
|
||||||
.HasName("PRIMARY");
|
|
||||||
|
|
||||||
b.HasIndex(new[] { "GroupId" }, "GroupId");
|
|
||||||
|
|
||||||
b.HasIndex(new[] { "InviteUser" }, "InviteUser");
|
|
||||||
|
|
||||||
b.HasIndex(new[] { "InvitedUser" }, "InvitedUser");
|
|
||||||
|
|
||||||
b.ToTable("group_invite", (string)null);
|
|
||||||
|
|
||||||
MySqlEntityTypeBuilderExtensions.HasCharSet(b, "utf8mb4");
|
|
||||||
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("IM_API.Models.GroupMember", b =>
|
modelBuilder.Entity("IM_API.Models.GroupMember", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -502,6 +459,9 @@ namespace IM_API.Migrations
|
|||||||
.HasColumnType("int(11)")
|
.HasColumnType("int(11)")
|
||||||
.HasComment("群聊编号\r\n");
|
.HasComment("群聊编号\r\n");
|
||||||
|
|
||||||
|
b.Property<int?>("InviteUserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<sbyte>("State")
|
b.Property<sbyte>("State")
|
||||||
.HasColumnType("tinyint(4)")
|
.HasColumnType("tinyint(4)")
|
||||||
.HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)");
|
.HasComment("申请状态(0:待管理员同意,1:已拒绝,2:已同意)");
|
||||||
@@ -515,8 +475,7 @@ namespace IM_API.Migrations
|
|||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
b.HasIndex(new[] { "GroupId" }, "GroupId")
|
b.HasIndex(new[] { "GroupId" }, "GroupId");
|
||||||
.HasDatabaseName("GroupId1");
|
|
||||||
|
|
||||||
b.ToTable("group_request", (string)null);
|
b.ToTable("group_request", (string)null);
|
||||||
|
|
||||||
@@ -844,6 +803,12 @@ namespace IM_API.Migrations
|
|||||||
.HasDefaultValueSql("'1970-01-01 00:00:00'")
|
.HasDefaultValueSql("'1970-01-01 00:00:00'")
|
||||||
.HasComment("创建时间");
|
.HasComment("创建时间");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<sbyte>("IsDeleted")
|
b.Property<sbyte>("IsDeleted")
|
||||||
.HasColumnType("tinyint(4)")
|
.HasColumnType("tinyint(4)")
|
||||||
.HasComment("软删除标识\r\n0:账号正常\r\n1:账号已删除");
|
.HasComment("软删除标识\r\n0:账号正常\r\n1:账号已删除");
|
||||||
@@ -863,6 +828,9 @@ namespace IM_API.Migrations
|
|||||||
.HasColumnType("varchar(50)")
|
.HasColumnType("varchar(50)")
|
||||||
.HasComment("密码");
|
.HasComment("密码");
|
||||||
|
|
||||||
|
b.Property<string>("Region")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<sbyte>("Status")
|
b.Property<sbyte>("Status")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("tinyint(4)")
|
.HasColumnType("tinyint(4)")
|
||||||
@@ -991,31 +959,6 @@ namespace IM_API.Migrations
|
|||||||
b.Navigation("GroupMasterNavigation");
|
b.Navigation("GroupMasterNavigation");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("IM_API.Models.GroupInvite", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("IM_API.Models.Group", "Group")
|
|
||||||
.WithMany("GroupInvites")
|
|
||||||
.HasForeignKey("GroupId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasConstraintName("group_invite_ibfk_2");
|
|
||||||
|
|
||||||
b.HasOne("IM_API.Models.User", "InviteUserNavigation")
|
|
||||||
.WithMany("GroupInviteInviteUserNavigations")
|
|
||||||
.HasForeignKey("InviteUser")
|
|
||||||
.HasConstraintName("group_invite_ibfk_1");
|
|
||||||
|
|
||||||
b.HasOne("IM_API.Models.User", "InvitedUserNavigation")
|
|
||||||
.WithMany("GroupInviteInvitedUserNavigations")
|
|
||||||
.HasForeignKey("InvitedUser")
|
|
||||||
.HasConstraintName("group_invite_ibfk_3");
|
|
||||||
|
|
||||||
b.Navigation("Group");
|
|
||||||
|
|
||||||
b.Navigation("InviteUserNavigation");
|
|
||||||
|
|
||||||
b.Navigation("InvitedUserNavigation");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("IM_API.Models.GroupMember", b =>
|
modelBuilder.Entity("IM_API.Models.GroupMember", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("IM_API.Models.Group", "Group")
|
b.HasOne("IM_API.Models.Group", "Group")
|
||||||
@@ -1108,8 +1051,6 @@ namespace IM_API.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("IM_API.Models.Group", b =>
|
modelBuilder.Entity("IM_API.Models.Group", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("GroupInvites");
|
|
||||||
|
|
||||||
b.Navigation("GroupMembers");
|
b.Navigation("GroupMembers");
|
||||||
|
|
||||||
b.Navigation("GroupRequests");
|
b.Navigation("GroupRequests");
|
||||||
@@ -1148,10 +1089,6 @@ namespace IM_API.Migrations
|
|||||||
|
|
||||||
b.Navigation("FriendUsers");
|
b.Navigation("FriendUsers");
|
||||||
|
|
||||||
b.Navigation("GroupInviteInviteUserNavigations");
|
|
||||||
|
|
||||||
b.Navigation("GroupInviteInvitedUserNavigations");
|
|
||||||
|
|
||||||
b.Navigation("GroupMembers");
|
b.Navigation("GroupMembers");
|
||||||
|
|
||||||
b.Navigation("GroupRequests");
|
b.Navigation("GroupRequests");
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public partial class Group
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 群公告
|
/// 群公告
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Announcement { get; set; }
|
public string Announcement { get; set; } = "暂无群公告,点击编辑添加。";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 群聊创建时间
|
/// 群聊创建时间
|
||||||
@@ -56,7 +56,6 @@ public partial class Group
|
|||||||
public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow;
|
public DateTimeOffset LastUpdateTime { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
|
||||||
public virtual ICollection<GroupInvite> GroupInvites { get; set; } = new List<GroupInvite>();
|
|
||||||
|
|
||||||
public virtual User GroupMasterNavigation { get; set; } = null!;
|
public virtual User GroupMasterNavigation { get; set; } = null!;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
/// </summary>
|
||||||
Pending = 0,
|
Pending = 0,
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 已拒绝
|
/// 管理员已拒绝
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Declined = 1,
|
Declined = 1,
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 已同意
|
/// 管理员已同意
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Passed = 2
|
Passed = 2,
|
||||||
|
/// <summary>
|
||||||
|
/// 待对方同意
|
||||||
|
/// </summary>
|
||||||
|
TargetPending = 3,
|
||||||
|
/// <summary>
|
||||||
|
/// 对方拒绝
|
||||||
|
/// </summary>
|
||||||
|
TargetDeclined = 4,
|
||||||
|
/// <summary>
|
||||||
|
/// 对方同意
|
||||||
|
/// </summary>
|
||||||
|
TargetPassed = 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
/// </summary>
|
||||||
public int UserId { get; set; }
|
public int UserId { get; set; }
|
||||||
|
|
||||||
|
public int? InviteUserId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 申请状态(0:待管理员同意,1:已拒绝,2:已同意)
|
/// 申请状态(0:待管理员同意,1:管理员已拒绝,2:管理员已同意,3:待对方同意,4:对方拒绝)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sbyte State { get; set; }
|
public sbyte State { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,6 @@ namespace IM_API.Models
|
|||||||
entity.Ignore(e => e.AuhorityEnum);
|
entity.Ignore(e => e.AuhorityEnum);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<GroupInvite>(entity =>
|
|
||||||
{
|
|
||||||
entity.Ignore(e => e.StateEnum);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<GroupMember>(entity =>
|
modelBuilder.Entity<GroupMember>(entity =>
|
||||||
{
|
{
|
||||||
entity.Ignore(e => e.RoleEnum);
|
entity.Ignore(e => e.RoleEnum);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public partial class ImContext : DbContext
|
|||||||
|
|
||||||
public virtual DbSet<Group> Groups { get; set; }
|
public virtual DbSet<Group> Groups { get; set; }
|
||||||
|
|
||||||
public virtual DbSet<GroupInvite> GroupInvites { get; set; }
|
|
||||||
|
|
||||||
public virtual DbSet<GroupMember> GroupMembers { get; set; }
|
public virtual DbSet<GroupMember> GroupMembers { get; set; }
|
||||||
|
|
||||||
@@ -362,53 +361,6 @@ public partial class ImContext : DbContext
|
|||||||
.HasConstraintName("groups_ibfk_1");
|
.HasConstraintName("groups_ibfk_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<GroupInvite>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(e => e.Id).HasName("PRIMARY");
|
|
||||||
|
|
||||||
entity
|
|
||||||
.ToTable("group_invite")
|
|
||||||
.HasCharSet("utf8mb4")
|
|
||||||
.UseCollation("utf8mb4_general_ci");
|
|
||||||
|
|
||||||
entity.HasIndex(e => e.GroupId, "GroupId");
|
|
||||||
|
|
||||||
entity.HasIndex(e => e.InviteUser, "InviteUser");
|
|
||||||
|
|
||||||
entity.HasIndex(e => e.InvitedUser, "InvitedUser");
|
|
||||||
|
|
||||||
entity.Property(e => e.Id)
|
|
||||||
.HasColumnType("int(11)")
|
|
||||||
.HasColumnName("ID");
|
|
||||||
entity.Property(e => e.Created)
|
|
||||||
.HasComment("创建时间")
|
|
||||||
.HasColumnType("datetime");
|
|
||||||
entity.Property(e => e.GroupId)
|
|
||||||
.HasComment("群聊编号")
|
|
||||||
.HasColumnType("int(11)");
|
|
||||||
entity.Property(e => e.InviteUser)
|
|
||||||
.HasComment("邀请用户")
|
|
||||||
.HasColumnType("int(11)");
|
|
||||||
entity.Property(e => e.InvitedUser)
|
|
||||||
.HasComment("被邀请用户")
|
|
||||||
.HasColumnType("int(11)");
|
|
||||||
entity.Property(e => e.State)
|
|
||||||
.HasComment("当前状态(0:待被邀请人同意\r\n1:被邀请人已同意)")
|
|
||||||
.HasColumnType("tinyint(4)");
|
|
||||||
|
|
||||||
entity.HasOne(d => d.Group).WithMany(p => p.GroupInvites)
|
|
||||||
.HasForeignKey(d => d.GroupId)
|
|
||||||
.OnDelete(DeleteBehavior.ClientSetNull)
|
|
||||||
.HasConstraintName("group_invite_ibfk_2");
|
|
||||||
|
|
||||||
entity.HasOne(d => d.InviteUserNavigation).WithMany(p => p.GroupInviteInviteUserNavigations)
|
|
||||||
.HasForeignKey(d => d.InviteUser)
|
|
||||||
.HasConstraintName("group_invite_ibfk_1");
|
|
||||||
|
|
||||||
entity.HasOne(d => d.InvitedUserNavigation).WithMany(p => p.GroupInviteInvitedUserNavigations)
|
|
||||||
.HasForeignKey(d => d.InvitedUser)
|
|
||||||
.HasConstraintName("group_invite_ibfk_3");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<GroupMember>(entity =>
|
modelBuilder.Entity<GroupMember>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ public partial class User
|
|||||||
/// 用户昵称
|
/// 用户昵称
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 用户邮箱
|
||||||
|
/// </summary>
|
||||||
|
public string? Email { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 用户签名
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; } = "";
|
||||||
|
/// <summary>
|
||||||
|
/// 地区
|
||||||
|
/// </summary>
|
||||||
|
public string? Region { get; set; } = "未知地区";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户在线状态
|
/// 用户在线状态
|
||||||
@@ -73,10 +85,6 @@ public partial class User
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>();
|
public virtual ICollection<Friend> FriendUsers { get; set; } = new List<Friend>();
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public virtual ICollection<GroupInvite> GroupInviteInviteUserNavigations { get; set; } = new List<GroupInvite>();
|
|
||||||
[JsonIgnore]
|
|
||||||
public virtual ICollection<GroupInvite> GroupInviteInvitedUserNavigations { get; set; } = new List<GroupInvite>();
|
|
||||||
[JsonIgnore]
|
|
||||||
public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>();
|
public virtual ICollection<GroupMember> GroupMembers { get; set; } = new List<GroupMember>();
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>();
|
public virtual ICollection<GroupRequest> GroupRequests { get; set; } = new List<GroupRequest>();
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ namespace IM_API.Services
|
|||||||
|
|
||||||
public async Task MakeConversationAsync(int userAId, int userBId, ChatType chatType)
|
public async Task MakeConversationAsync(int userAId, int userBId, ChatType chatType)
|
||||||
{
|
{
|
||||||
var userAcExist = await _context.Conversations.AnyAsync(x => x.UserId == userAId && x.TargetId == userBId);
|
var userAcExist = await _context.Conversations.AnyAsync(
|
||||||
|
x => x.UserId == userAId && x.TargetId == userBId && x.ChatType == chatType);
|
||||||
if (userAcExist) return;
|
if (userAcExist) return;
|
||||||
var streamKey = chatType == ChatType.PRIVATE ?
|
var streamKey = chatType == ChatType.PRIVATE ?
|
||||||
StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId);
|
StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ namespace IM_API.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public Task DeleteGroupAsync(int userId, int groupId)
|
public Task DeleteGroupAsync(int userId, int groupId)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
@@ -90,15 +91,15 @@ namespace IM_API.Services
|
|||||||
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
|
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
|
||||||
//过滤非好友
|
//过滤非好友
|
||||||
var groupInviteIds = await validFriendshipAsync(userId, userIds);
|
var groupInviteIds = await validFriendshipAsync(userId, userIds);
|
||||||
var inviteList = groupInviteIds.Select(id => new GroupInvite
|
var inviteList = groupInviteIds.Select(id => new GroupRequest
|
||||||
{
|
{
|
||||||
Created = DateTime.UtcNow,
|
Created = DateTime.UtcNow,
|
||||||
GroupId = group.Id,
|
GroupId = group.Id,
|
||||||
InviteUser = userId,
|
UserId = id,
|
||||||
InvitedUser = id,
|
InviteUserId = userId,
|
||||||
StateEnum = GroupInviteState.Pending
|
StateEnum = GroupRequestState.TargetPending
|
||||||
}).ToList();
|
}).ToList();
|
||||||
_context.GroupInvites.AddRange(inviteList);
|
_context.GroupRequests.AddRange(inviteList);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
await _endPoint.Publish(new GroupInviteEvent
|
await _endPoint.Publish(new GroupInviteEvent
|
||||||
{
|
{
|
||||||
@@ -132,7 +133,7 @@ namespace IM_API.Services
|
|||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page, int limit, bool desc)
|
public async Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page = 1, int limit = 50, bool desc = false)
|
||||||
{
|
{
|
||||||
var query = _context.GroupMembers
|
var query = _context.GroupMembers
|
||||||
.Where(x => x.UserId == userId)
|
.Where(x => x.UserId == userId)
|
||||||
@@ -159,14 +160,18 @@ namespace IM_API.Services
|
|||||||
_context.Groups.Update(group);
|
_context.Groups.Update(group);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto)
|
public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto)
|
||||||
{
|
{
|
||||||
var user = _userService.GetUserInfoAsync(userid);
|
var user = _userService.GetUserInfoAsync(userid);
|
||||||
var inviteInfo = await _context.GroupInvites.FirstOrDefaultAsync(x => x.Id == dto.InviteId)
|
var inviteInfo = await _context.GroupRequests
|
||||||
|
.FirstOrDefaultAsync(x => x.UserId == userid && x.StateEnum == GroupRequestState.TargetPending)
|
||||||
?? throw new BaseException(CodeDefine.INVALID_ACTION);
|
?? throw new BaseException(CodeDefine.INVALID_ACTION);
|
||||||
if (inviteInfo.InvitedUser != userid) throw new BaseException(CodeDefine.AUTH_FAILED);
|
if (!(dto.Action == GroupRequestState.TargetPassed ||
|
||||||
|
dto.Action == GroupRequestState.TargetDeclined))
|
||||||
|
return;
|
||||||
inviteInfo.StateEnum = dto.Action;
|
inviteInfo.StateEnum = dto.Action;
|
||||||
_context.GroupInvites.Update(inviteInfo);
|
_context.GroupRequests.Update(inviteInfo);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
await _endPoint.Publish(new GroupInviteActionUpdateEvent
|
await _endPoint.Publish(new GroupInviteActionUpdateEvent
|
||||||
{
|
{
|
||||||
@@ -176,12 +181,13 @@ namespace IM_API.Services
|
|||||||
EventId = Guid.NewGuid(),
|
EventId = Guid.NewGuid(),
|
||||||
GroupId = inviteInfo.GroupId,
|
GroupId = inviteInfo.GroupId,
|
||||||
InviteId = inviteInfo.Id,
|
InviteId = inviteInfo.Id,
|
||||||
InviteUserId = inviteInfo.InviteUser.Value,
|
InviteUserId = inviteInfo.InviteUserId!.Value,
|
||||||
OperatorId = userid,
|
OperatorId = userid,
|
||||||
UserId = userid
|
UserId = userid
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto)
|
public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto)
|
||||||
{
|
{
|
||||||
var user = _userService.GetUserInfoAsync(userid);
|
var user = _userService.GetUserInfoAsync(userid);
|
||||||
@@ -266,5 +272,104 @@ namespace IM_API.Services
|
|||||||
return user;
|
return user;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<GroupInfoVo> UpdateGroupInfoAsync(int userId, int groupId, GroupUpdateDto updateDto)
|
||||||
|
{
|
||||||
|
//判断群存在
|
||||||
|
var groupInfo = await _context.Groups.FirstOrDefaultAsync(x => x.Id == groupId);
|
||||||
|
if (groupInfo is null)
|
||||||
|
throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
|
||||||
|
|
||||||
|
//判断操作者权限
|
||||||
|
var memberInfo = await _context.GroupMembers
|
||||||
|
.FirstOrDefaultAsync(x => x.UserId == userId && x.GroupId == groupId);
|
||||||
|
|
||||||
|
if (memberInfo is null || memberInfo.RoleEnum == GroupMemberRole.Normal)
|
||||||
|
throw new BaseException(CodeDefine.NO_GROUP_PERMISSION);
|
||||||
|
|
||||||
|
groupInfo.Name = updateDto.GroupName ?? groupInfo.Name;
|
||||||
|
groupInfo.Avatar = updateDto.Avatar ?? groupInfo.Avatar;
|
||||||
|
groupInfo.Announcement = updateDto.Description ?? groupInfo.Announcement;
|
||||||
|
|
||||||
|
|
||||||
|
_context.Groups.Update(groupInfo);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
return _mapper.Map<GroupInfoVo>(groupInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GroupInfoVo> GetGroupInfoAsync(int groupId)
|
||||||
|
{
|
||||||
|
var groupInfo = await _context.Groups.FirstOrDefaultAsync(x => x.Id == groupId);
|
||||||
|
|
||||||
|
if (groupInfo is null)
|
||||||
|
throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
|
||||||
|
|
||||||
|
return _mapper.Map<GroupInfoVo>(groupInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<GroupNotificationVo>> GetGroupNotificationAsync(int userId)
|
||||||
|
{
|
||||||
|
// 1. 查询群请求记录
|
||||||
|
var groupList = await _context.GroupMembers
|
||||||
|
.Where(x => x.UserId == userId &&
|
||||||
|
(x.Role == (sbyte)GroupMemberRole.Master || x.Role == (sbyte)GroupMemberRole.Administrator))
|
||||||
|
.Select(s => s.GroupId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var groupRequest = await _context.GroupRequests
|
||||||
|
.Where(x => groupList.Contains(x.GroupId) || x.UserId == userId || x.InviteUserId == userId)
|
||||||
|
.OrderByDescending(o => o.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!groupRequest.Any()) return new List<GroupNotificationVo>();
|
||||||
|
|
||||||
|
// 2. 收集所有需要的 ID 并去重
|
||||||
|
var userIds = groupRequest.Select(s => s.UserId).Distinct().ToList();
|
||||||
|
var inviteUserIds = groupRequest.Where(x => x.InviteUserId != null).Select(s => s.InviteUserId.Value).Distinct().ToList();
|
||||||
|
var groupIds = groupRequest.Select(s => s.GroupId).Distinct().ToList();
|
||||||
|
|
||||||
|
var userList = await _userService.GetUserInfoListAsync(userIds);
|
||||||
|
var inviteUserList = await _userService.GetUserInfoListAsync(inviteUserIds);
|
||||||
|
var groupInfoList = await _context.Groups
|
||||||
|
.Where(x => groupIds.Contains(x.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// 2. 转换为字典
|
||||||
|
var userDict = userList.ToDictionary(u => u.Id);
|
||||||
|
var inviteUserDict = inviteUserList.ToDictionary(u => u.Id);
|
||||||
|
var groupDict = groupInfoList.ToDictionary(g => g.Id);
|
||||||
|
|
||||||
|
// 3. 组装数据 (Select 逻辑不变)
|
||||||
|
return groupRequest.Select(g =>
|
||||||
|
{
|
||||||
|
var gnv = _mapper.Map<GroupNotificationVo>(g);
|
||||||
|
|
||||||
|
// 匹配用户信息
|
||||||
|
if (userDict.TryGetValue(g.UserId, out var u))
|
||||||
|
{
|
||||||
|
gnv.UserAvatar = u.Avatar;
|
||||||
|
gnv.NickName = u.NickName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配邀请人信息
|
||||||
|
if (g.InviteUserId.HasValue && inviteUserDict.TryGetValue(g.InviteUserId.Value, out var i))
|
||||||
|
{
|
||||||
|
gnv.InviteUserAvatar = i.Avatar;
|
||||||
|
gnv.InviteUserNickname = i.NickName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配群信息
|
||||||
|
if (groupDict.TryGetValue(g.GroupId, out var gi))
|
||||||
|
{
|
||||||
|
gnv.GroupAvatar = gi.Avatar;
|
||||||
|
gnv.GroupName = gi.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return gnv;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using IM_API.Dtos.Group;
|
||||||
|
|
||||||
|
namespace IM_API.VOs.Group
|
||||||
|
{
|
||||||
|
public class GroupInfoVo:GroupInfoDto
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,6 @@ namespace IM_API.VOs.Group
|
|||||||
public int InviteUserId { get; set; }
|
public int InviteUserId { get; set; }
|
||||||
public int InvitedUserId { get; set; }
|
public int InvitedUserId { get; set; }
|
||||||
public int InviteId { get; set; }
|
public int InviteId { get; set; }
|
||||||
public GroupInviteState Action { get; set; }
|
public GroupRequestState Action { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using IM_API.Models;
|
||||||
|
|
||||||
|
namespace IM_API.VOs.Group
|
||||||
|
{
|
||||||
|
public class GroupNotificationVo
|
||||||
|
{
|
||||||
|
public int RequestId { get; set; }
|
||||||
|
public int? UserId { get; set; }
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string? UserAvatar { get; set; }
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public string? GroupAvatar { get; set; }
|
||||||
|
public string? GroupName { get; set; }
|
||||||
|
|
||||||
|
public GroupRequestState Status { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public int? InviteUser { get; set; }
|
||||||
|
public string? InviteUserNickname { get; set; }
|
||||||
|
public string? InviteUserAvatar { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ pluginManagement {
|
|||||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
// 👇 添加这一行,用于下载 Flutter 插件相关的依赖
|
||||||
|
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
|
||||||
|
|
||||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
@@ -21,6 +24,9 @@ pluginManagement {
|
|||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||||
repositories {
|
repositories {
|
||||||
|
// 👇 添加这一行,这是解决你报错的核心!强制项目从这里下载 Flutter 引擎
|
||||||
|
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
|
||||||
|
|
||||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
@@ -31,8 +37,10 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.1.1" apply false
|
// 👇 升级到 8.2.1 修复 Java 21 兼容性 bug
|
||||||
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
|
id("com.android.application") version "8.2.1" apply false
|
||||||
|
// 👇 顺便把 Kotlin 版本也稍微升一下,避免后续出现旧版本警告
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
class AuthInterceptor extends Interceptor{
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
// TODO: implement onRequest
|
||||||
|
super.onRequest(options, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:app/core/network/auth_interceptor.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
class Request {
|
||||||
|
static final Request _instance = Request._internal();
|
||||||
|
factory Request() => _instance;
|
||||||
|
|
||||||
|
late Dio dio;
|
||||||
|
|
||||||
|
Request._internal(){
|
||||||
|
BaseOptions options = BaseOptions(
|
||||||
|
baseUrl: "",
|
||||||
|
responseType: ResponseType.json,
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
);
|
||||||
|
dio = Dio(options);
|
||||||
|
dio.interceptors.addAll([
|
||||||
|
AuthInterceptor()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import 'package:app/features/auth/pages/login_page.dart';
|
import 'package:app/features/auth/pages/login_page.dart';
|
||||||
|
import 'package:app/features/auth/pages/register_page.dart';
|
||||||
import 'package:app/features/home/pages/index_page.dart';
|
import 'package:app/features/home/pages/index_page.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ final appRouter = GoRouter(
|
|||||||
initialLocation: '/auth/login',
|
initialLocation: '/auth/login',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/auth/login', builder: (context, state) => const LoginPage()),
|
GoRoute(path: '/auth/login', builder: (context, state) => const LoginPage()),
|
||||||
|
GoRoute(path: '/auth/register', builder: (context, state) => const RegisterPage()),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
return MainPage(child: child);
|
return MainPage(child: child);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:app/core/constants/app_colors.dart';
|
import 'package:app/core/constants/app_colors.dart';
|
||||||
import 'package:app/features/auth/pages/login_page.dart';
|
import 'package:app/features/auth/pages/login_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:app/core/router/app_router.dart';
|
||||||
|
|
||||||
class LoginPageState extends State<LoginPage> {
|
class LoginPageState extends State<LoginPage> {
|
||||||
@override
|
@override
|
||||||
@@ -11,10 +12,10 @@ class LoginPageState extends State<LoginPage> {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
// TextButton(
|
||||||
onPressed: () {},
|
// onPressed: () {},
|
||||||
child: const Text("找回密码", style: TextStyle(color: Colors.grey)),
|
// child: const Text("找回密码", style: TextStyle(color: Colors.grey)),
|
||||||
),
|
// ),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
@@ -24,23 +25,9 @@ class LoginPageState extends State<LoginPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 1. 品牌Logo/头像区域
|
|
||||||
Container(
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.primaryColor.withOpacity(0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.person_rounded,
|
|
||||||
size: 60,
|
|
||||||
color: AppColors.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
const Text(
|
||||||
"登录您的聊天账号",
|
"登陆账号",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -109,7 +96,9 @@ class LoginPageState extends State<LoginPage> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {},
|
onPressed: () {
|
||||||
|
appRouter.push("/auth/register");
|
||||||
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
"注册账号",
|
"注册账号",
|
||||||
style: TextStyle(color: Color(0xFF576B95)),
|
style: TextStyle(color: Color(0xFF576B95)),
|
||||||
@@ -126,6 +115,13 @@ class LoginPageState extends State<LoginPage> {
|
|||||||
style: TextStyle(color: Color(0xFF576B95)),
|
style: TextStyle(color: Color(0xFF576B95)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text(
|
||||||
|
"找回密码",
|
||||||
|
style: TextStyle(color: Color(0xFF576B95)),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:app/features/auth/pages/register_page.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/constants/app_colors.dart';
|
||||||
|
|
||||||
|
class RegisterPageState extends State<RegisterPage> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// TODO: implement build
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
// TextButton(
|
||||||
|
// onPressed: () {},
|
||||||
|
// child: const Text("找回密码", style: TextStyle(color: Colors.grey)),
|
||||||
|
// ),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
"注册账号",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
|
||||||
|
// 2. 账号输入框 (极简下划线风格)
|
||||||
|
TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "用户名",
|
||||||
|
labelStyle: const TextStyle(color: Colors.grey, fontSize: 14),
|
||||||
|
floatingLabelStyle: const TextStyle(color: AppColors.primaryColor),
|
||||||
|
enabledBorder: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: const UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: AppColors.primaryColor, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
|
||||||
|
// 3. 密码输入框
|
||||||
|
TextField(
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "请输入密码",
|
||||||
|
labelStyle: const TextStyle(color: Colors.grey, fontSize: 14),
|
||||||
|
floatingLabelStyle: const TextStyle(color: AppColors.primaryColor),
|
||||||
|
enabledBorder: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: const UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: AppColors.primaryColor, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
|
||||||
|
// 4. 登录按钮 (圆润大按钮)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
"注 册",
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// 5. 注册/切换登录方式
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text(
|
||||||
|
"已有账号?点我登录",
|
||||||
|
style: TextStyle(color: Color(0xFF576B95)),
|
||||||
|
), // 经典的链接蓝
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
child: VerticalDivider(color: Colors.grey),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text(
|
||||||
|
"找回密码",
|
||||||
|
style: TextStyle(color: Color(0xFF576B95)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
// 6. 底部协议 (社交App必有)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: true,
|
||||||
|
activeColor: AppColors.primaryColor,
|
||||||
|
onChanged: (v) {},
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
"我已阅读并同意",
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
"《用户协议》",
|
||||||
|
style: TextStyle(color: Color(0xFF576B95), fontSize: 12),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
"与",
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
"《隐私政策》",
|
||||||
|
style: TextStyle(color: Color(0xFF576B95), fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:app/features/auth/bloc/register_page_state.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
|
class RegisterPage extends StatefulWidget {
|
||||||
|
const RegisterPage({super.key});
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() {
|
||||||
|
// TODO: implement createState
|
||||||
|
return RegisterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+78
-30
@@ -5,16 +5,16 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.13.1"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: boolean_selector
|
name: boolean_selector
|
||||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
characters:
|
characters:
|
||||||
@@ -22,7 +22,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
clock:
|
clock:
|
||||||
@@ -30,7 +30,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: clock
|
name: clock
|
||||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
collection:
|
collection:
|
||||||
@@ -38,23 +38,39 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: collection
|
name: collection
|
||||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: cupertino_icons
|
name: cupertino_icons
|
||||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.9"
|
||||||
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.2"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
version: "1.3.3"
|
||||||
flutter:
|
flutter:
|
||||||
@@ -67,7 +83,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: flutter_lints
|
name: flutter_lints
|
||||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -85,15 +101,23 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.1.0"
|
version: "17.1.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.0.2"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
@@ -101,7 +125,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.10"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
@@ -109,7 +133,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
@@ -117,7 +141,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
logging:
|
logging:
|
||||||
@@ -125,7 +149,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: logging
|
name: logging
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
@@ -133,7 +157,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
@@ -141,7 +165,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
@@ -149,15 +173,23 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
@@ -170,7 +202,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
@@ -178,7 +210,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.1"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
@@ -186,7 +218,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: stream_channel
|
name: stream_channel
|
||||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
@@ -194,7 +226,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: string_scanner
|
name: string_scanner
|
||||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
@@ -202,7 +234,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: term_glyph
|
name: term_glyph
|
||||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
test_api:
|
test_api:
|
||||||
@@ -210,15 +242,23 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
|
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.8"
|
version: "0.7.8"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
@@ -226,9 +266,17 @@ packages:
|
|||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.4 <4.0.0"
|
dart: ">=3.10.4 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.35.0"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
|
dio: ^5.9.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
+2
-2
@@ -4,5 +4,5 @@
|
|||||||
# VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
|
# VITE_SIGNALR_BASE_URL = https://im.test.nxsir.cn/chat/
|
||||||
|
|
||||||
|
|
||||||
VITE_API_BASE_URL = http://192.168.5.116:7070/api
|
VITE_API_BASE_URL = http://192.168.5.100:8009/api
|
||||||
VITE_SIGNALR_BASE_URL = http://192.168.5.116:7070/chat/
|
VITE_SIGNALR_BASE_URL = http://192.168.5.100:8009/chat
|
||||||
|
|||||||
Vendored
+1
@@ -11,6 +11,7 @@
|
|||||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||||
},
|
},
|
||||||
"runtimeArgs": ["--sourcemap"],
|
"runtimeArgs": ["--sourcemap"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
"env": {
|
"env": {
|
||||||
"REMOTE_DEBUGGING_PORT": "9222"
|
"REMOTE_DEBUGGING_PORT": "9222"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
|||||||
@@ -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 { resolve } from 'path'
|
||||||
import { defineConfig } from 'electron-vite'
|
import { defineConfig } from 'electron-vite'
|
||||||
|
import { loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
main: {},
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
preload: {},
|
// 从 VITE_API_BASE_URL 推导网关源(去掉末尾 /api)
|
||||||
renderer: {
|
const apiBase = env.VITE_API_BASE_URL || 'http://localhost:8009/api'
|
||||||
resolve: {
|
const signalRBase = env.VITE_SIGNALR_BASE_URL || 'http://localhost:8009/chat'
|
||||||
alias: {
|
const gatewayOrigin = new URL(apiBase).origin
|
||||||
'@': resolve('src/renderer/src')
|
const signalROrigin = new URL(signalRBase).origin
|
||||||
}
|
const signalRWebSocketOrigin = signalROrigin.replace(/^http/, 'ws')
|
||||||
},
|
const cspPlugin = {
|
||||||
plugins: [vue()]
|
name: 'environment-csp',
|
||||||
|
transformIndexHtml: (html) => html
|
||||||
|
.replaceAll('__API_ORIGIN__', gatewayOrigin)
|
||||||
|
.replaceAll('__SIGNALR_ORIGIN__', signalROrigin)
|
||||||
|
.replaceAll('__SIGNALR_WS_ORIGIN__', signalRWebSocketOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
main: {},
|
||||||
|
preload: {},
|
||||||
|
renderer: {
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
// 开发环境代理,规避浏览器 CORS(Electron 内不受影响)
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: gatewayOrigin, changeOrigin: true },
|
||||||
|
'/chat': { target: gatewayOrigin, changeOrigin: true, ws: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve('src/renderer/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [vue(), vueDevTools(), cspPlugin]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+994
-7
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
|
"test": "vitest run",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"build:win": "npm run build && electron-builder --win",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@vuelidate/core": "^2.0.3",
|
"@vuelidate/core": "^2.0.3",
|
||||||
"@vuelidate/validators": "^2.0.4",
|
"@vuelidate/validators": "^2.0.4",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"crypto": "^1.0.1",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"feather-icons": "^4.29.2",
|
"feather-icons": "^4.29.2",
|
||||||
"hevue-img-preview": "^7.1.3",
|
"hevue-img-preview": "^7.1.3",
|
||||||
@@ -47,6 +49,8 @@
|
|||||||
"eslint-plugin-vue": "^10.6.2",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.7",
|
||||||
|
"vitest": "^5.0.0",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
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 { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||||
import { join } from 'path'
|
import path from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { registerWindowHandler } from './ipcHandlers/window'
|
import { registerWindowHandler } from './ipcHandlers/window'
|
||||||
import { createTry } from './trayHandler'
|
import { createTry } from './trayHandler'
|
||||||
|
import { registerCacheHandler } from './ipcHandlers/cache'
|
||||||
|
import { addProtocolHandler } from '../cache/protocolReg'
|
||||||
|
import { CACHE_ROOT } from '../cache/cacheDir'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
|
||||||
|
let mainWindow = null
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 900,
|
||||||
height: 670,
|
height: 670,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
frame:false,
|
frame:false,
|
||||||
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
|
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
|
||||||
icon: join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
|
icon: path.join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false
|
sandbox: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -24,7 +30,7 @@ function createWindow() {
|
|||||||
createTry(mainWindow);
|
createTry(mainWindow);
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
mainWindow.show()
|
// mainWindow.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +44,7 @@ function createWindow() {
|
|||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +55,8 @@ app.whenReady().then(() => {
|
|||||||
// Set app user model id for windows
|
// Set app user model id for windows
|
||||||
electronApp.setAppUserModelId('com.electron')
|
electronApp.setAppUserModelId('com.electron')
|
||||||
|
|
||||||
|
addProtocolHandler()
|
||||||
|
|
||||||
// Default open or close DevTools by F12 in development
|
// Default open or close DevTools by F12 in development
|
||||||
// and ignore CommandOrControl + R in production.
|
// and ignore CommandOrControl + R in production.
|
||||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||||
@@ -59,7 +67,50 @@ app.whenReady().then(() => {
|
|||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
|
||||||
registerWindowHandler()
|
// 开机自启
|
||||||
|
ipcMain.on('setting-autoStart', (_event, enable) => {
|
||||||
|
app.setLoginItemSettings({ openAtLogin: !!enable })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理磁盘文件缓存
|
||||||
|
ipcMain.handle('cache-clear-disk', async () => {
|
||||||
|
try {
|
||||||
|
await fs.emptyDir(CACHE_ROOT)
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取磁盘缓存大小
|
||||||
|
ipcMain.handle('cache-disk-size', async () => {
|
||||||
|
try {
|
||||||
|
let total = 0
|
||||||
|
const walk = async (dir) => {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = path.join(dir, e.name)
|
||||||
|
if (e.isDirectory()) { await walk(p) }
|
||||||
|
else { try { total += (await fs.stat(p)).size } catch { /* Ignore files removed during traversal. */ } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (await fs.pathExists(CACHE_ROOT)) await walk(CACHE_ROOT)
|
||||||
|
return { success: true, size: total }
|
||||||
|
} catch {
|
||||||
|
return { success: false, size: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新消息任务栏/托盘闪动
|
||||||
|
ipcMain.on('window-flash', () => {
|
||||||
|
if (mainWindow && !mainWindow.isFocused()) {
|
||||||
|
mainWindow.flashFrame(true)
|
||||||
|
// 也可以设置托盘图标高亮
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
registerWindowHandler()
|
||||||
|
registerCacheHandler()
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ipcMain } from "electron";
|
||||||
|
import { getCacheResorce } from "../../cache/cacheHandler";
|
||||||
|
|
||||||
|
export function registerCacheHandler(){
|
||||||
|
ipcMain.handle('cache-get', (event, url, type) => {
|
||||||
|
return getCacheResorce(url, type)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { is } from '@electron-toolkit/utils'
|
|||||||
export function registerWindowHandler() {
|
export function registerWindowHandler() {
|
||||||
const windowMapData = new Map()
|
const windowMapData = new Map()
|
||||||
|
|
||||||
ipcMain.on('window-action', (event, action) => {
|
//**窗口控件操作 */
|
||||||
|
ipcMain.on('window-action', (event, action, data) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
if (!win) return
|
if (!win) return
|
||||||
const actions = {
|
const actions = {
|
||||||
@@ -20,14 +21,21 @@ export function registerWindowHandler() {
|
|||||||
win.destroy()
|
win.destroy()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isMaximized: () => win.isMaximized()
|
isMaximized: () => win.isMaximized(),
|
||||||
|
changeSize: () => {
|
||||||
|
win.setSize(data.width, data.height, true)
|
||||||
|
win.setResizable(data.resizable)
|
||||||
|
win.center()
|
||||||
|
},
|
||||||
|
show: () => win.show()
|
||||||
}
|
}
|
||||||
actions[action]?.()
|
actions[action]?.()
|
||||||
})
|
})
|
||||||
ipcMain.on('window-new', (event, { route, data }) => {
|
/**新开窗口 */
|
||||||
|
ipcMain.on('window-new', (event, { route, data, width = 900, height=670 }) => {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 900,
|
width: width,
|
||||||
height: 670,
|
height: height,
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
frame: false,
|
frame: false,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { app, Tray, Menu, nativeImage } from 'electron'
|
import { app, Tray, Menu, nativeImage } from 'electron'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
let tray = null;
|
let tray = null;
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,18 @@ const api = {
|
|||||||
maximize: () => ipcRenderer.send('window-action', 'maximize'),
|
maximize: () => ipcRenderer.send('window-action', 'maximize'),
|
||||||
close: () => ipcRenderer.send('window-action', 'close'),
|
close: () => ipcRenderer.send('window-action', 'close'),
|
||||||
closeThis: () => ipcRenderer.send('window-action', 'closeThis'),
|
closeThis: () => ipcRenderer.send('window-action', 'closeThis'),
|
||||||
|
show: () => ipcRenderer.send('window-action', 'show'),
|
||||||
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
|
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
|
||||||
newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }),
|
newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }),
|
||||||
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId)
|
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId),
|
||||||
|
setMainSize: (width, height, resizable = true) =>
|
||||||
|
ipcRenderer.send('window-action', 'changeSize', { width, height, resizable }),
|
||||||
|
flash: () => ipcRenderer.send('window-flash')
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type),
|
||||||
|
clearDisk: () => ipcRenderer.invoke('cache-clear-disk'),
|
||||||
|
diskSize: () => ipcRenderer.invoke('cache-disk-size')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
<title>Electron</title>
|
<title>Electron</title>
|
||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self';
|
content="default-src 'self' ql-im:;
|
||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
connect-src 'self' http://localhost:5202 ws://localhost:5202 http://192.168.5.116:7070 ws://192.168.5.116:7070;
|
connect-src 'self' __API_ORIGIN__ __SIGNALR_ORIGIN__ __SIGNALR_WS_ORIGIN__;
|
||||||
img-src 'self' data: blob: https: http:;
|
img-src 'self' data: blob: https: http: ql-im:;
|
||||||
font-src 'self' data:;
|
font-src 'self' data:;
|
||||||
media-src 'self' blob:;">
|
media-src 'self' blob: __API_ORIGIN__ ql-im:">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Alert from '@/components/messages/Alert.vue';
|
import Alert from '@/components/messages/Alert.vue';
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import { useAuthStore } from './stores/auth';
|
|
||||||
//import { useSignalRStore } from './stores/signalr';
|
//import { useSignalRStore } from './stores/signalr';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<img :src="src" :class="$attrs.class" v-bind="filteredAttrs" @error="onErr" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, useAttrs, watch } from 'vue';
|
||||||
|
import default_avatar from '@/assets/default_avatar.png';
|
||||||
|
import loading_img from '@/assets/loading_img.png';
|
||||||
|
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const filteredAttrs = computed(() => {
|
||||||
|
const rest = { ...attrs };
|
||||||
|
delete rest.class;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rawUrl: { type: [String, null], default: null },
|
||||||
|
noAvatar: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackImg = props.noAvatar ? loading_img : default_avatar;
|
||||||
|
const error = ref(false);
|
||||||
|
|
||||||
|
const src = computed(() => {
|
||||||
|
if (error.value) return props.noAvatar ? loading_img : default_avatar;
|
||||||
|
if (props.rawUrl && props.rawUrl !== '') {
|
||||||
|
return props.rawUrl;
|
||||||
|
}
|
||||||
|
return fallbackImg;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onErr = () => { error.value = true; };
|
||||||
|
watch(() => props.rawUrl, () => { error.value = false; });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div class="v-ui-dropdown" ref="dropdownRef">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="v-ui-dropdown-toggle"
|
||||||
|
:class="{ 'is-open': isOpen }"
|
||||||
|
@click.stop="toggleDropdown"
|
||||||
|
role="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
:disabled="disable"
|
||||||
|
>
|
||||||
|
<span class="v-ui-selected-text">{{ selectedLabel }}</span>
|
||||||
|
<span class="v-ui-arrow-icon" :class="{ 'v-ui-arrow-up': isOpen }">
|
||||||
|
<svg viewBox="0 0 1024 1024" width="1em" height="1em">
|
||||||
|
<path d="M831.872 340.864L512 652.672 192.128 340.864a31.936 31.936 0 0 0-45.248 0 32 32 0 0 0 0 45.248l342.144 333.76a31.936 31.936 0 0 0 45.248 0l342.144-333.76a32 32 0 0 0-45.248-45.248z" fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="v-ui-dropdown-grow">
|
||||||
|
<ul
|
||||||
|
v-show="isOpen"
|
||||||
|
class="v-ui-dropdown-menu"
|
||||||
|
role="listbox"
|
||||||
|
:aria-activedescendant="modelValue"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
class="v-ui-dropdown-item"
|
||||||
|
:class="{ 'is-selected': option.value === modelValue }"
|
||||||
|
@click.stop="selectOption(option)"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="option.value === modelValue"
|
||||||
|
>
|
||||||
|
<span class="v-ui-item-label">{{ option.label }}</span>
|
||||||
|
<span v-if="option.value === modelValue" class="v-ui-check-icon">
|
||||||
|
<svg viewBox="0 0 1024 1024" width="1em" height="1em">
|
||||||
|
<path d="M358.4 716.8l-204.8-204.8-51.2 51.2 256 256 512-512-51.2-51.2-460.8 460.8z" fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
// 定义 Props (逻辑未变)
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
// 期望格式: [{ label: '选项一', value: 1 }, { label: '选项二', value: 2 }]
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '请选择...'
|
||||||
|
},
|
||||||
|
disable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 定义 Emits (支持 v-model, 逻辑未变)
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const dropdownRef = ref(null)
|
||||||
|
|
||||||
|
// 计算当前选中的文本 (逻辑未变)
|
||||||
|
const selectedLabel = computed(() => {
|
||||||
|
const selected = props.options.find(opt => opt.value === props.modelValue)
|
||||||
|
return selected ? selected.label : props.placeholder
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换下拉菜单状态 (逻辑未变)
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中选项 (逻辑未变)
|
||||||
|
const selectOption = (option) => {
|
||||||
|
emit('update:modelValue', option.value)
|
||||||
|
emit('change', option)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击组件外部区域关闭菜单 (逻辑未变)
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂载和卸载全局点击事件监听 (逻辑未变)
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 使用加强型前缀和特异性选择器防止污染 */
|
||||||
|
.v-ui-dropdown {
|
||||||
|
position: relative; /* 强制相对定位 */
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline-block; /* 防止父容器布局冲突 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 针对 ul 和 li 进行强制 reset,防止全局样式干扰 */
|
||||||
|
.v-ui-dropdown ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.v-ui-dropdown li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触发按钮:白底、靛蓝色边框的现代 Filled 风格 */
|
||||||
|
.v-ui-dropdown-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background-color: #007aff; /* 强制微灰底色,与纯白背景区分 */
|
||||||
|
border: 1px solid #e4e4e7; /* 浅灰边框,避免融合 */
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #000000; /* 极深灰 */
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-dropdown-toggle:hover {
|
||||||
|
background-color: #cacaff;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开状态下 */
|
||||||
|
.v-ui-dropdown-toggle.is-open {
|
||||||
|
background-color: #cacaff;
|
||||||
|
border-color: #4f46e5; /* 靛蓝色主色 */
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); /* Focus 环 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-selected-text {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-right: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-arrow-icon {
|
||||||
|
display: flex;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #000000;
|
||||||
|
transition: transform 0.3s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-arrow-up {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉菜单:纯白底色,强化悬浮阴影 */
|
||||||
|
.v-ui-dropdown .v-ui-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 12px;
|
||||||
|
/* 强阴影是白色背景上脱颖而出的秘诀 */
|
||||||
|
box-shadow: 0 12px 32px -4px rgba(0, 0, 0, 0.12), 0 4px 12px -4px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 1000; /* 确保在最上层 */
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 菜单项样式 */
|
||||||
|
.v-ui-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #18181b;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 2px; /* Item 呼吸感 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-dropdown-item:hover {
|
||||||
|
background-color: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中项的样式 */
|
||||||
|
.v-ui-dropdown-item.is-selected {
|
||||||
|
background-color: #eef2ff; /* 极淡的靛蓝色 */
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-item-label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-check-icon {
|
||||||
|
display: flex;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过渡动画 */
|
||||||
|
.v-ui-dropdown-grow-enter-active,
|
||||||
|
.v-ui-dropdown-grow-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-ui-dropdown-grow-enter-from,
|
||||||
|
.v-ui-dropdown-grow-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleY(0.95) translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -31,7 +31,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, useAttrs, onMounted } from 'vue';
|
import { defineProps, useAttrs } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// 按钮样式变体:primary, secondary, danger, text
|
// 按钮样式变体:primary, secondary, danger, text
|
||||||
@@ -184,4 +184,4 @@ const attrs = useAttrs()
|
|||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<rect width="10" height="1" fill="currentColor" />
|
<rect width="10" height="1" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原">
|
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原" :disabled="!props.resizable">
|
||||||
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
|
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||||
<rect x="1.5" y="1.5" width="7" height="7" />
|
<rect x="1.5" y="1.5" width="7" height="7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" />
|
<rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="control-btn close" @click="close" title="关闭">
|
<button class="control-btn close" @click="close" title="关闭" >
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"
|
||||||
stroke-linecap="round">
|
stroke-linecap="round">
|
||||||
<path d="M1 1L9 9M9 1L1 9" />
|
<path d="M1 1L9 9M9 1L1 9" />
|
||||||
@@ -27,11 +27,19 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref, defineEmits, defineProps } from 'vue'
|
||||||
import { isElectron } from '../utils/electronHelper'
|
import { isElectron } from '../utils/electronHelper'
|
||||||
|
|
||||||
const isMaximized = ref(false)
|
const isMaximized = ref(false)
|
||||||
|
|
||||||
|
const emits = defineEmits(['close'])
|
||||||
|
const props = defineProps({
|
||||||
|
resizable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function minimize() {
|
function minimize() {
|
||||||
window.api.window.minimize();
|
window.api.window.minimize();
|
||||||
}
|
}
|
||||||
@@ -41,6 +49,8 @@ function toggleMaximize() {
|
|||||||
}
|
}
|
||||||
function close() {
|
function close() {
|
||||||
window.api.window.close()
|
window.api.window.close()
|
||||||
|
emits('close')
|
||||||
|
emits('close')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineProps, onMounted, defineEmits } from 'vue';
|
import { ref, defineProps, defineEmits } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
menuList: {
|
menuList: {
|
||||||
@@ -177,4 +177,4 @@ const vClickOutside = {
|
|||||||
font-size: 13px; /* 稍微增大字号,更容易阅读 */
|
font-size: 13px; /* 稍微增大字号,更容易阅读 */
|
||||||
font-weight: 500; /* 增加字重,更有质感 */
|
font-weight: 500; /* 增加字重,更有质感 */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,71 +1,90 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-for="c in props.contacts"
|
<div v-for="c in props.contacts" :key="c.id" class="list-item" :class="{ active: activeContactId === c.id }"
|
||||||
:key="c.id"
|
@click="routeUserInfo(c.id)">
|
||||||
class="list-item"
|
<AsyncImage :raw-url="c.avatar" class="avatar-std" />
|
||||||
:class="{active: activeContactId === c.id}"
|
<div class="info">
|
||||||
@click="routeUserInfo(c.id)">
|
<div class="name">{{ c.remarkName || c.nickName }}</div>
|
||||||
<img :src="c.userInfo.avatar" class="avatar-std" />
|
</div>
|
||||||
<div class="info">
|
</div>
|
||||||
<div class="name">{{ c.remarkName }}</div>
|
<div v-if="!props.contacts || props.contacts.length === 0" class="empty-placeholder">
|
||||||
</div>
|
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
|
||||||
</div>
|
<p class="empty-text">暂无好友</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, ref } from 'vue';
|
import { defineProps, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import feather from 'feather-icons';
|
||||||
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const activeContactId = ref(null)
|
const activeContactId = computed(() => route.params.id)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contacts: {
|
contacts: {
|
||||||
type:String,
|
type: Array,
|
||||||
required: true
|
default: () => []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const routeUserInfo = (id) => {
|
const routeUserInfo = (id) => {
|
||||||
router.push(`/contacts/info/${id}`);
|
router.push(`/contacts/info/${id}`);
|
||||||
activeContactId.value = id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.list-item {
|
.empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
.empty-placeholder i { color: #ccc; margin-bottom: 10px; line-height: 0; }
|
||||||
|
.empty-text { font-size: 13px; color: #999; margin: 0; }
|
||||||
|
|
||||||
|
.list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
text-decoration: none; /* 去除下划线 */
|
text-decoration: none;
|
||||||
color: inherit; /* 继承父元素的文本颜色 */
|
/* 去除下划线 */
|
||||||
outline: none; /* 去除点击时的蓝框 */
|
color: inherit;
|
||||||
-webkit-tap-highlight-color: transparent; /* 移动端点击高亮 */
|
/* 继承父元素的文本颜色 */
|
||||||
|
outline: none;
|
||||||
|
/* 去除点击时的蓝框 */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
/* 移动端点击高亮 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 去除 hover、active 等状态的效果 */
|
/* 去除 hover、active 等状态的效果 */
|
||||||
a:hover,
|
a:hover,
|
||||||
a:active,
|
a:active,
|
||||||
a:focus {
|
a:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit; /* 保持颜色不变 */
|
color: inherit;
|
||||||
|
/* 保持颜色不变 */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item:hover { background: #e2e2e2; }
|
.list-item:hover {
|
||||||
.list-item.active { background: #c6c6c6; }
|
background: #e2e2e2;
|
||||||
|
|
||||||
.avatar-std {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.active {
|
||||||
|
background: #c6c6c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.avatar-std) { width: 36px; height: 36px; border-radius: 4px; flex-shrink: 0; }
|
||||||
|
|
||||||
.icon-box {
|
.icon-box {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -76,7 +95,16 @@ a:focus {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.icon-box.orange { background: #faad14; }
|
|
||||||
.icon-box.green { background: #52c41a; }
|
.icon-box.orange {
|
||||||
.icon-box.blue { background: #1890ff; }
|
background: #faad14;
|
||||||
</style>
|
}
|
||||||
|
|
||||||
|
.icon-box.green {
|
||||||
|
background: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-box.blue {
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<WindowControls/>
|
<div></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { previewImages } from 'hevue-img-preview/v3'
|
import { previewImages } from 'hevue-img-preview/v3'
|
||||||
import {onMounted, ref } from 'vue';
|
import {onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import WindowControls from '../WindowControls.vue';
|
// import WindowControls from '../WindowControls.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ onMounted(async () => {
|
|||||||
imageList.value = data.imageList;
|
imageList.value = data.imageList;
|
||||||
index.value = data.index;
|
index.value = data.index;
|
||||||
previewImages({
|
previewImages({
|
||||||
imgList: imageList.value.map(m => m.content.url),
|
imgList: imageList.value.map(m => m.url || (m.content?.body?.url) || m.localUrl),
|
||||||
nowImgIndex: index,
|
nowImgIndex: index,
|
||||||
clickMaskCLose: false,
|
clickMaskCLose: false,
|
||||||
disabledImgRightClick:true,
|
disabledImgRightClick:true,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="video-overlay">
|
||||||
|
|
||||||
|
<div class="video-dialog" :style="isElectron() ? 'width:100vw;height:100vh' : ''">
|
||||||
|
<WindowControls v-if="isElectron()" @close="windowCloseHandler"/>
|
||||||
|
<div class="close-bar" v-if="!isElectron()">
|
||||||
|
<span>正在播放视频</span>
|
||||||
|
<button class="close-btn" @click="webCloseHandler">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-wrapper" :class="{'electron-play-container': isElectron()}">
|
||||||
|
<vue3-video-player
|
||||||
|
v-if="videoLoaded"
|
||||||
|
:src="videoInfo"
|
||||||
|
poster="https://xxx.jpg"
|
||||||
|
:controls="true"
|
||||||
|
:autoplay="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, defineEmits } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { isElectron } from '../../utils/electronHelper';
|
||||||
|
import WindowControls from '../WindowControls.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
videoData: {
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits(['close'])
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const videoInfo = ref(null);
|
||||||
|
|
||||||
|
const videoLoaded = ref(false)
|
||||||
|
|
||||||
|
const winId = ref(null)
|
||||||
|
|
||||||
|
const windowCloseHandler = () => {
|
||||||
|
window.api.window.closeThis()
|
||||||
|
}
|
||||||
|
|
||||||
|
const webCloseHandler = () => {
|
||||||
|
emits('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isElectron()) {
|
||||||
|
winId.value = route.query.winId;
|
||||||
|
const data = await window.api.window.getWindowData(winId.value);
|
||||||
|
videoInfo.value = data;
|
||||||
|
videoLoaded.value = true;
|
||||||
|
}else{
|
||||||
|
videoInfo.value = props.videoData
|
||||||
|
videoLoaded.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
/* 遮罩层:全屏、黑色半透明、固定定位 */
|
||||||
|
.video-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
/* 确保在最顶层 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放器弹窗主体 */
|
||||||
|
.video-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: 90%;
|
||||||
|
/* max-width: 1000px; */
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部状态栏(包含关闭按钮) */
|
||||||
|
.close-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #eee;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
/* 锁定 16:9 比例 */
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进场动画 */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.electron-play-container {
|
||||||
|
height: calc(100vh - 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,41 +1,46 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { useContactStore } from '@/stores/contact';
|
import { friendService } from '../../services/friend';
|
||||||
import { groupService } from '@/services/group';
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
|
||||||
import { useMessage } from '../messages/useAlert';
|
|
||||||
|
|
||||||
const contactStore = useContactStore();
|
const props = defineProps({
|
||||||
const message = useMessage();
|
modelValue: Boolean,
|
||||||
|
type: { type: String, default: 'CreateGroup' },
|
||||||
const props = defineProps({ modelValue: Boolean });
|
title: { type: String, default: '创建群聊' },
|
||||||
|
/** 已在群的 userId 数组 */
|
||||||
const friends = ref([])
|
excludeIds: { type: Array, default: () => [] }
|
||||||
|
|
||||||
const groupName = ref('');
|
|
||||||
const selected = ref(new Set()); // 使用 Set 处理选中逻辑更简洁
|
|
||||||
|
|
||||||
const toggle = (id) => {
|
|
||||||
selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
const res = await groupService.createGroup({
|
|
||||||
name: groupName.value,
|
|
||||||
avatar: "https://baidu.com",
|
|
||||||
userIDs: [...selected.value]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
|
const emits = defineEmits(['submit']);
|
||||||
message.show('群聊创建成功。');
|
|
||||||
}else{
|
|
||||||
message.error(res.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () =>{
|
const friends = ref([]);
|
||||||
friends.value = contactStore.contacts;
|
const groupName = ref('');
|
||||||
})
|
const selected = ref(new Set());
|
||||||
|
|
||||||
|
// 纯同步过滤
|
||||||
|
const excludeSet = computed(() => new Set(props.excludeIds || []));
|
||||||
|
|
||||||
|
const available = computed(() =>
|
||||||
|
(friends.value || []).filter(f => !excludeSet.value.has(f.targetId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = (id) => { selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id); };
|
||||||
|
const submit = () => emits('submit', selected.value, groupName.value);
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async (v) => {
|
||||||
|
if (!v) return;
|
||||||
|
selected.value = new Set();
|
||||||
|
friends.value = [];
|
||||||
|
try {
|
||||||
|
const res = await friendService.getFriendList();
|
||||||
|
friends.value = (res.data || []).map(f => ({
|
||||||
|
...f,
|
||||||
|
friendId: f.targetId,
|
||||||
|
avatar: f.avatar,
|
||||||
|
nickName: f.remarkName || f.nickName,
|
||||||
|
}));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,25 +48,26 @@ onMounted(async () =>{
|
|||||||
<div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)">
|
<div v-if="modelValue" class="overlay" @click.self="$emit('update:modelValue', false)">
|
||||||
<div class="mini-modal">
|
<div class="mini-modal">
|
||||||
<header>
|
<header>
|
||||||
<span>发起群聊</span>
|
<span>{{ props.title }}</span>
|
||||||
<button @click="$emit('update:modelValue', false)">✕</button>
|
<button @click="$emit('update:modelValue', false)">✕</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<input v-model="groupName" placeholder="群组名称..." class="mini-input" />
|
<input v-if="props.type == 'CreateGroup'" v-model="groupName" placeholder="群组名称..." class="mini-input" />
|
||||||
|
|
||||||
<div class="list">
|
<div class="list">
|
||||||
<div v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item">
|
<div v-for="f in available" :key="f.friendId" class="item" @click="toggle(f.friendId)">
|
||||||
<img :src="f.userInfo.avatar" class="avatar" />
|
<AsyncImage :raw-url="f.avatar" class="avatar" />
|
||||||
<span class="name">{{ f.remarkName }}</span>
|
<span class="name">{{ f.nickName }}</span>
|
||||||
<input type="checkbox" :checked="selected.has(f.friendId)" />
|
<input type="checkbox" :checked="selected.has(f.friendId)" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="available.length === 0 && friends.length > 0" class="empty-hint">暂无可邀请的好友</div>
|
||||||
|
<div v-if="friends.length === 0" class="empty-hint">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<button @click="submit" :disabled="!groupName || !selected.size" class="btn">
|
<button @click="submit" :disabled="(!groupName && props.type == 'CreateGroup') || !selected.size" class="btn">
|
||||||
创建 ({{ selected.size }})
|
{{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,44 +76,19 @@ onMounted(async () =>{
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.overlay {
|
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
.mini-modal { background: white; width: 300px; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 999;
|
header { padding: 12px 16px; display: flex; justify-content: space-between; background: #f9f9f9; font-weight: bold; font-size: 14px; }
|
||||||
}
|
|
||||||
|
|
||||||
.mini-modal {
|
|
||||||
background: white; width: 300px; border-radius: 12px; overflow: hidden;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 12px 16px; display: flex; justify-content: space-between;
|
|
||||||
background: #f9f9f9; font-weight: bold; font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header button { background: none; border: none; cursor: pointer; color: #999; }
|
header button { background: none; border: none; cursor: pointer; color: #999; }
|
||||||
|
|
||||||
main { padding: 12px; }
|
main { padding: 12px; }
|
||||||
|
.mini-input { width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee; border-radius: 4px; box-sizing: border-box; outline: none; }
|
||||||
.mini-input {
|
|
||||||
width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee;
|
|
||||||
border-radius: 4px; box-sizing: border-box; outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list { max-height: 200px; overflow-y: auto; }
|
.list { max-height: 200px; overflow-y: auto; }
|
||||||
|
.item { display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px; }
|
||||||
.item {
|
|
||||||
display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px;
|
|
||||||
}
|
|
||||||
.item:hover { background: #f5f5f5; }
|
.item:hover { background: #f5f5f5; }
|
||||||
|
:deep(.avatar) { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; flex-shrink: 0; }
|
||||||
.avatar { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; }
|
|
||||||
.name { flex: 1; font-size: 14px; }
|
.name { flex: 1; font-size: 14px; }
|
||||||
|
.empty-hint { text-align: center; padding: 20px; color: #999; font-size: 13px; }
|
||||||
footer { padding: 12px; }
|
footer { padding: 12px; }
|
||||||
.btn {
|
.btn { width: 100%; padding: 10px; background: #07c160; color: white; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; }
|
||||||
width: 100%; padding: 10px; background: #07c160; color: white;
|
|
||||||
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
|
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,32 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pure-group-list">
|
<div class="pure-group-list">
|
||||||
<div
|
<div
|
||||||
v-for="group in groups"
|
v-for="group in groups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
class="group-item"
|
class="group-item"
|
||||||
:class="{ active: activeId === group.id }"
|
:class="{ active: activeGroupId === group.id }"
|
||||||
@click="activeId = group.id; $emit('select', group)"
|
@click="routeGroupInfo(group)"
|
||||||
>
|
>
|
||||||
<img :src="group.avatar" class="group-avatar" />
|
<img :src="group.avatar" class="group-avatar" />
|
||||||
|
|
||||||
<span class="group-name">{{ group.name }}</span>
|
<span class="group-name">{{ group.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!groups || groups.length === 0" class="empty-placeholder">
|
||||||
|
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
|
||||||
|
<p class="empty-text">暂无群聊</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import feather from 'feather-icons';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
groups: {
|
groups: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
// 数据结构仅需: { id, name, avatar }
|
// 数据结构: { id, name, avatar }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select']);
|
const emit = defineEmits(['select']);
|
||||||
const activeId = ref(null);
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const activeGroupId = computed(() => route.params.id);
|
||||||
|
|
||||||
|
const routeGroupInfo = (group) => {
|
||||||
|
emit('select', group);
|
||||||
|
router.push(`/contacts/group/${group.id}`);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -68,6 +83,16 @@ const activeId = ref(null);
|
|||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
.empty-placeholder i { color: #ccc; margin-bottom: 10px; line-height: 0; }
|
||||||
|
.empty-text { font-size: 13px; color: #999; margin: 0; }
|
||||||
|
|
||||||
.group-name {
|
.group-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,51 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<aside class="group-info-sidebar">
|
||||||
<aside class="group-info-sidebar">
|
|
||||||
<div class="sidebar-scroll-content">
|
<div class="sidebar-scroll-content">
|
||||||
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card header-section">
|
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card header-section">
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<img :src="groupData.targetAvatar" class="group-main-avatar" />
|
<input type="file" style="display: none;" ref="input" @change="fileUploadHandler">
|
||||||
|
<img :src="groupData.targetAvatar" class="group-main-avatar" @click="uploadGroupAvatar"/>
|
||||||
<div class="edit-badge" v-if="isAdmin" v-html="feather.icons['camera'].toSvg({width:15, height: 15})">
|
<div class="edit-badge" v-if="isAdmin" v-html="feather.icons['camera'].toSvg({width:15, height: 15})">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="group-name">{{ groupData.targetName }}</h2>
|
<h2 class="group-name">{{ groupData.targetName }}</h2>
|
||||||
<p class="group-id">群ID: {{ groupData.id }}</p>
|
<p class="group-id">群ID: {{ groupData.targetId }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
|
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3 class="section-label">群公告</h3>
|
<h3 class="section-label">群公告</h3>
|
||||||
<button v-if="isAdmin" class="text-link">编辑</button>
|
<button v-if="isAdmin" class="text-link" @click="editingAnnouncement = !editingAnnouncement">{{ editingAnnouncement ? '完成' : '编辑' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="announcement-box">
|
<div v-if="editingAnnouncement" class="announcement-edit">
|
||||||
{{ groupData.announcement || '暂无群公告,点击编辑添加。' }}
|
<textarea v-model="announcementText" class="announce-input" rows="3" placeholder="输入群公告..."></textarea>
|
||||||
|
<button class="save-announce-btn" @click="saveAnnouncement">保存公告</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="announcement-box">
|
||||||
|
{{ groupInfo?.announcement || '暂无群公告,点击编辑添加。' }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
|
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3 class="section-label">群成员 <span class="count-tag">{{ groupData.members?.length || 0 }}</span></h3>
|
<h3 class="section-label">群成员 <span class="count-tag">{{ groupInfo.members?.length || 0 }}</span></h3>
|
||||||
<button class="text-link" @click="$emit('viewAll')">查看全部</button>
|
<button class="text-link" @click="showAllMembers = !showAllMembers">{{ showAllMembers ? '收起' : '查看全部' }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="member-grid">
|
<div class="member-grid">
|
||||||
<div class="member-item add-btn">
|
<div class="member-item add-btn">
|
||||||
<div class="member-avatar-box dashed">
|
<div class="member-avatar-box dashed" @click="inviteHandler">
|
||||||
<span>+</span>
|
<span>+</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="member-nick">邀请</span>
|
<span class="member-nick">邀请</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="member in groupData.members?.slice(0, 11)"
|
v-for="member in (showAllMembers ? groupInfo.members : groupInfo.members?.slice(0, 11))"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="member-item"
|
class="member-item"
|
||||||
|
@click="handleMemberClick(member)"
|
||||||
>
|
>
|
||||||
<div class="member-avatar-box">
|
<div class="member-avatar-box">
|
||||||
<img :src="member.avatar" class="member-img" />
|
<async-image :raw-url="member.avatar" class="member-img"/>
|
||||||
<span v-if="member.role === 'admin'" class="role-badge"></span>
|
<span v-if="member.role === GROUP_MEMBER_ROLE.ADMIN || member.role === GROUP_MEMBER_ROLE.MASTER" class="role-badge"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="member-nick">{{ member.nickname }}</span>
|
<span class="member-nick">{{ member.nickname || member.groupNickName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -53,34 +58,45 @@
|
|||||||
<section class="info-card settings-list">
|
<section class="info-card settings-list">
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<span>置顶聊天</span>
|
<span>置顶聊天</span>
|
||||||
<input type="checkbox" class="ios-switch" />
|
<input type="checkbox" class="ios-switch" :checked="isPinned" @change="togglePin" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<span>消息免打扰</span>
|
<span>消息免打扰</span>
|
||||||
<input type="checkbox" class="ios-switch" />
|
<input type="checkbox" class="ios-switch" :checked="isMuted" @change="toggleMute" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item arrow">
|
<div class="setting-item arrow" @click="$emit('searchInChat')">
|
||||||
<span>查找聊天记录</span>
|
<span>查找聊天记录</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="danger-zone">
|
<div v-if="chatType == CHAT_TYPE.GROUP" class="danger-zone">
|
||||||
<button class="danger-btn">删除并退出</button>
|
<button class="danger-btn" @click="handleExitGroup">{{ isMaster ? '解散群组' : '删除并退出' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友"
|
||||||
</transition>
|
:excludeIds="groupMemberIds" @submit="inviteUserHandler"/>
|
||||||
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { MESSAGE_TYPE } from '../../constants/MessageType';
|
import { CHAT_TYPE } from '../../constants/MessageType';
|
||||||
import feather from 'feather-icons';
|
import feather from 'feather-icons';
|
||||||
|
import { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
|
||||||
|
import { uploadService } from '../../services/upload/uploadService';
|
||||||
|
import { groupService } from '../../services/group';
|
||||||
|
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
|
||||||
|
import { useMessage } from './useAlert';
|
||||||
|
import CreateGroup from '../groups/CreateGroup.vue';
|
||||||
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { useConversationStore } from '../../stores/conversation';
|
||||||
|
import { useGroupStore } from '../../stores/group';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
chatType: {
|
chatType: {
|
||||||
type: String,
|
type: String,
|
||||||
default: MESSAGE_TYPE.GROUP
|
default: CHAT_TYPE.GROUP
|
||||||
},
|
},
|
||||||
groupData: {
|
groupData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -93,20 +109,169 @@ const props = defineProps({
|
|||||||
id: i,
|
id: i,
|
||||||
nickname: `成员 ${i + 1}`,
|
nickname: `成员 ${i + 1}`,
|
||||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
||||||
role: i === 0 ? 'admin' : 'member'
|
role: i === 0 ? 'Master' : 'Normal'
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
currentUserId: [String, Number]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['close', 'viewAll']);
|
const input = useTemplateRef('input')
|
||||||
|
|
||||||
// 判断当前用户是否为管理员
|
const message = useMessage();
|
||||||
const isAdmin = computed(() => {
|
const conversationStore = useConversationStore();
|
||||||
// 逻辑:在 members 数组中找到当前用户并检查 role
|
const groupStore = useGroupStore();
|
||||||
return true; // 演示用,默认 true
|
|
||||||
});
|
const groupInfo = ref({
|
||||||
|
members: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupInviteModal = ref(false)
|
||||||
|
const groupMemberIds = computed(() => (groupInfo.value?.members || []).map(m => m.userId))
|
||||||
|
const editingAnnouncement = ref(false)
|
||||||
|
const announcementText = ref('')
|
||||||
|
const showAllMembers = ref(false)
|
||||||
|
|
||||||
|
// --- pin / mute (Task 17, persisted in localStorage) ---
|
||||||
|
const storageKey = computed(() => `conv_${props.groupData?.targetId || props.groupData?.id}`)
|
||||||
|
const isPinned = ref(false)
|
||||||
|
const isMuted = ref(false)
|
||||||
|
const togglePin = () => {
|
||||||
|
isPinned.value = !isPinned.value
|
||||||
|
localStorage.setItem(storageKey.value + '_pin', isPinned.value ? '1' : '0')
|
||||||
|
message.success(isPinned.value ? '已置顶' : '已取消置顶')
|
||||||
|
}
|
||||||
|
const toggleMute = () => {
|
||||||
|
isMuted.value = !isMuted.value
|
||||||
|
localStorage.setItem(storageKey.value + '_mute', isMuted.value ? '1' : '0')
|
||||||
|
message.success(isMuted.value ? '已免打扰' : '已取消免打扰')
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'viewAll', 'searchInChat']);
|
||||||
|
|
||||||
|
// --- current user / role ---
|
||||||
|
const currentUserId = computed(() => useAuthStore().userInfo?.id)
|
||||||
|
const currentMember = computed(() =>
|
||||||
|
(groupInfo.value?.members || []).find(member => member.userId === currentUserId.value)
|
||||||
|
)
|
||||||
|
const isMaster = computed(() => currentMember.value?.role === GROUP_MEMBER_ROLE.MASTER)
|
||||||
|
const isAdmin = computed(() =>
|
||||||
|
currentMember.value?.role === GROUP_MEMBER_ROLE.MASTER ||
|
||||||
|
currentMember.value?.role === GROUP_MEMBER_ROLE.ADMIN
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- announcement ---
|
||||||
|
const saveAnnouncement = async () => {
|
||||||
|
const res = await groupService.updateGroupInfo({
|
||||||
|
groupId: props.groupData.targetId,
|
||||||
|
description: announcementText.value
|
||||||
|
})
|
||||||
|
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
groupInfo.value.announcement = announcementText.value
|
||||||
|
editingAnnouncement.value = false
|
||||||
|
message.success('公告已更新')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- member management ---
|
||||||
|
const handleMemberClick = async (member) => {
|
||||||
|
if (!isAdmin.value || member.userId === currentUserId.value) return
|
||||||
|
if (!confirm(`确定要移除成员 ${member.nickname || member.groupNickName} 吗?`)) return
|
||||||
|
const res = await groupService.deleteMember(member.id)
|
||||||
|
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
groupInfo.value.members = groupInfo.value.members.filter(m => m.id !== member.id)
|
||||||
|
message.success('已移除')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '移除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- exit group ---
|
||||||
|
const handleExitGroup = async () => {
|
||||||
|
const groupId = props.groupData.targetId
|
||||||
|
const actionText = isMaster.value ? '解散该群组' : '退出该群组'
|
||||||
|
if (!confirm(`确定要${actionText}吗?${isMaster.value ? '此操作会移除所有成员。' : ''}`)) return
|
||||||
|
try {
|
||||||
|
const res = isMaster.value
|
||||||
|
? await groupService.dissolveGroup(groupId)
|
||||||
|
: await groupService.leaveGroup(groupId)
|
||||||
|
if (res.code === 0) {
|
||||||
|
await conversationStore.removeConversation(props.groupData.id)
|
||||||
|
groupStore.removeGroup(groupId)
|
||||||
|
message.success(isMaster.value ? '群组已解散' : '已退出群组')
|
||||||
|
emit('close')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || `${actionText}失败`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error(`${actionText}失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadGroupAvatar = () => {
|
||||||
|
input.value.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileUploadHandler = async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
const { data } = await uploadService.uploadSmallFile(file, true);
|
||||||
|
const res = await groupService.updateGroupInfo({
|
||||||
|
groupId: props.groupData.targetId,
|
||||||
|
avatar: data.url
|
||||||
|
})
|
||||||
|
|
||||||
|
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
|
||||||
|
message.success('头像更新成功')
|
||||||
|
}else{
|
||||||
|
message.error(res.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inviteHandler = async () => {
|
||||||
|
// 打开弹窗前确保成员数据已加载
|
||||||
|
try {
|
||||||
|
const mRes = await groupService.getGroupMember(props.groupData.targetId)
|
||||||
|
if (mRes.code === SYSTEM_BASE_STATUS.SUCCESS && mRes.data) {
|
||||||
|
groupInfo.value.members = mRes.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载群成员失败:', error)
|
||||||
|
}
|
||||||
|
groupInviteModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteUserHandler = async (selectedUsers) => {
|
||||||
|
const userIds = [...selectedUsers];
|
||||||
|
let allSuccess = true;
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const res = await groupService.inviteUser(props.groupData.targetId, userId);
|
||||||
|
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) allSuccess = false;
|
||||||
|
}
|
||||||
|
if (allSuccess) {
|
||||||
|
message.success('邀请成功')
|
||||||
|
} else {
|
||||||
|
message.error('部分邀请发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.groupData.id,
|
||||||
|
async (newVal, oldVal) => {
|
||||||
|
if (props.chatType == CHAT_TYPE.GROUP && newVal != oldVal) {
|
||||||
|
groupInfo.value = (await groupService.getGroupInfo(props.groupData.targetId)).data
|
||||||
|
groupInfo.value.members = (await groupService.getGroupMember(props.groupData.targetId)).data
|
||||||
|
// restore pin/mute state
|
||||||
|
isPinned.value = localStorage.getItem(storageKey.value + '_pin') === '1'
|
||||||
|
isMuted.value = localStorage.getItem(storageKey.value + '_mute') === '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -118,7 +283,7 @@ const isAdmin = computed(() => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */
|
background-color: #f5f5f5; /* 背景色改为浅灰,突出白色卡片 */
|
||||||
z-index: 1000;
|
z-index: 100;
|
||||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05);
|
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -274,7 +439,7 @@ const isAdmin = computed(() => {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-img {
|
:deep(.member-img) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -378,6 +543,11 @@ const isAdmin = computed(() => {
|
|||||||
|
|
||||||
.ios-switch:checked::before { transform: translateX(18px); }
|
.ios-switch:checked::before { transform: translateX(18px); }
|
||||||
|
|
||||||
|
/* 公告编辑 */
|
||||||
|
.announcement-edit { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.announce-input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; resize: vertical; box-sizing: border-box; }
|
||||||
|
.save-announce-btn { align-self: flex-end; padding: 6px 16px; background: #007aff; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||||
|
|
||||||
/* 动画 */
|
/* 动画 */
|
||||||
.slide-enter-active, .slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
.slide-enter-active, .slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||||
.slide-enter-from, .slide-leave-to { transform: translateX(100%); opacity: 0.5; }
|
.slide-enter-from, .slide-leave-to { transform: translateX(100%); opacity: 0.5; }
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let player = new Player({
|
new Player({
|
||||||
id: 'Video',
|
id: 'Video',
|
||||||
url: props.m.content.url,
|
url: props.m.url || (props.m.content?.body?.url) || '',
|
||||||
controlPlugins: [
|
controlPlugins: [
|
||||||
volume,
|
volume,
|
||||||
playbackRate
|
playbackRate
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import { ref, reactive } from 'vue';
|
import { ref, reactive } from 'vue';
|
||||||
import { friendService } from '@/services/friend';
|
import { friendService } from '@/services/friend';
|
||||||
import { useMessage } from '../messages/useAlert';
|
import { useMessage } from '../messages/useAlert';
|
||||||
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
|
||||||
const props = defineProps({ modelValue: Boolean });
|
defineProps({ modelValue: Boolean });
|
||||||
const emit = defineEmits(['update:modelValue', 'success']);
|
const emit = defineEmits(['update:modelValue', 'success']);
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ const onSearch = async () => {
|
|||||||
const submitAdd = async () => {
|
const submitAdd = async () => {
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
const res = await friendService.requestFriend({
|
const res = await friendService.requestFriend({
|
||||||
toUserId: userResult.value.id,
|
targetId: userResult.value.id,
|
||||||
remarkName: form.remark,
|
remarkName: form.remark,
|
||||||
description: form.description
|
description: form.description
|
||||||
});
|
});
|
||||||
@@ -74,10 +75,10 @@ const submitAdd = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="userResult" class="result-card">
|
<div v-if="userResult" class="result-card">
|
||||||
<img :src="userResult.avatar" class="mini-avatar" />
|
<AsyncImage :raw-url="userResult.avatar" class="mini-avatar" />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">{{ userResult.nickName }}</div>
|
<div class="name">{{ userResult.nickName }}</div>
|
||||||
<div class="id">ID: {{ userResult.username }}</div>
|
<div class="id">ID: {{ userResult.userName }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="next-btn" @click="step = 2">添加</button>
|
<button class="next-btn" @click="step = 2">添加</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +147,7 @@ main { padding: 12px; }
|
|||||||
margin-top: 12px; display: flex; align-items: center;
|
margin-top: 12px; display: flex; align-items: center;
|
||||||
padding: 10px; background: #f9f9f9; border-radius: 8px;
|
padding: 10px; background: #f9f9f9; border-radius: 8px;
|
||||||
}
|
}
|
||||||
.mini-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; }
|
:deep(.mini-avatar) { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; flex-shrink: 0; }
|
||||||
.info { flex: 1; }
|
.info { flex: 1; }
|
||||||
.name { font-size: 14px; font-weight: bold; color: #333; }
|
.name { font-size: 14px; font-weight: bold; color: #333; }
|
||||||
.id { font-size: 11px; color: #999; }
|
.id { font-size: 11px; color: #999; }
|
||||||
@@ -172,4 +173,4 @@ footer { padding: 0 12px 12px; }
|
|||||||
.submit-btn:disabled { opacity: 0.6; }
|
.submit-btn:disabled { opacity: 0.6; }
|
||||||
|
|
||||||
.empty { text-align: center; padding: 20px; font-size: 12px; color: #ccc; }
|
.empty { text-align: center; padding: 20px; font-size: 12px; color: #ccc; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="isVisible"
|
v-if="isVisible"
|
||||||
|
ref="cardRef"
|
||||||
class="im-hover-card"
|
class="im-hover-card"
|
||||||
:style="cardStyle"
|
:style="cardStyle"
|
||||||
@mouseenter="clearTimer"
|
|
||||||
@mouseleave="hide"
|
|
||||||
>
|
>
|
||||||
<div class="card-inner">
|
<div class="card-inner">
|
||||||
<div class="user-profile">
|
<div class="user-profile">
|
||||||
<div class="info-text">
|
<div class="info-text">
|
||||||
<h4 class="nickname">{{ currentUser.name }}</h4>
|
<h4 class="nickname">{{ currentUser.name }}</h4>
|
||||||
<p class="detail-item">
|
<p class="detail-item">
|
||||||
<span class="label">微信号:</span>
|
<span class="label">用户名:</span>
|
||||||
<span class="value">{{ currentUser.id }}</span>
|
<span class="value">{{ currentUser.userName || currentUser.name || currentUser.id }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="detail-item">
|
<p class="detail-item">
|
||||||
<span class="label">地 区:</span>
|
<span class="label">地区:</span>
|
||||||
<span class="value">{{ currentUser.region || '未知' }}</span>
|
<span class="value">{{ currentUser.region || '未知' }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<img :src="currentUser.avatar" class="avatar-square" />
|
<AsyncImage :raw-url="currentUser.avatar" class="avatar-square" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-bio">
|
<div class="user-bio">
|
||||||
@@ -39,7 +38,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue';
|
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||||
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
|
||||||
const isVisible = ref(false);
|
const isVisible = ref(false);
|
||||||
const currentUser = ref({});
|
const currentUser = ref({});
|
||||||
@@ -48,30 +48,30 @@ const cardStyle = reactive({
|
|||||||
top: '0px',
|
top: '0px',
|
||||||
left: '0px'
|
left: '0px'
|
||||||
});
|
});
|
||||||
|
const cardRef = ref(null);
|
||||||
let timer = null;
|
|
||||||
|
|
||||||
const show = (el, data) => {
|
|
||||||
clearTimer();
|
|
||||||
currentUser.value = data;
|
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
// IM 习惯:通常在头像右侧或下方弹出
|
|
||||||
// 这里设置为在头像中心水平对齐,下方弹出
|
|
||||||
cardStyle.top = `${rect.bottom + 8}px`;
|
|
||||||
cardStyle.left = `${rect.left}px`;
|
|
||||||
|
|
||||||
isVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
timer = setTimeout(() => {
|
isVisible.value = false;
|
||||||
isVisible.value = false;
|
|
||||||
}, 300);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearTimer = () => {
|
const onDocumentClick = (e) => {
|
||||||
if (timer) clearTimeout(timer);
|
if (!isVisible.value) return;
|
||||||
|
// 点击卡片内部不关闭
|
||||||
|
if (cardRef.value && cardRef.value.contains(e.target)) return;
|
||||||
|
hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', onDocumentClick, true));
|
||||||
|
onUnmounted(() => document.removeEventListener('click', onDocumentClick, true));
|
||||||
|
|
||||||
|
const show = (el, data) => {
|
||||||
|
currentUser.value = data;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
cardStyle.top = `${rect.bottom + 8}px`;
|
||||||
|
cardStyle.left = `${rect.left}px`;
|
||||||
|
|
||||||
|
isVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAdd = () => {
|
const onAdd = () => {
|
||||||
@@ -133,12 +133,7 @@ defineExpose({ show, hide });
|
|||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-square {
|
:deep(.avatar-square) { width: 60px; height: 60px; border-radius: 4px; flex-shrink: 0; }
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 签名区 */
|
/* 签名区 */
|
||||||
.user-bio {
|
.user-bio {
|
||||||
@@ -192,4 +187,4 @@ defineExpose({ show, hide });
|
|||||||
.fade-enter-from, .fade-leave-to {
|
.fade-enter-from, .fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export const GROUP_MEMBER_ROLE = Object.freeze({
|
||||||
|
NORMAL: 'Normal',
|
||||||
|
ADMIN: 'Administrator',
|
||||||
|
MASTER: 'Master'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群入群请求状态 (对应后端 string)
|
||||||
|
* State: "Pending" 待管理员同意, "Declined" 已拒绝, "Passed" 已通过
|
||||||
|
*/
|
||||||
|
export const GROUP_REQUEST_STATUS = Object.freeze({
|
||||||
|
PENDING: 'Pending',
|
||||||
|
DECLINED: 'Declined',
|
||||||
|
PASSED: 'Passed',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群邀请状态 (对应后端 string)
|
||||||
|
* State: "Pending" 待被邀请人同意, "Passed" 已同意, "Reject" 拒绝
|
||||||
|
*/
|
||||||
|
export const GROUP_INVITATION_STATUS = Object.freeze({
|
||||||
|
PENDING: 'Pending',
|
||||||
|
ACCEPTED: 'Passed',
|
||||||
|
REJECTED: 'Reject',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GROUP_REQUEST_ACTION = Object.freeze({
|
||||||
|
ACCEPT: 'Accept',
|
||||||
|
REJECT: 'Reject'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export const getGroupRequestStatusTxt = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case GROUP_REQUEST_STATUS.PENDING:
|
||||||
|
return '待管理员处理';
|
||||||
|
case GROUP_REQUEST_STATUS.DECLINED:
|
||||||
|
return '管理员已拒绝';
|
||||||
|
case GROUP_REQUEST_STATUS.PASSED:
|
||||||
|
return '管理员已同意';
|
||||||
|
default:
|
||||||
|
return '未知状态';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,24 @@
|
|||||||
|
/** 会话类型 (对应后端 ChatType string) */
|
||||||
|
export const CHAT_TYPE = Object.freeze({
|
||||||
|
PRIVATE: 'PRIVATE',
|
||||||
|
GROUP: 'GROUP'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 消息类型 (对应后端 MsgType string) */
|
||||||
|
export const MSG_TYPE = Object.freeze({
|
||||||
|
Text: 'Text',
|
||||||
|
Image: 'Image',
|
||||||
|
Voice: 'Voice',
|
||||||
|
Video: 'Video',
|
||||||
|
File: 'File',
|
||||||
|
VoiceCall: 'VoiceChat',
|
||||||
|
VideoCall: 'VideoChat'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 CHAT_TYPE
|
||||||
|
*/
|
||||||
export const MESSAGE_TYPE = Object.freeze({
|
export const MESSAGE_TYPE = Object.freeze({
|
||||||
PRIVATE: 'PRIVATE',
|
PRIVATE: 'PRIVATE',
|
||||||
GROUP: 'GROUP'
|
GROUP: 'GROUP'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,40 +1,20 @@
|
|||||||
|
import { MSG_TYPE } from './MessageType'
|
||||||
|
|
||||||
export const getMessageType = (fileType) => {
|
export const getMessageType = (fileType) => {
|
||||||
if (!fileType) return FILE_TYPE.File; // 兜底处理
|
if (!fileType) return MSG_TYPE.File;
|
||||||
|
|
||||||
// 处理图片
|
if (fileType.startsWith('image/')) return MSG_TYPE.Image;
|
||||||
if (fileType.startsWith('image/')) {
|
if (fileType.startsWith('audio/')) return MSG_TYPE.Voice;
|
||||||
return FILE_TYPE.Image;
|
if (fileType.startsWith('video/')) return MSG_TYPE.Video;
|
||||||
}
|
|
||||||
|
|
||||||
// 处理音频(录音消息)
|
return MSG_TYPE.File;
|
||||||
if (fileType.startsWith('audio/')) {
|
|
||||||
return FILE_TYPE.Voice;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理视频
|
|
||||||
if (fileType.startsWith('video/')) {
|
|
||||||
return FILE_TYPE.Video;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 常见文档类型的特殊处理(可选)
|
|
||||||
const documentTypes = [
|
|
||||||
'application/pdf',
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'text/plain'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (documentTypes.includes(fileType)) {
|
|
||||||
return FILE_TYPE.File;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他所有情况统一归类为文件
|
|
||||||
return FILE_TYPE.File;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated 请使用 MSG_TYPE。保留仅为 AsyncImage/cache 等通用缓存层兼容。 */
|
||||||
export const FILE_TYPE = Object.freeze({
|
export const FILE_TYPE = Object.freeze({
|
||||||
Image: 'Image',
|
Image: 'Image',
|
||||||
Video: 'Video',
|
Video: 'Video',
|
||||||
Voice: 'Voice',
|
Voice: 'Voice',
|
||||||
File: 'File'
|
File: 'File',
|
||||||
|
TEXT: 'Text'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
export const FRIEND_ACTIONS = Object.freeze({
|
export const FRIEND_ACTIONS = Object.freeze({
|
||||||
/**接受 */
|
/**同意 */
|
||||||
Accept: 'Accept',
|
Accept: 'Accpet',
|
||||||
/**拒绝 */
|
/**拒绝 */
|
||||||
Reject: 'Reject'
|
Reject: 'Reject',
|
||||||
|
/**拉黑 */
|
||||||
|
Block: 'Block'
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FRIEND_REQUEST_STATUS = Object.freeze({
|
export const FRIEND_REQUEST_STATUS = Object.freeze({
|
||||||
/**待处理 */
|
/**待通过 */
|
||||||
Pending: 'Pending',
|
Pending: 'Pending',
|
||||||
/**通过 */
|
|
||||||
Passed: 'Passed',
|
|
||||||
/**已拒绝 */
|
/**已拒绝 */
|
||||||
Declined: 'Declined',
|
Declined: 'Declined',
|
||||||
|
/**已同意 */
|
||||||
|
Passed: 'Passed',
|
||||||
/**已拉黑 */
|
/**已拉黑 */
|
||||||
Blocked: 'Blocked'
|
Blocked: 'Blocked'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const GROUP_REQUEST_TYPE = Object.freeze({
|
||||||
|
//**邀请对方 */
|
||||||
|
INVITE: 'invite',
|
||||||
|
/**被邀请 */
|
||||||
|
INVITED: 'invited',
|
||||||
|
/**入群请求 */
|
||||||
|
IS_GROUP: 'is-group',
|
||||||
|
//**我的申请入群 */
|
||||||
|
IS_USER: 'is-user',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getTypeText = (type) => {
|
||||||
|
switch(type){
|
||||||
|
case GROUP_REQUEST_TYPE.INVITE:
|
||||||
|
return '邀请好友入群';
|
||||||
|
case GROUP_REQUEST_TYPE.INVITED:
|
||||||
|
return '邀请你入群';
|
||||||
|
case GROUP_REQUEST_TYPE.IS_GROUP:
|
||||||
|
return '申请入群';
|
||||||
|
case GROUP_REQUEST_TYPE.IS_USER:
|
||||||
|
return '我的申请入群';
|
||||||
|
|
||||||
|
default:
|
||||||
|
'未知状态'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export const NOTIFICATION_TYPE = Object.freeze({
|
||||||
|
// --- 基础聊天 (10-19) ---
|
||||||
|
ChatMsg: 1,
|
||||||
|
FileMsg: 11,
|
||||||
|
EmojiSticker: 12,
|
||||||
|
ForwardMsg: 13,
|
||||||
|
|
||||||
|
// --- 系统与通知 (20-29) ---
|
||||||
|
SystemNotice: 2,
|
||||||
|
FriendRequest: 21,
|
||||||
|
FriendAccepted: 22,
|
||||||
|
UserInLine: 23,
|
||||||
|
|
||||||
|
// --- 状态变更与交互 (30-39) ---
|
||||||
|
ActionStatus: 3,
|
||||||
|
MsgReadReceipt: 31,
|
||||||
|
MsgRevoke: 32,
|
||||||
|
MsgEdit: 33,
|
||||||
|
|
||||||
|
// --- 群组管理 (40-49) ---
|
||||||
|
GroupInvited: 41,
|
||||||
|
GroupMemberUpdate: 42,
|
||||||
|
GroupAnnouncement: 43,
|
||||||
|
GroupDismissed: 44,
|
||||||
|
|
||||||
|
// --- 实时通信控制 (50-59) ---
|
||||||
|
RTC_CallRequest: 51,
|
||||||
|
RTC_CallHandled: 52,
|
||||||
|
|
||||||
|
// --- 异常与安全 (90-99) ---
|
||||||
|
ErrorInternal: 91,
|
||||||
|
TokenExpired: 92,
|
||||||
|
KickedOut: 93
|
||||||
|
})
|
||||||
@@ -1,14 +1,50 @@
|
|||||||
import { useConversationStore } from "@/stores/conversation"
|
import { useConversationStore } from "@/stores/conversation"
|
||||||
|
import { CHAT_TYPE } from "../constants/MessageType";
|
||||||
|
|
||||||
export const messageHandler = (msg) => {
|
export const messageHandler = async (msg) => {
|
||||||
const conversationStore = useConversationStore();
|
const conversationStore = useConversationStore();
|
||||||
const conversation = conversationStore.conversations.find(x => (x.targetId == msg.senderId || x.targetId == msg.receiverId) && msg.chatType == x.chatType);
|
|
||||||
conversation.lastMessage = msg.content;
|
let conversation = conversationStore.conversations.find(x => {
|
||||||
if (conversation.targetId == msg.receiverId) {
|
if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
|
||||||
conversation.unreadCount = 0;
|
return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
|
||||||
} else {
|
(x.targetId === msg.senderId || x.targetId === msg.targetId);
|
||||||
conversation.unreadCount += 1;
|
}
|
||||||
|
if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
|
||||||
|
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
|
||||||
|
x.targetId === msg.targetId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 会话不存在 → 从服务器拉取
|
||||||
|
if (!conversation) {
|
||||||
|
await conversationStore.fetchConversationsFromServier();
|
||||||
|
conversation = conversationStore.conversations.find(x => {
|
||||||
|
if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
|
||||||
|
return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
|
||||||
|
(x.targetId === msg.senderId || x.targetId === msg.targetId);
|
||||||
|
}
|
||||||
|
if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
|
||||||
|
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
|
||||||
|
x.targetId === msg.targetId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
conversation.dateTime = new Date().toISOString();
|
if (!conversation) return;
|
||||||
|
|
||||||
|
const contentText = typeof msg.content === 'object'
|
||||||
|
? (msg.content?.body?.text || msg.content?.fallback || '')
|
||||||
|
: msg.content;
|
||||||
|
conversation.lastMessage = contentText;
|
||||||
|
|
||||||
|
// 不是我发的消息 → 未读+1
|
||||||
|
const isFromMe = (conversation.userId && conversation.userId === msg.senderId);
|
||||||
|
if (!isFromMe) {
|
||||||
|
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
|
||||||
|
}
|
||||||
|
conversation.dateTime = msg.pushTimestamp
|
||||||
|
? new Date(msg.pushTimestamp).toISOString()
|
||||||
|
: (msg.creationTime || new Date().toISOString());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const message = useMessage();
|
|||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') },
|
{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') },
|
||||||
|
{ path: '/auth/register', component: () => import('@/views/auth/Register.vue') },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: MainView,
|
component: MainView,
|
||||||
@@ -53,10 +54,21 @@ const routes = [
|
|||||||
component: () => import('@/views/contact/UserInfoContent.vue'),
|
component: () => import('@/views/contact/UserInfoContent.vue'),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/contacts/group/:id',
|
||||||
|
name: 'groupInfo',
|
||||||
|
component: () => import('@/views/contact/GroupInfoContent.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/contacts/requests',
|
path: '/contacts/requests',
|
||||||
name: 'friendRequests',
|
name: 'friendRequests',
|
||||||
component: () => import('@/views/contact/FriendRequestList.vue')
|
component: () => import('@/views/contact/FriendRequestList.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/contacts/grouphandle',
|
||||||
|
name: 'grouphandle',
|
||||||
|
component: () => import('@/views/contact/GroupRequest.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -72,6 +84,9 @@ const routes = [
|
|||||||
{ path: '/test', component: TestView },
|
{ path: '/test', component: TestView },
|
||||||
{
|
{
|
||||||
path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
|
path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/videopre', component: () => import('@/components/electron/VideoPreview.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +1,149 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useMessage } from '@/components/messages/useAlert';
|
import { ref } from 'vue'
|
||||||
import router from '@/router';
|
import { useMessage } from '@/components/messages/useAlert'
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import router from '@/router'
|
||||||
import { authService } from './auth';
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { authService } from './auth'
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage()
|
||||||
|
|
||||||
let waitqueue = [];
|
let waitqueue = []
|
||||||
let isRefreshing = false;
|
let isRefreshing = false
|
||||||
const authURL = ['/auth/login', '/auth/register', '/auth/refresh'];
|
const authURL = ['/auth/login', '/auth/register', '/auth/refresh']
|
||||||
|
|
||||||
|
// 全局网络状态:null=正常,string=错误消息
|
||||||
|
export const networkError = ref(null)
|
||||||
|
let recoveryTimer = null
|
||||||
|
|
||||||
|
const setNetworkError = (msg) => {
|
||||||
|
networkError.value = msg
|
||||||
|
// 10 秒后如果没恢复,自动清除(下次请求成功也会清除)
|
||||||
|
clearTimeout(recoveryTimer)
|
||||||
|
recoveryTimer = setTimeout(() => { networkError.value = null }, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearNetworkError = () => {
|
||||||
|
networkError.value = null
|
||||||
|
clearTimeout(recoveryTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushLoginElectron = () => {
|
||||||
|
window.api.window.close()
|
||||||
|
window.api.window.newWindow('/auth/login', null, 420, 540)
|
||||||
|
}
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
|
baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8009/api'),
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {}
|
||||||
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
config => {
|
(config) => {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore()
|
||||||
const token = authStore.token;
|
const token = authStore.token
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config;
|
return config
|
||||||
},
|
},
|
||||||
err => {
|
(err) => {
|
||||||
return Promise.reject(err);
|
return Promise.reject(err)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.logout()
|
||||||
|
message.error('登录已失效,请重新登录。')
|
||||||
|
if (window.api?.window) pushLoginElectron()
|
||||||
|
else router.push('/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectWaitingRequests = (error) => {
|
||||||
|
waitqueue.forEach(({ reject }) => reject(error))
|
||||||
|
waitqueue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAndRetry = async (config, originalError) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!config || authURL.some((x) => config.url?.includes(x)) || config._retry) {
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(originalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
config._retry = true
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
waitqueue.push({
|
||||||
|
resolve: (token) => {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
resolve(api(config))
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = authStore.refreshToken
|
||||||
|
if (!refreshToken) {
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(originalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
try {
|
||||||
|
const res = await authService.refresh(refreshToken)
|
||||||
|
if (res.code !== 0 || !res.data?.token) throw new Error(res.message || '刷新登录状态失败')
|
||||||
|
authStore.setLoginInfo(res.data)
|
||||||
|
waitqueue.forEach(({ resolve }) => resolve(authStore.token))
|
||||||
|
waitqueue = []
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||||
|
return api(config)
|
||||||
|
} catch (error) {
|
||||||
|
rejectWaitingRequests(error)
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
response => {
|
(response) => {
|
||||||
return response.data;
|
// 任何成功的响应都清除网络错误状态
|
||||||
|
clearNetworkError()
|
||||||
|
// 滚动发布期间兼容旧后端的 HTTP 200 + code=1006。
|
||||||
|
if (response.data?.code === 1006) {
|
||||||
|
return refreshAndRetry(response.config, response.data)
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
},
|
},
|
||||||
async err => {
|
async (err) => {
|
||||||
const authStore = useAuthStore();
|
const { config, response } = err
|
||||||
const { config, response } = err;
|
|
||||||
if (response) {
|
|
||||||
switch (response.status) {
|
|
||||||
case 401:
|
|
||||||
if (authURL.some(x => config.url.includes(x))) {
|
|
||||||
authStore.logout();
|
|
||||||
message.error('未登录,请登录后操作。');
|
|
||||||
router.push('/auth/login')
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (config._retry) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
config._retry = true;
|
// 无响应 → 网络不通
|
||||||
// 已经在刷新 → 排队
|
if (!response) {
|
||||||
if (isRefreshing) {
|
setNetworkError('网络连接异常')
|
||||||
return new Promise(resolve => {
|
return Promise.reject(err)
|
||||||
waitqueue.push(token => {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
resolve(api(config))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
isRefreshing = true;
|
|
||||||
const refreshToken = authStore.refreshToken;
|
|
||||||
if (refreshToken != null && refreshToken != '') {
|
|
||||||
const res = await authService.refresh(refreshToken)
|
|
||||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
|
||||||
waitqueue.forEach(cb => cb(authStore.token));
|
|
||||||
waitqueue = [];
|
|
||||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
|
||||||
return api(config)
|
|
||||||
}
|
|
||||||
authStore.logout();
|
|
||||||
message.error('未登录,请登录后操作。');
|
|
||||||
router.push('/auth/login')
|
|
||||||
break;
|
|
||||||
case 400:
|
|
||||||
if (response.data && response.data.code == 1003) {
|
|
||||||
message.error(response.data.message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
message.error('请求错误,请检查网络。');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return Promise.reject(err);
|
|
||||||
} else {
|
|
||||||
message.error('请求错误,请检查网络。');
|
|
||||||
return Promise.reject(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (response.status) {
|
||||||
|
case 401:
|
||||||
|
return refreshAndRetry(config, err)
|
||||||
|
|
||||||
|
case 400:
|
||||||
|
if (response.data && response.data.code == 1003) {
|
||||||
|
message.error(response.data.message)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 其他 HTTP 错误用 toast(用户主动操作触发,需要即时反馈)
|
||||||
|
message.error(response.data?.message || `请求错误 (${response.status})`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,5 +152,5 @@ export const request = {
|
|||||||
post: (url, data, config) => api.post(url, data, config),
|
post: (url, data, config) => api.post(url, data, config),
|
||||||
put: (url, data, config) => api.put(url, data, config),
|
put: (url, data, config) => api.put(url, data, config),
|
||||||
delete: (url, config) => api.delete(url, config),
|
delete: (url, config) => api.delete(url, config),
|
||||||
instance: api,
|
instance: api
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -3,21 +3,22 @@ import { request } from "./api";
|
|||||||
export const authService = {
|
export const authService = {
|
||||||
/**
|
/**
|
||||||
* 用户登录接口
|
* 用户登录接口
|
||||||
* @param {*} data
|
* @param {{ userName, password }} data
|
||||||
* @returns
|
* @returns Result<LoginResponse>
|
||||||
*/
|
*/
|
||||||
login: (data) => request.post('/auth/login', data),
|
login: (data) => request.post('/auth/login', data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户注册
|
* 用户注册
|
||||||
* @param {*} data
|
* @param {{ userName, password, nickName }} data
|
||||||
* @returns
|
* @returns Result<UserResponse>
|
||||||
*/
|
*/
|
||||||
register: (data) => request.post('/auth/register', data),
|
register: (data) => request.post('/auth/register', data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新用户凭证
|
* 刷新用户凭证
|
||||||
* @param {*} data
|
* @param {string} refreshToken
|
||||||
* @returns
|
* @returns Result<LoginResponse>
|
||||||
*/
|
*/
|
||||||
refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken })
|
refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken })
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user