diff --git a/frontend/pc/IM/API.md b/frontend/pc/IM/API.md new file mode 100644 index 0000000..733c1dd --- /dev/null +++ b/frontend/pc/IM/API.md @@ -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 +``` + +SignalR 客户端通过 `/chat?access_token=` 完成握手;使用官方 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`: + +```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`。 + +### 2.3 POST `/api/auth/refresh` + +无需 Access Token。请求: + +```json +{ "refreshToken": "string" } +``` + +返回新的 `Result`。 + +### 2.4 用户接口 + +| 方法 | 路径 | 参数 | 返回 | +|---|---|---|---| +| GET | `/api/user/me` | 无 | `Result` | +| GET | `/api/user/find?userId={guid}` | query: `userId` | `Result` | +| GET | `/api/user/findByUname?username={value}` | query: `username` | `Result` | +| POST | `/api/user/update` | body: `UserUpdateRequest` | `Result` | +| POST | `/api/user/getUsersByIds` | body: GUID 数组 | `Result` | + +`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` | +| POST | `/api/friend/delete?friendId={guid}` | 好友关系记录 ID,不是对方用户 ID | `Result`,成功时 `data: null` | +| POST | `/api/friend/block?friendId={guid}` | 好友关系记录 ID | `Result` | +| GET | `/api/friend/checkFriend?userId={guid}&targetId={guid}` | 两个用户 ID | `Result` | + +`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`。 + +#### POST `/api/friendRequest/handle` + +```json +{ + "requestId": "guid", + "action": "Accpet|Reject|Block", + "remarkName": "string|null" +} +``` + +注意:后端枚举当前拼写为 `Accpet`,接受申请时 `remarkName` 必填。 + +#### GET `/api/friendRequest/list` + +返回与当前用户相关的申请:`Result`。 + +`FriendRequestResponse.state`:`Pending`、`Declined`、`Passed`、`Blocked`。 + +## 4. 群组 + +### 4.1 群信息 + +| 方法 | 路径 | 请求 | 返回 | +|---|---|---|---| +| GET | `/api/group/getAll` | 无 | `Result` | +| GET | `/api/group/getOne?groupId={guid}` | query: `groupId` | `Result` | +| POST | `/api/group/create` | `{ "name": "string|null" }` | `Result` | +| POST | `/api/group/update` | `GroupUpdateRequest` | `Result` | +| POST | `/api/group/dissolve?groupId={guid}` | 群 ID;仅群主 | `Result` | + +`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` | +| GET | `/api/groupMember/list?groupId={guid}` | 群 ID | `Result` | +| POST | `/api/groupMember/delete?memberId={guid}` | 群成员记录 ID | `Result` | +| POST | `/api/groupMember/leave?groupId={guid}` | 群 ID | `Result` | + +`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` | +| GET | `/api/groupInvitation/get?invitationId={guid}` | 邀请 ID | `Result` | +| POST | `/api/groupInvitation/handle?invitationId={guid}&action={value}` | `action=Accept|Reject` | `Result` | + +邀请状态:`Pending`、`Passed`、`Reject`。 + +### 4.4 入群申请 + +| 方法 | 路径 | 参数 | 返回 | +|---|---|---|---| +| POST | `/api/groupRequest/send` | body: `{ "groupId": "guid", "desc": "string|null" }` | `Result` | +| POST | `/api/groupRequest/handle?requestId={guid}&action={value}` | `action=Accept|Reject` | `Result` | +| GET | `/api/groupRequest/find?id={guid}` | 申请 ID | `Result` | +| GET | `/api/groupRequest/list` | 无 | `Result` | + +`desc` 最大 20 字符;状态为 `Pending`、`Declined` 或 `Passed`。 + +`list` 返回当前用户提交的申请,以及当前用户作为管理员或群主有权处理的群申请。 + +## 5. 会话与消息 + +所有接口均要求认证。 + +### 5.1 会话 + +| 方法 | 路径 | 参数 | 返回 | +|---|---|---|---| +| GET | `/api/conversation/list` | 无 | `Result` | +| GET | `/api/conversation/get?id={guid}` | 会话 ID | `Result` | +| POST | `/api/conversation/markRead?conversationId={guid}` | 会话 ID | `Result` | + +`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`: + +```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` | +| GET | `/api/message/getMessages?conversationId={guid}&cursor={long?}&direction={int}&limit={int}` | 会话、游标、方向、条数 | `Result` | + +`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`。 + +#### GET `/api/file/{id}` + +返回文件信息 `Result`。 + +#### GET `/api/file/{id}/content` + +返回鉴权后的二进制文件流,支持 HTTP Range,不使用 `Result` 包装。无权限返回 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`,不是字符串: + +```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`,其中包含 `location`、`eTag`、`size`、`checksum` 和 `versionId`。 + +#### POST `/api/fileTask/complete` + +```json +{ + "sessionId": "string", + "parts": [ + { "partNumber": 1, "eTag": "string", "size": 123, "checksum": null } + ] +} +``` + +成功接受异步合并时返回 HTTP 202 + `Result`,其中 `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` 为准。 diff --git a/frontend/pc/IM/API_INTEGRATION_AUDIT.md b/frontend/pc/IM/API_INTEGRATION_AUDIT.md new file mode 100644 index 0000000..65f4d52 --- /dev/null +++ b/frontend/pc/IM/API_INTEGRATION_AUDIT.md @@ -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。 diff --git a/frontend/pc/IM/electron.vite.config.mjs b/frontend/pc/IM/electron.vite.config.mjs index f16f179..96843b0 100644 --- a/frontend/pc/IM/electron.vite.config.mjs +++ b/frontend/pc/IM/electron.vite.config.mjs @@ -1,20 +1,43 @@ import { resolve } from 'path' import { defineConfig } from 'electron-vite' +import { loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' -export default defineConfig({ - main: {}, - preload: {}, - renderer: { - server: { - host: true - }, - resolve: { - alias: { - '@': resolve('src/renderer/src') - } - }, - plugins: [vue(), vueDevTools()] +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + // 从 VITE_API_BASE_URL 推导网关源(去掉末尾 /api) + const apiBase = env.VITE_API_BASE_URL || 'http://localhost:8009/api' + const signalRBase = env.VITE_SIGNALR_BASE_URL || 'http://localhost:8009/chat' + const gatewayOrigin = new URL(apiBase).origin + const signalROrigin = new URL(signalRBase).origin + const signalRWebSocketOrigin = signalROrigin.replace(/^http/, 'ws') + const cspPlugin = { + name: 'environment-csp', + transformIndexHtml: (html) => html + .replaceAll('__API_ORIGIN__', gatewayOrigin) + .replaceAll('__SIGNALR_ORIGIN__', signalROrigin) + .replaceAll('__SIGNALR_WS_ORIGIN__', signalRWebSocketOrigin) + } + + return { + main: {}, + preload: {}, + renderer: { + server: { + host: true, + // 开发环境代理,规避浏览器 CORS(Electron 内不受影响) + proxy: { + '/api': { target: gatewayOrigin, changeOrigin: true }, + '/chat': { target: gatewayOrigin, changeOrigin: true, ws: true } + } + }, + resolve: { + alias: { + '@': resolve('src/renderer/src') + } + }, + plugins: [vue(), vueDevTools(), cspPlugin] + } } }) diff --git a/frontend/pc/IM/package-lock.json b/frontend/pc/IM/package-lock.json index e7ebfb3..fb54d1c 100644 --- a/frontend/pc/IM/package-lock.json +++ b/frontend/pc/IM/package-lock.json @@ -40,6 +40,7 @@ "prettier": "^3.7.4", "vite": "^7.2.6", "vite-plugin-vue-devtools": "^8.0.7", + "vitest": "^5.0.0", "vue": "^3.5.25", "vue-eslint-parser": "^10.2.0" } @@ -2474,6 +2475,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", @@ -2484,6 +2496,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -2595,6 +2614,64 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/babel-helper-vue-transform-on": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", @@ -3235,6 +3312,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3699,6 +3786,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -4771,6 +4868,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5236,6 +5340,16 @@ "node": ">=12.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -6999,6 +7113,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", @@ -7261,9 +7389,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -7845,6 +7973,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8015,6 +8150,13 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", @@ -8025,6 +8167,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8282,15 +8431,35 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -9268,6 +9437,99 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/vue": { "version": "3.5.28", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz", @@ -9389,6 +9651,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/pc/IM/package.json b/frontend/pc/IM/package.json index 1c62636..72c30a5 100644 --- a/frontend/pc/IM/package.json +++ b/frontend/pc/IM/package.json @@ -11,6 +11,7 @@ "start": "electron-vite preview", "dev": "electron-vite dev", "build": "electron-vite build", + "test": "vitest run", "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir", "build:win": "npm run build && electron-builder --win", @@ -49,6 +50,7 @@ "prettier": "^3.7.4", "vite": "^7.2.6", "vite-plugin-vue-devtools": "^8.0.7", + "vitest": "^5.0.0", "vue": "^3.5.25", "vue-eslint-parser": "^10.2.0" } diff --git a/frontend/pc/IM/src/main/index.js b/frontend/pc/IM/src/main/index.js index d053649..805cfd1 100644 --- a/frontend/pc/IM/src/main/index.js +++ b/frontend/pc/IM/src/main/index.js @@ -1,24 +1,28 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron' -import { join } from 'path' +import path from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { registerWindowHandler } from './ipcHandlers/window' import { createTry } from './trayHandler' import { registerCacheHandler } from './ipcHandlers/cache' import { addProtocolHandler } from '../cache/protocolReg' +import { CACHE_ROOT } from '../cache/cacheDir' +import fs from 'fs-extra' + +let mainWindow = null function createWindow() { // Create the browser window. - const mainWindow = new BrowserWindow({ + mainWindow = new BrowserWindow({ width: 900, height: 670, show: false, autoHideMenuBar: true, frame:false, ...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设 - icon: join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览 + icon: path.join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览 webPreferences: { - preload: join(__dirname, '../preload/index.js'), + preload: path.join(__dirname, '../preload/index.js'), sandbox: false } }) @@ -40,7 +44,7 @@ function createWindow() { if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) } } @@ -63,6 +67,48 @@ app.whenReady().then(() => { // IPC test ipcMain.on('ping', () => console.log('pong')) + // 开机自启 + ipcMain.on('setting-autoStart', (_event, enable) => { + app.setLoginItemSettings({ openAtLogin: !!enable }) + }) + + // 清理磁盘文件缓存 + ipcMain.handle('cache-clear-disk', async () => { + try { + await fs.emptyDir(CACHE_ROOT) + return { success: true } + } catch (e) { + return { success: false, error: e.message } + } + }) + + // 获取磁盘缓存大小 + ipcMain.handle('cache-disk-size', async () => { + try { + let total = 0 + const walk = async (dir) => { + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const e of entries) { + const p = path.join(dir, e.name) + if (e.isDirectory()) { await walk(p) } + else { try { total += (await fs.stat(p)).size } catch { /* Ignore files removed during traversal. */ } } + } + } + if (await fs.pathExists(CACHE_ROOT)) await walk(CACHE_ROOT) + return { success: true, size: total } + } catch { + return { success: false, size: 0 } + } + }) + + // 新消息任务栏/托盘闪动 + ipcMain.on('window-flash', () => { + if (mainWindow && !mainWindow.isFocused()) { + mainWindow.flashFrame(true) + // 也可以设置托盘图标高亮 + } + }) + registerWindowHandler() registerCacheHandler() diff --git a/frontend/pc/IM/src/main/trayHandler.js b/frontend/pc/IM/src/main/trayHandler.js index 6fb67f1..932f6e1 100644 --- a/frontend/pc/IM/src/main/trayHandler.js +++ b/frontend/pc/IM/src/main/trayHandler.js @@ -1,6 +1,5 @@ import { app, Tray, Menu, nativeImage } from 'electron' import path from 'path' -import { useRouter } from 'vue-router'; let tray = null; diff --git a/frontend/pc/IM/src/preload/index.js b/frontend/pc/IM/src/preload/index.js index 5d7065c..a6a56d5 100644 --- a/frontend/pc/IM/src/preload/index.js +++ b/frontend/pc/IM/src/preload/index.js @@ -13,10 +13,13 @@ const api = { newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }), getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId), setMainSize: (width, height, resizable = true) => - ipcRenderer.send('window-action', 'changeSize', { width, height, resizable }) + ipcRenderer.send('window-action', 'changeSize', { width, height, resizable }), + flash: () => ipcRenderer.send('window-flash') }, cache: { - getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type) + getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type), + clearDisk: () => ipcRenderer.invoke('cache-clear-disk'), + diskSize: () => ipcRenderer.invoke('cache-disk-size') } } diff --git a/frontend/pc/IM/src/renderer/index.html b/frontend/pc/IM/src/renderer/index.html index 7fb0741..f049aae 100644 --- a/frontend/pc/IM/src/renderer/index.html +++ b/frontend/pc/IM/src/renderer/index.html @@ -5,13 +5,13 @@ Electron + media-src 'self' blob: __API_ORIGIN__ ql-im:"> diff --git a/frontend/pc/IM/src/renderer/src/App.vue b/frontend/pc/IM/src/renderer/src/App.vue index 34d8f73..26b97d6 100644 --- a/frontend/pc/IM/src/renderer/src/App.vue +++ b/frontend/pc/IM/src/renderer/src/App.vue @@ -8,7 +8,6 @@ diff --git a/frontend/pc/IM/src/renderer/src/components/MyButton.vue b/frontend/pc/IM/src/renderer/src/components/MyButton.vue index f7ea866..673e4bd 100644 --- a/frontend/pc/IM/src/renderer/src/components/MyButton.vue +++ b/frontend/pc/IM/src/renderer/src/components/MyButton.vue @@ -31,7 +31,7 @@ export default { diff --git a/frontend/pc/IM/src/renderer/src/components/groups/groupsShow.vue b/frontend/pc/IM/src/renderer/src/components/groups/groupsShow.vue index 7095cf4..fdb82a7 100644 --- a/frontend/pc/IM/src/renderer/src/components/groups/groupsShow.vue +++ b/frontend/pc/IM/src/renderer/src/components/groups/groupsShow.vue @@ -1,32 +1,47 @@ \ No newline at end of file + diff --git a/frontend/pc/IM/src/renderer/src/components/user/UserHoverCard.vue b/frontend/pc/IM/src/renderer/src/components/user/UserHoverCard.vue index e7ddb9a..b73c3c5 100644 --- a/frontend/pc/IM/src/renderer/src/components/user/UserHoverCard.vue +++ b/frontend/pc/IM/src/renderer/src/components/user/UserHoverCard.vue @@ -1,27 +1,26 @@ diff --git a/frontend/pc/IM/src/renderer/src/views/contact/FriendRequestList.vue b/frontend/pc/IM/src/renderer/src/views/contact/FriendRequestList.vue index 18fd192..570435a 100644 --- a/frontend/pc/IM/src/renderer/src/views/contact/FriendRequestList.vue +++ b/frontend/pc/IM/src/renderer/src/views/contact/FriendRequestList.vue @@ -9,19 +9,19 @@
- +
- {{ item.nickName }} - {{ formatDate(item.created) }} + {{ item.ownerId != authStore.userInfo.id ? (item.ownerNickName || item.ownerId) : (item.targetNickName || item.targetId) }} + {{ formatDate(item.creationTime) }}

{{ item.description }}

-

{{ item.remark }}

+

备注:{{ item.remarkName }}

- diff --git a/frontend/pc/IM/src/renderer/src/views/contact/UserInfoContent.vue b/frontend/pc/IM/src/renderer/src/views/contact/UserInfoContent.vue index ac3bc8e..6bbe00f 100644 --- a/frontend/pc/IM/src/renderer/src/views/contact/UserInfoContent.vue +++ b/frontend/pc/IM/src/renderer/src/views/contact/UserInfoContent.vue @@ -7,21 +7,26 @@

- {{ currentContact.remarkName }} - - {{ '♂' }} - + {{ currentContact.remarkName || currentContact.nickName }} + {{ '♂' }}

-

账号:{{ currentContact.userInfo.username }}

-

地区:{{ '未知' }}

+

账号:{{ currentContact.targetId }}

+

备注:{{ editableRemark || '未设置' }}

+

地区:{{ currentContact.region || '未知' }}

- +
昵称 - {{ currentContact.userInfo.nickName }} + {{ currentContact.nickName }} +
+
+ 修改备注 + + +
个性签名 @@ -35,55 +40,96 @@
- + + +
- diff --git a/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useRightClickHandler.js b/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useRightClickHandler.js index 02eb7fd..656476b 100644 --- a/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useRightClickHandler.js +++ b/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useRightClickHandler.js @@ -1,41 +1,28 @@ -import { FILE_TYPE } from '../../../../constants/fileTypeDefine' +import { MSG_TYPE } from '@/constants/MessageType' -export function useRightClickHandler(e, m) { - const textRightItem = [ - { - label: '复制', - action: async () => { - await navigator.clipboard.writeText(e.target.innerText) - } - }, - { - label: '引用', - action: () => console.log('进入私聊') - }, - { - label: '转发', - action: () => {} - }, - { - label: '删除', - type: 'danger', - action: () => alert('删除成功') - } - ] +const buildMenu = (message, opts = {}) => { + const { onQuote, onForward, onDelete, onWithdraw, onCopy } = opts + const items = [] - const imgRightItem = [ - { - label: '复制', - action: () => { - console.log(e.target) - } - } - ] - switch (m.type) { - case FILE_TYPE.TEXT: - return textRightItem - case FILE_TYPE.Image: - case FILE_TYPE.Video: - return imgRightItem; + const isText = message.msgType === MSG_TYPE.Text + const isImage = message.msgType === MSG_TYPE.Image + + if (isText) { + items.push({ label: '复制', action: () => navigator.clipboard.writeText(message.content?.body?.text || message.content?.fallback || message.content || '') }) } + if (isImage) { + items.push({ label: '复制图片', action: () => onCopy?.(message) }) + } + if (isText) { + items.push({ label: '引用', action: () => onQuote?.(message) }) + } + items.push({ label: '转发', action: () => onForward?.(message) }) + items.push({ label: '撤回', action: () => onWithdraw?.(message) }) + items.push({ label: '删除', type: 'danger', action: () => onDelete?.(message) }) + + return items +} + +export const useRightClickHandler = (message, opts) => { + return buildMenu(message, opts) } diff --git a/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useSendMessageHandler.js b/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useSendMessageHandler.js index b8569bd..a72c3fd 100644 --- a/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useSendMessageHandler.js +++ b/frontend/pc/IM/src/renderer/src/views/messages/messageContent/hooks/useSendMessageHandler.js @@ -1,109 +1,213 @@ import { useChatStore } from "@/stores/chat"; +import { useAuthStore } from "@/stores/auth"; import { generateSessionId } from "@/utils/sessionIdTools"; -import { MESSAGE_TYPE } from "@/constants/MessageType"; +import { CHAT_TYPE, MSG_TYPE } from "@/constants/MessageType"; import { messageService } from "@/services/message"; import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus"; import { uploadFile } from "@/services/upload/uploader"; import { UPLOAD_STATUS } from "@/constants/uploadStatus"; import { getMessageType } from "@/constants/fileTypeDefine"; import { uploadService } from "@/services/upload/uploadService"; -import { getFileHash } from "@/utils/uploadTools"; export function useSendMessageHandler() { + // 发送者恒等于当前登录用户,避免会话数据缺 userId 导致消息归属错误 + const myId = useAuthStore().userInfo?.id; + const sendMessage = async (msg) => { const chatStore = useChatStore(); - //设置消息为加载状态 - const msgServer = { ...msg } msg.isLoading = true; - //将临时消息推送到消息列表(存库,方便后续重试) - await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true); - //从列表取出消息 - let updateMsg = msg; + const isGroupChat = msg.chatType == CHAT_TYPE.GROUP; + await chatStore.pushAndSortMessagesAsync( + [msg], + generateSessionId(msg.senderId, msg.targetId, isGroupChat), + true + ); + try { - const res = await messageService.sendMessage(msgServer); + const res = await messageService.sendMessage(msg); if (res.code != SYSTEM_BASE_STATUS.SUCCESS) { - updateMsg.isError = true; + msg.isError = true; } else { - //发送成功将后端生成的sequenceId更新 - updateMsg = res.data; + // 用服务端数据覆盖(保留本地 clientMsgId 等字段用于去重/重发) + const serverData = res.data; + Object.assign(msg, serverData); + msg.isError = false; } } catch { - updateMsg.isError = true; + msg.isError = true; } finally { - updateMsg.isLoading = false; - chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true); - msg.isLoading = false; + await chatStore.pushAndSortMessagesAsync( + [msg], + generateSessionId(msg.senderId, msg.targetId, isGroupChat), + true + ); } } - const sendTextMessage = async (text, conversationInfo) => { + const sendTextMessage = async (text, conversationInfo, quoteMessageId = null) => { const msg = { - type: 'Text', // 消息类型,例如 'Text', 'Image', 'File' - chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP' - senderId: conversationInfo.value.userId, // 当前用户ID (对应 int) - receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int) - content: text, - timeStamp: new Date(), // 对应 DateTime - msgId: self.crypto.randomUUID() + clientMsgId: self.crypto.randomUUID(), + chatType: conversationInfo.value.chatType, + targetId: conversationInfo.value.targetId, + msgType: MSG_TYPE.Text, + senderId: myId, + sequenceId: Date.now(), + text, // 顶层 text 供 /message/send(MessageSendRequest 校验 text 非空) + // 统一用 content 结构,本地消息与服务端返回格式一致 + content: { fallback: text, body: { text }, ext: {}, quote: null }, }; - //更新当前会话最新消息 - conversationInfo.value.lastMessage = msg.content; + if (quoteMessageId) msg.quoteMessageId = quoteMessageId; + conversationInfo.value.lastMessage = text; await sendMessage(msg); } - const sendFileMessage = async (file, conversationInfo, info, localUrl) => { - const chatStore = useChatStore(); - const msg = { - type: getMessageType(file.type), // 消息类型,例如 'Text', 'Image', 'File' - chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP' - senderId: conversationInfo.value.userId, // 当前用户ID (对应 int) - receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int) - content: '', - timeStamp: new Date(), // 对应 DateTime - msgId: self.crypto.randomUUID(), - localUrl: localUrl, - progress: 0 - }; - //更新当前会话最新消息 - conversationInfo.value.lastMessage = info.text; - msg.isImgLoading = true; - await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true); - if (info.thumb) { - const hash = await getFileHash(info.thumb); - try { - const { data } = await uploadService.uploadSmallFile(info.thumb, hash); - info.thumb = data.objectName; - } catch (e) { - console.error(e) - msg.isError = true; - msg.isLoading = false; - return; - } + // 重发失败消息 + const retryMessage = async (msg) => { + const chatStore = useChatStore() + msg.isError = false + msg.isLoading = true + const isGroupChat = msg.chatType == CHAT_TYPE.GROUP + // 兼容旧缓存中的纯文本 content,并保留新版结构化消息字段。 + const text = typeof msg.content === 'object' + ? (msg.content?.body?.text || msg.content?.fallback || '') + : (msg.content || '') + const apiMsg = { + clientMsgId: msg.clientMsgId || msg.msgId, + targetId: msg.targetId, + chatType: msg.chatType, + msgType: msg.msgType, + text, + url: msg.url, + thumb: msg.thumb, + width: msg.width, + height: msg.height, + duration: msg.duration, + fileId: msg.fileId, + fileName: msg.fileName, + fileSize: msg.fileSize, + fileFormat: msg.fileFormat, } - await uploadFile(file, { - onProgress: async (e) => { - if (!e.status) return; - switch (e.status) { - case UPLOAD_STATUS.MERGING: - case UPLOAD_STATUS.UPLOADING: - msg.progress = e.progress; - break; - case UPLOAD_STATUS.COMPLETE: - msg.progress = 100; - msg.isImgLoading = false; - info.fileId = e.taskId; - msg.content = JSON.stringify(info); - await sendMessage(msg); - break; - default: - break; - } + await chatStore.pushAndSortMessagesAsync( + [msg], + generateSessionId(msg.senderId, msg.targetId, isGroupChat), + true + ) + try { + const res = await messageService.sendMessage(apiMsg) + if (res.code != SYSTEM_BASE_STATUS.SUCCESS) { + msg.isError = true + } else { + Object.assign(msg, res.data) + msg.isError = false } - }); + } catch { + msg.isError = true + } finally { + msg.isLoading = false + await chatStore.pushAndSortMessagesAsync( + [msg], + generateSessionId(msg.senderId, msg.targetId, isGroupChat), + true + ) + } } - return { sendMessage, sendFileMessage, sendTextMessage }; + const sendFileMessage = async (file, conversationInfo, info, localUrl) => { + const chatStore = useChatStore(); + const isGroupChat = conversationInfo.value.chatType == CHAT_TYPE.GROUP; + const msgType = getMessageType(file.type); + const msgId = self.crypto.randomUUID(); + + const msg = { + clientMsgId: msgId, + chatType: conversationInfo.value.chatType, + targetId: conversationInfo.value.targetId, + msgType: msgType, + senderId: myId, + sequenceId: Date.now(), + localUrl: localUrl, + progress: 0, + isLoading: false, + isImgLoading: true, + }; + + conversationInfo.value.lastMessage = info.text || '[文件]'; + msg.isImgLoading = true; + await chatStore.pushAndSortMessagesAsync( + [msg], + generateSessionId(msg.senderId, msg.targetId, isGroupChat), + true + ); + + // 缩略图直传 + if (info.thumb instanceof Blob) { + try { + const thumbFile = info.thumb instanceof File + ? info.thumb + : new File([info.thumb], `thumb_${msgId}.jpg`, { type: info.thumb.type || 'image/jpeg' }); + const thumbRes = await uploadService.uploadSmallFile(thumbFile, true); + info.thumb = thumbRes.data?.url || thumbRes.data?.id || ''; + } catch (e) { + console.error('缩略图上传失败:', e); + info.thumb = ''; + } + } + + const conversationId = chatStore.activeConversationId; + + try { + const uploadedFile = await uploadFile(file, { + conversationId, + chatType: conversationInfo.value.chatType, + targetId: conversationInfo.value.targetId, + onProgress: async (e) => { + if (!e.status) return; + switch (e.status) { + case UPLOAD_STATUS.MERGING: + case UPLOAD_STATUS.UPLOADING: + msg.progress = e.progress; + break; + case UPLOAD_STATUS.UPLOADED: + msg.isImgLoading = false; + break; + case UPLOAD_STATUS.COMPLETE: + msg.progress = 100; + msg.isImgLoading = false; + break; + default: + break; + } + } + }); + msg.progress = 100; + msg.isImgLoading = false; + msg.fileId = uploadedFile.id; + msg.url = uploadedFile.url || ''; + msg.thumb = info.thumb || ''; + if (info.w) msg.width = info.w; + if (info.h) msg.height = info.h; + if (info.duration) msg.duration = Math.round(info.duration); + if (msgType === MSG_TYPE.File) { + msg.fileName = file.name; + msg.fileSize = file.size; + msg.fileFormat = file.type || file.name.split('.').pop() || 'application/octet-stream'; + } + await sendMessage(msg); + } catch (e) { + console.error('文件上传失败:', e); + msg.isError = true; + msg.isLoading = false; + msg.isImgLoading = false; + await chatStore.pushAndSortMessagesAsync( + [msg], + generateSessionId(msg.senderId, msg.targetId, isGroupChat), + true + ); + } + } + + return { sendMessage, sendFileMessage, sendTextMessage, retryMessage }; } diff --git a/frontend/pc/IM/src/renderer/src/views/settings/AccountSecurity.vue b/frontend/pc/IM/src/renderer/src/views/settings/AccountSecurity.vue index 3b29960..00c3d62 100644 --- a/frontend/pc/IM/src/renderer/src/views/settings/AccountSecurity.vue +++ b/frontend/pc/IM/src/renderer/src/views/settings/AccountSecurity.vue @@ -55,7 +55,7 @@