fix: align frontend with backend APIs

This commit is contained in:
2026-09-11 18:20:37 +08:00
parent f1af6e6bc6
commit 465361dd10
71 changed files with 3653 additions and 1307 deletions
+573
View File
@@ -0,0 +1,573 @@
# IM_NEW 接口文档
> 源码基线:`IM_NEW/codex/api-alignment-fixes`(起点 `32177a7293ec9eb7bd731467953da3c51e561beb`
> 文档以控制器、DTO、枚举、序列化配置和 Nginx 网关配置为准;“当前实现注意事项”用于标明源码中的实际限制,不代表理想设计。
## 1. 通用约定
### 1.1 网关与路径大小写
- 网关默认端口:`8009`
- HTTP API 前缀:`/api`
- SignalR Hub`/chat`
- Nginx `location` 匹配区分大小写,因此应使用本文给出的全小写控制器前缀,例如 `/api/user/me``/api/User/Me` 在当前网关会返回 404。
### 1.2 认证
除登录、注册以及下文特别标注的接口外,请携带:
```http
Authorization: Bearer <JWT>
```
SignalR 客户端通过 `/chat?access_token=<JWT>` 完成握手;使用官方 SignalR 客户端时由 `accessTokenFactory` 自动添加。
Token 缺失、无效或过期时返回 **HTTP 401**,业务响应为:
```json
{ "code": 1006, "message": "认证失败", "data": null }
```
已认证但权限不足时返回 **HTTP 403**,业务码为 `1005`。新版前端在滚动发布期间仍兼容旧服务的 HTTP 200 + `code=1006`
### 1.3 统一响应
除文件内容下载外,接口通常返回:
```json
{
"code": 0,
"message": "成功",
"data": {}
}
```
- JSON 属性使用 camelCase。
- `code === 0` 表示业务成功。
- 参数校验失败也返回 HTTP 200,业务码为 `1003`
- 枚举通过 `JsonStringEnumConverter` 序列化为字符串,并接受字符串枚举请求值。
- 未处理异常统一返回 HTTP 500 + `code=1000`,响应头 `X-Correlation-ID` 可用于关联服务端日志。
## 2. 认证与用户
### 2.1 POST `/api/auth/login`
无需认证。请求:
```json
{ "userName": "user1", "password": "******" }
```
校验:`userName` 520 字符;`password` 非空且不超过 50 字符。
成功返回 `Result<LoginResponse>`
```json
{
"code": 0,
"message": "成功",
"data": {
"userId": "guid",
"token": "jwt",
"refreshToken": "string",
"expired": null,
"userName": "string",
"nickName": "string",
"avatar": "string|null",
"creationTime": "datetime-offset"
}
}
```
当前实现创建 `LoginResponse` 时将 `expired` 传为 `null`
### 2.2 POST `/api/auth/register`
无需认证。请求:
```json
{ "userName": "newuser", "password": "******", "nickName": "新用户" }
```
校验:`userName` 520 字符;`password` 650 字符;`nickName` 非空且不超过 50 字符。返回 `Result<UserResponse>`
### 2.3 POST `/api/auth/refresh`
无需 Access Token。请求:
```json
{ "refreshToken": "string" }
```
返回新的 `Result<LoginResponse>`
### 2.4 用户接口
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/user/me` | 无 | `Result<UserResponse>` |
| GET | `/api/user/find?userId={guid}` | query: `userId` | `Result<UserResponse>` |
| GET | `/api/user/findByUname?username={value}` | query: `username` | `Result<UserResponse>` |
| POST | `/api/user/update` | body: `UserUpdateRequest` | `Result<UserResponse>` |
| POST | `/api/user/getUsersByIds` | body: GUID 数组 | `Result<UserResponse[]>` |
`UserUpdateRequest` 的字段均可省略:
```json
{
"nickName": "string|null",
"region": "string|null",
"avatar": "string|null",
"description": "string|null"
}
```
`UserResponse`
```json
{
"id": "guid",
"userName": "string",
"nickName": "string",
"email": "string|null",
"phone": "string|null",
"region": "string",
"description": "string",
"avatar": "string|null",
"creationTime": "datetime-offset",
"deletion": "datetime-offset|null"
}
```
## 3. 好友与好友申请
所有接口均要求认证。
### 3.1 好友
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/friend/list` | 无 | `Result<FriendResponse[]>` |
| POST | `/api/friend/delete?friendId={guid}` | 好友关系记录 ID,不是对方用户 ID | `Result<object>`,成功时 `data: null` |
| POST | `/api/friend/block?friendId={guid}` | 好友关系记录 ID | `Result<object>` |
| GET | `/api/friend/checkFriend?userId={guid}&targetId={guid}` | 两个用户 ID | `Result<boolean>` |
`FriendResponse`
```json
{
"id": "friend-relation-guid",
"targetId": "peer-user-guid",
"avatar": "string|null",
"nickName": "string",
"remarkName": "string|null",
"createTime": "datetime",
"updateTime": "datetime|null",
"status": "Pending|Added|Declined|Blocked"
}
```
### 3.2 好友申请
#### POST `/api/friendRequest/add`
```json
{
"targetId": "guid",
"description": "string|null",
"remarkName": "string|null"
}
```
`targetId` 必填。返回 `Result<FriendRequestResponse>`
#### POST `/api/friendRequest/handle`
```json
{
"requestId": "guid",
"action": "Accpet|Reject|Block",
"remarkName": "string|null"
}
```
注意:后端枚举当前拼写为 `Accpet`,接受申请时 `remarkName` 必填。
#### GET `/api/friendRequest/list`
返回与当前用户相关的申请:`Result<FriendRequestResponse[]>`
`FriendRequestResponse.state``Pending``Declined``Passed``Blocked`
## 4. 群组
### 4.1 群信息
| 方法 | 路径 | 请求 | 返回 |
|---|---|---|---|
| GET | `/api/group/getAll` | 无 | `Result<GroupResponse[]>` |
| GET | `/api/group/getOne?groupId={guid}` | query: `groupId` | `Result<GroupResponse>` |
| POST | `/api/group/create` | `{ "name": "string|null" }` | `Result<GroupResponse>` |
| POST | `/api/group/update` | `GroupUpdateRequest` | `Result<GroupResponse>` |
| POST | `/api/group/dissolve?groupId={guid}` | 群 ID;仅群主 | `Result<object>` |
`name` 最大 20 字符。更新请求:
```json
{
"groupId": "guid",
"groupName": "string|null",
"avatar": "string|null",
"description": "string|null"
}
```
`description` 实际用于更新群公告 `announcement`
`GroupResponse`
```json
{
"id": "guid",
"name": "string",
"groupMaster": "guid",
"authority": "REQUIRE_CONSENT|ANYONE_CAN_JOIN|NOT_ALLOWED_TO_JOIN",
"allMembersBanned": false,
"status": "Normal|Blocked",
"announcement": "string",
"avatar": "string|null",
"maxSequenceId": 0,
"lastMessage": "string",
"lastSenderName": "string",
"created": "datetime-offset",
"updated": "datetime-offset"
}
```
`getAll` 按当前用户的有效群成员关系返回群,普通成员、管理员和群主均可看到已加入群。
### 4.2 群成员
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/groupMember/checkMember?userId={guid}&groupId={guid}` | 用户 ID、群 ID | `Result<boolean>` |
| GET | `/api/groupMember/list?groupId={guid}` | 群 ID | `Result<GroupMemberResponse[]>` |
| POST | `/api/groupMember/delete?memberId={guid}` | 群成员记录 ID | `Result<object>` |
| POST | `/api/groupMember/leave?groupId={guid}` | 群 ID | `Result<object>` |
`GroupMemberResponse.role``Normal``Administrator``Master`
`list``delete``leave` 均要求登录。`delete` 是管理操作,只能移除角色低于操作者的其他成员,禁止移除群主或自己;群主必须使用 `dissolve`,不能使用 `leave`
`checkMember` 是服务间内部接口,必须携带 `X-Internal-Api-Key`,不应通过公网网关暴露。
### 4.3 群邀请
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `/api/groupInvitation/send` | body: `{ "groupId": "guid", "userId": "guid" }` | `Result<GroupInvitationResponse>` |
| GET | `/api/groupInvitation/get?invitationId={guid}` | 邀请 ID | `Result<GroupInvitationResponse>` |
| POST | `/api/groupInvitation/handle?invitationId={guid}&action={value}` | `action=Accept|Reject` | `Result<object>` |
邀请状态:`Pending``Passed``Reject`
### 4.4 入群申请
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `/api/groupRequest/send` | body: `{ "groupId": "guid", "desc": "string|null" }` | `Result<GroupRequestResponse>` |
| POST | `/api/groupRequest/handle?requestId={guid}&action={value}` | `action=Accept|Reject` | `Result<object>` |
| GET | `/api/groupRequest/find?id={guid}` | 申请 ID | `Result<GroupRequestResponse>` |
| GET | `/api/groupRequest/list` | 无 | `Result<GroupRequestResponse[]>` |
`desc` 最大 20 字符;状态为 `Pending``Declined``Passed`
`list` 返回当前用户提交的申请,以及当前用户作为管理员或群主有权处理的群申请。
## 5. 会话与消息
所有接口均要求认证。
### 5.1 会话
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `/api/conversation/list` | 无 | `Result<ConversationResponse[]>` |
| GET | `/api/conversation/get?id={guid}` | 会话 ID | `Result<ConversationResponse>` |
| POST | `/api/conversation/markRead?conversationId={guid}` | 会话 ID | `Result<object>` |
`ConversationResponse`
```json
{
"id": "guid",
"userId": "guid",
"targetId": "guid",
"targetAvatar": "string",
"targetName": "string",
"lastReadSequenceId": 0,
"unreadCount": 0,
"chatType": "PRIVATE|GROUP",
"lastMessage": "string",
"dateTime": "datetime"
}
```
### 5.2 POST `/api/message/send`
```json
{
"clientMsgId": "guid",
"targetId": "guid",
"chatType": "PRIVATE|GROUP",
"msgType": "Text|Image|Voice|Video|File|VoiceChat|VideoChat",
"quoteMessageId": "guid|null",
"ext": { "key": "value" },
"text": "string|null",
"url": "string|null",
"width": 0,
"height": 0,
"thumb": "string|null",
"duration": 0,
"fileId": "guid|null",
"fileName": "string|null",
"fileSize": 0,
"fileFormat": "mime/type|null"
}
```
- `clientMsgId``targetId` 必填。
- `Text` 要求 `text`
- `Image``Video``Voice` 至少提供 `url``fileId`
- `File` 要求 `fileId``fileName``fileSize``fileFormat`;服务端已支持构建和保存文件消息。
- `VoiceChat``VideoChat` 仍未实现,发送会返回 `2303`
成功返回 `Result<MessageResponse>`
```json
{
"id": "guid",
"clientMsgId": "guid",
"chatType": "PRIVATE",
"msgType": "Text",
"senderId": "guid",
"targetId": "guid",
"state": "Sent|Withdrwan",
"streamKey": "string",
"sequenceId": 1,
"creationTime": "datetime-offset",
"content": {
"fallback": "string",
"body": {},
"ext": {},
"quote": null
}
}
```
### 5.3 其他消息接口
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `/api/message/withDraw?msgId={guid}` | 消息 ID | `Result<object>` |
| GET | `/api/message/getMessages?conversationId={guid}&cursor={long?}&direction={int}&limit={int}` | 会话、游标、方向、条数 | `Result<GetMessagesResponse>` |
`GetMessagesResponse`
```json
{ "messages": [], "hasmore": false }
```
方向约定:`0` 查询 `SequenceId < cursor` 的历史消息;`1` 查询 `SequenceId > cursor` 的增量消息。两个方向都查询 `limit + 1` 条判断 `hasmore`,但响应最多返回 `limit` 条并按序列号升序;`limit` 范围为 1100。
## 6. 文件服务
所有接口均要求认证。
### 6.1 文件
#### POST `/api/file/simple-upload`
`multipart/form-data`
| 字段 | 类型 | 必填 |
|---|---|---|
| `file` | 文件 | 是 |
| `isPublic` | boolean | 是 |
服务端计算 MD5 并执行安全秒传:公开文件可复用,私有文件只允许同一所有者复用。返回 `Result<FileResponse>`
#### GET `/api/file/{id}`
返回文件信息 `Result<FileResponse>`
#### GET `/api/file/{id}/content`
返回鉴权后的二进制文件流,支持 HTTP Range,不使用 `Result<T>` 包装。无权限返回 HTTP 403,文件不存在返回 HTTP 404。
`FileResponse` 为扁平结构,不暴露存储桶、对象键等内部位置:
```json
{
"id": "guid",
"ownerId": "guid",
"fileName": "avatar.png",
"fileSize": 123,
"contentType": "image/png",
"state": "Uploaded",
"checkSum": "md5-hex",
"chatType": "PRIVATE|GROUP|null",
"targetId": "guid|null",
"isPublic": false,
"created": "datetime-offset",
"updated": "datetime-offset",
"url": "string|null"
}
```
`url` 仅在存储提供方能够生成公开地址时存在。
### 6.2 分片上传
#### POST `/api/fileTask/init`
```json
{
"conversationId": "guid",
"chatType": "PRIVATE|GROUP",
"targetId": "peer-or-group-guid",
"fileName": "string",
"fileSize": 123,
"contentType": "mime/type",
"checkSum": "md5"
}
```
返回:
```json
{
"taskId": "guid",
"uploadSessionId": "string",
"instant": false,
"uploadMode": "LocalMultipart|Presigned",
"totalPartCount": 1,
"partSizeBytes": 5242880,
"file": null
}
```
秒传命中时返回 `instant: true``uploadMode: "Instant"` 和最终 `file`;客户端直接使用该文件,不再调用 `complete`
#### GET `/api/fileTask/getuploadurl?sessionId={value}&partNum={n}`
返回 `Result<PresignedUrl>`,不是字符串:
```json
{
"url": "string",
"method": "PUT|POST",
"headers": {},
"expiresAt": "datetime-offset"
}
```
#### GET `/api/fileTask/progress?sessionId={value}`
```json
{
"sessionId": "string",
"taskId": "string",
"fileSize": 123,
"totalPartCount": 1,
"completedPartCount": 0,
"uploadedBytes": 0,
"progressPercent": 0
}
```
#### POST `/api/fileTask/local/parts/upload`
`multipart/form-data``sessionId``partNumber``file`。返回 `Result<CompleteUploadResult>`,其中包含 `location``eTag``size``checksum``versionId`
#### POST `/api/fileTask/complete`
```json
{
"sessionId": "string",
"parts": [
{ "partNumber": 1, "eTag": "string", "size": 123, "checksum": null }
]
}
```
成功接受异步合并时返回 HTTP 202 + `Result<UploadTaskResponse>`,其中 `state``Merging`
#### GET `/api/fileTask/status?taskId={guid}`
客户端轮询此接口。处理中返回 `Uploading|Merging`;失败返回 `state: "Failed"``failureReason`;完成后返回:
```json
{
"id": "upload-task-guid",
"uploaderId": "guid",
"conversationId": "guid",
"fileName": "string",
"fileSize": 123,
"contentType": "mime/type",
"storageLocation": {},
"state": "Completed",
"checkSum": "string",
"resultFileId": "guid",
"failureReason": null,
"file": { "id": "guid", "fileName": "string", "fileSize": 123, "url": null }
}
```
任务、分片、进度、完成和状态接口都会校验当前用户是上传者。私有文件的 `url` 为 null,使用 `/api/file/{id}/content` 鉴权读取。
## 7. SignalR
- Hub`/chat`
- 服务端事件:`ReceiveNewMessage`
- 当前 Hub 没有 `clearUnreadCount` 方法;清零未读应调用 HTTP `/api/conversation/markRead`
推送载荷与 HTTP `MessageResponse` 略有不同:
```json
{
"id": "guid",
"clientId": "guid",
"chatType": "PRIVATE|GROUP",
"msgType": "Text|Image|Voice|Video",
"senderId": "guid",
"targetId": "guid",
"state": "Sent|Withdrwan",
"streamKey": "string",
"sequenceId": 1,
"pushTimestamp": 0,
"content": {
"fallback": "string",
"body": {},
"ext": {},
"quote": null
}
}
```
## 8. 业务状态码
| 范围/代码 | 含义 |
|---|---|
| `0` | 成功 |
| `1000``1006` | 系统、超时、参数、数据库、权限、认证错误 |
| `2000``2004` | 用户不存在、已存在、密码错误、禁用、登录过期 |
| `2100``2107` | 好友申请、好友关系和操作错误 |
| `2200``2206` | 群不存在、已入群、群满、权限、邀请、申请、成员错误 |
| `2300``2303` | 消息发送、消息不存在、撤回、不支持的消息类型 |
| `2400``2403` | 文件上传、不存在、过大、类型不支持 |
| `3000``3004` | 管理后台错误 |
| `3100` | 会话不存在 |
| `3201``3206` | 分片不存在、合并失败、分片过小/数量不符、会话过期、分片号无效 |
完整名称与中文说明以 `IM.Commons/ResultCode.cs` 为准。
+100
View File
@@ -0,0 +1,100 @@
# 前后端 API 修复实施报告
## 1. 结论与基线
修复计划中的核心阻断项已完成代码落地:认证刷新、会话映射与未读、消息双向分页、群列表/审批/退群/解散、异步分片上传、普通文件消息、私有文件鉴权读取及多环境 CSP 已对齐。
| 项目 | 基线/结果 |
|---|---|
| 后端 | `IM_NEW/codex/api-alignment-fixes`,起点 `32177a7293ec9eb7bd731467953da3c51e561beb` |
| 前端 | `feature-nxdev@f1af6e6` 的现有工作区上增量修改,未覆盖用户原有未提交改动 |
| 后端构建 | `dotnet build IM_API_NEW.sln --no-restore`0 错误 |
| 前端构建 | `npm run build`:通过 |
| 前端测试 | `npm test`:1 个测试文件、3 个上传状态机测试全部通过 |
| 定向 ESLint | 本次涉及的前端文件使用 `npx eslint --quiet`0 错误 |
| 运行态联调 | 待部署迁移并启动 MySQL、Redis、RabbitMQ、Consul 和网关后执行 |
## 2. 已完成修复
### 认证与公共异常
- JWT Challenge/Forbidden 改为标准 HTTP 401/403,保留统一业务响应体。
- 前端同时兼容新版 HTTP 401 和旧版 HTTP 200 + `code=1006`
- Token 刷新采用单飞队列;刷新成功重放请求,失败拒绝全部排队请求并退出登录。
- 全局异常统一返回 HTTP 500 + `SYSTEM_ERROR`,通过 `X-Correlation-ID` 对应服务端日志。
- RabbitMQ 消费增加短间隔重试,降低数据库提交与消息消费竞态造成的偶发失败。
### 会话与消息
- `ConversationResponse.dateTime` 改为 `DateTimeOffset`,映射使用 `ModificationTime ?? CreationTime`
- 会话更新拆分为更新摘要、增加未读和推进已读序号,群聊与私聊均更新参与者会话。
- 会话创建消费者增加幂等判断。
- `direction=0/1` 分别使用 `< cursor``> cursor`,响应裁剪为 `limit` 并保持升序;`limit` 限制为 1100。
- 前端断线补消息携带本地最大 `sequenceId`,IndexedDB 保存完整结构化消息。
### 群组、审批和权限
- 群列表按有效成员关系查询,普通成员也能看到已加入群。
- 入群申请列表包含申请人自己的记录,以及管理员/群主可处理的目标群申请。
- 群详情和成员列表验证当前用户是有效成员。
- 成员移除严格比较角色,禁止移除自己、群主或同级/更高角色。
- 新增 `/api/groupMember/leave``/api/group/dissolve`;群主只能解散,其他成员可退出。
- 退出或解散通过事件软删除对应群会话;前端同步移除群、会话和 IndexedDB 缓存。
- 内部成员检查要求 `X-Internal-Api-Key`MessageService/FileService 使用服务端密钥调用。
### 文件上传与文件消息
- 初始化明确返回 `instant``uploadMode``totalPartCount``partSizeBytes` 和秒传 `file`
- 前端遵循服务端分片大小、预签名 method/headers;对象存储请求不携带业务 JWT。
- 任一分片失败会使整体失败,不再吞错后调用 complete。
- Vitest 覆盖秒传、正常分片异步完成和分片持续失败不得 complete。
- complete 返回 HTTP 202,前端轮询 `/api/fileTask/status`,拿到最终 `fileId` 后才发送消息。
- 消费者以 `SourceTaskId` 幂等创建最终文件,失败写入 `FailureReason`
- 上传地址、分片、进度、完成和状态都校验上传者;秒传按公开/私聊/群聊作用域复用。
- 文件响应扁平化并隐藏内部位置;私有文件通过 `/api/file/{id}/content` 鉴权读取并支持 Range。
- MessageService 支持 `File`,媒体消息支持稳定 `fileId`;前端可预览和下载普通文件消息。
- 小文件上传由服务端计算 MD5,文件名支持 255 字符。
### 环境与部署
- 开发/生产默认网关统一为 `localhost:8009`
- Electron CSP 根据 `VITE_API_BASE_URL``VITE_SIGNALR_BASE_URL` 在构建时生成,不再固定测试网 IP。
- Docker Compose 为 Group/Message/File 服务注入必填的 `IM_INTERNAL_API_KEY`
## 3. 数据库变更与影响面
| 服务 | 迁移 | 影响 |
|---|---|---|
| MessageService | `20260909000100_ApiAlignmentFixes` | 新增会话复合索引;不改消息数据 |
| GroupService | `20260909000200_ApiAlignmentFixes` | 新增成员、角色、申请查询索引和申请 ID 唯一索引 |
| FileService | `20260909000300_AsyncUploadResult` | 增加上传作用域、结果、失败原因、公开标记和来源任务;收紧字符串列并新增索引 |
迁移不物理删除业务数据。文件列由 `longtext` 收紧前,应检查历史值长度;`SourceTaskId` 建唯一索引前,应确认没有重复回填值。
## 4. 发布顺序
1. 备份三个服务数据库,执行历史字段长度与唯一性预检。
2. 设置同一个非空 `IM_INTERNAL_API_KEY`,配置 FileService 的公开桶/公开地址、存储、Redis 和 RabbitMQ 参数。
3. 先发布兼容新旧认证协议的前端。
4. 执行 Message、Group、File 三个迁移并发布后端。
5. 发布新版前端,确认 CSP 只包含当前环境的 API/SignalR 源。
6. 用两个隔离账号执行运行态验收。
## 5. 部署后验收清单
- Token 过期时并发请求只刷新一次;401/403 正确,刷新失败统一退出。
- 会话列表不再 500;私聊/群聊连续消息未读数正确,markRead 清零。
- 两个分页方向无重复、无漏页、每页不超过 limit;断线后只补新消息。
- 普通成员能看到群;群主/管理员能看到待审批申请;退群、解散、越权移人符合规则。
- 本地分片与预签名上传均完成 `init → parts → complete → status`;秒传直接返回最终文件。
- 图片、视频、语音和普通文件可发送、SignalR 接收、历史恢复、预览/下载。
- 私聊第三方不能读取文件;退群成员不能读取群文件;公开头像 URL 非空。
- 重复投递上传完成事件不会创建重复文件。
## 6. 当前保留项
- 本轮未启动完整依赖栈执行真实迁移和双账号端到端联调;这属于发布环境验证,不能由编译结果替代。
- 项目原有全量 ESLint 基线仍有历史问题;本次只保证涉及文件的 `--quiet` 定向检查为 0 错误。
- 后端仓库原先没有自动化测试项目;本轮完成全解决方案编译,数据库/MQ 行为仍以部署后集成验收为准。
- `VoiceChat``VideoChat` 消息仍不在本次实现范围。
- 私有文件采用鉴权内容接口而非暴露短时下载 URL,客户端必须携带 JWT 获取 Blob。
+26 -3
View File
@@ -1,20 +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' import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
// 从 VITE_API_BASE_URL 推导网关源(去掉末尾 /api)
const apiBase = env.VITE_API_BASE_URL || 'http://localhost:8009/api'
const signalRBase = env.VITE_SIGNALR_BASE_URL || 'http://localhost:8009/chat'
const gatewayOrigin = new URL(apiBase).origin
const signalROrigin = new URL(signalRBase).origin
const signalRWebSocketOrigin = signalROrigin.replace(/^http/, 'ws')
const cspPlugin = {
name: 'environment-csp',
transformIndexHtml: (html) => html
.replaceAll('__API_ORIGIN__', gatewayOrigin)
.replaceAll('__SIGNALR_ORIGIN__', signalROrigin)
.replaceAll('__SIGNALR_WS_ORIGIN__', signalRWebSocketOrigin)
}
return {
main: {}, main: {},
preload: {}, preload: {},
renderer: { renderer: {
server: { server: {
host: true host: true,
// 开发环境代理,规避浏览器 CORS(Electron 内不受影响)
proxy: {
'/api': { target: gatewayOrigin, changeOrigin: true },
'/chat': { target: gatewayOrigin, changeOrigin: true, ws: true }
}
}, },
resolve: { resolve: {
alias: { alias: {
'@': resolve('src/renderer/src') '@': resolve('src/renderer/src')
} }
}, },
plugins: [vue(), vueDevTools()] plugins: [vue(), vueDevTools(), cspPlugin]
}
} }
}) })
+286 -7
View File
@@ -40,6 +40,7 @@
"prettier": "^3.7.4", "prettier": "^3.7.4",
"vite": "^7.2.6", "vite": "^7.2.6",
"vite-plugin-vue-devtools": "^8.0.7", "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"
} }
@@ -2474,6 +2475,17 @@
"@types/responselike": "^1.0.0" "@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": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz",
@@ -2484,6 +2496,13 @@
"@types/ms": "*" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@@ -2595,6 +2614,64 @@
"vue": "^3.2.25" "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": { "node_modules/@vue/babel-helper-vue-transform-on": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", "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": ">=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": { "node_modules/astral-regex": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -3699,6 +3786,16 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -4771,6 +4868,13 @@
"node": ">= 0.4" "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": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5236,6 +5340,16 @@
"node": ">=12.0.0" "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": { "node_modules/exponential-backoff": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -6999,6 +7113,20 @@
"node": ">= 0.4" "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": { "node_modules/ohash": {
"version": "2.0.11", "version": "2.0.11",
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", "resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
@@ -7261,9 +7389,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.7",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7845,6 +7973,13 @@
"node": ">=8" "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": { "node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", "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": "^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": { "node_modules/stat-mode": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz",
@@ -8025,6 +8167,13 @@
"node": ">= 6" "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": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -8282,15 +8431,35 @@
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3" "picomatch": "^4.0.4"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -9268,6 +9437,99 @@
"@esbuild/win32-x64": "0.27.3" "@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": { "node_modules/vue": {
"version": "3.5.28", "version": "3.5.28",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz",
@@ -9389,6 +9651,23 @@
"node": "^18.17.0 || >=20.5.0" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
+2
View File
@@ -11,6 +11,7 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "electron-vite build", "build": "electron-vite build",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win", "build:win": "npm run build && electron-builder --win",
@@ -49,6 +50,7 @@
"prettier": "^3.7.4", "prettier": "^3.7.4",
"vite": "^7.2.6", "vite": "^7.2.6",
"vite-plugin-vue-devtools": "^8.0.7", "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"
} }
+51 -5
View File
@@ -1,24 +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 { registerCacheHandler } from './ipcHandlers/cache'
import { addProtocolHandler } from '../cache/protocolReg' 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
} }
}) })
@@ -40,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'))
} }
} }
@@ -63,6 +67,48 @@ app.whenReady().then(() => {
// IPC test // IPC test
ipcMain.on('ping', () => console.log('pong')) 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() registerWindowHandler()
registerCacheHandler() registerCacheHandler()
-1
View File
@@ -1,6 +1,5 @@
import { app, Tray, Menu, nativeImage } from 'electron' import { app, Tray, Menu, nativeImage } from 'electron'
import path from 'path' import path from 'path'
import { useRouter } from 'vue-router';
let tray = null; let tray = null;
+5 -2
View File
@@ -13,10 +13,13 @@ const api = {
newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }), 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) => 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: { 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')
} }
} }
+3 -3
View File
@@ -5,13 +5,13 @@
<title>Electron</title> <title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self' http://192.168.5.100:8009 ql-im:; 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.100:8009 ws://192.168.5.100:8009; connect-src 'self' __API_ORIGIN__ __SIGNALR_ORIGIN__ __SIGNALR_WS_ORIGIN__;
img-src 'self' data: blob: https: http: ql-im:; img-src 'self' data: blob: https: http: ql-im:;
font-src 'self' data:; font-src 'self' data:;
media-src 'self' blob: http://192.168.5.100:8009; ql-im:"> media-src 'self' blob: __API_ORIGIN__ ql-im:">
</head> </head>
<body> <body>
-1
View File
@@ -8,7 +8,6 @@
<script setup> <script setup>
import Alert from '@/components/messages/Alert.vue'; import Alert from '@/components/messages/Alert.vue';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
//import { useSignalRStore } from './stores/signalr'; //import { useSignalRStore } from './stores/signalr';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -1,46 +1,35 @@
<template> <template>
<img :src="src" :class="$attrs.class" v-bind="filteredAttrs" @error="onErr" />
<div class="img-container">
<img :src="finalUrl" alt="" v-bind="$attrs" @error="imgLoadErrHandler">
</div>
</template> </template>
<script setup> <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';
import { onMounted, ref, watch } from 'vue'; const attrs = useAttrs();
import { useCacheStore } from '../stores/cache'; const filteredAttrs = computed(() => {
import { FILE_TYPE } from '../constants/fileTypeDefine'; const rest = { ...attrs };
import default_avatar from '@/assets/default_avatar.png' delete rest.class;
import loading_img from '@/assets/loading_img.png' return rest;
});
const cacheStore = useCacheStore()
const props = defineProps({ const props = defineProps({
rawUrl: { rawUrl: { type: [String, null], default: null },
type: String, noAvatar: { type: Boolean, default: false }
required: true });
},
noAvatar: { const fallbackImg = props.noAvatar ? loading_img : default_avatar;
type: Boolean, const error = ref(false);
default: 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 finalUrl = ref(props.noAvatar ? loading_img : default_avatar)
const imgLoadErrHandler = (e) => {
e.target.src = loading_img
}
watch(() => props.rawUrl,
(newVal) => {
if (!props.rawUrl || props.rawUrl == '') return
cacheStore.getCache(props.rawUrl, FILE_TYPE.Image).then(res => {
finalUrl.value = res
})
},
{ immediate: true }
)
const onErr = () => { error.value = true; };
watch(() => props.rawUrl, () => { error.value = false; });
</script> </script>
@@ -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
@@ -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: {
@@ -3,14 +3,19 @@
@click="routeUserInfo(c.id)"> @click="routeUserInfo(c.id)">
<AsyncImage :raw-url="c.avatar" class="avatar-std" /> <AsyncImage :raw-url="c.avatar" class="avatar-std" />
<div class="info"> <div class="info">
<div class="name">{{ c.remarkName }}</div> <div class="name">{{ c.remarkName || c.nickName }}</div>
</div> </div>
</div> </div>
<div v-if="!props.contacts || props.contacts.length === 0" class="empty-placeholder">
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
<p class="empty-text">暂无好友</p>
</div>
</template> </template>
<script setup> <script setup>
import { defineProps, computed } from 'vue'; import { defineProps, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import feather from 'feather-icons';
import AsyncImage from '../AsyncImage.vue'; import AsyncImage from '../AsyncImage.vue';
const router = useRouter() const router = useRouter()
@@ -20,8 +25,8 @@ const activeContactId = computed(() => route.params.id)
const props = defineProps({ const props = defineProps({
contacts: { contacts: {
type: String, type: Array,
required: true default: () => []
} }
}) })
@@ -33,6 +38,16 @@ const routeUserInfo = (id) => {
</script> </script>
<style scoped> <style scoped>
.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 { .list-item {
display: flex; display: flex;
padding: 10px 12px; padding: 10px 12px;
@@ -68,12 +83,7 @@ a:focus {
background: #c6c6c6; background: #c6c6c6;
} }
:deep(.avatar-std) { :deep(.avatar-std) { width: 36px; height: 36px; border-radius: 4px; flex-shrink: 0; }
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.icon-box { .icon-box {
width: 36px; width: 36px;
@@ -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,
@@ -1,52 +1,46 @@
<script setup> <script setup>
import { ref, onMounted, defineEmits } from 'vue'; import { ref, computed, watch } from 'vue';
import { friendService } from '../../services/friend'; import { friendService } from '../../services/friend';
import { groupService } from '@/services/group';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import { useMessage } from '../messages/useAlert';
import AsyncImage from '../AsyncImage.vue'; import AsyncImage from '../AsyncImage.vue';
const message = useMessage();
const isLoaded = ref(false);
const isError = ref(false)
const props = defineProps({ const props = defineProps({
modelValue: Boolean, modelValue: Boolean,
type: { type: { type: String, default: 'CreateGroup' },
/**@type {"CreateGroup" | "InviteUser"} */ title: { type: String, default: '创建群聊' },
type: String, /** 已在群的 userId 数组 */
default: 'CreateGroup', excludeIds: { type: Array, default: () => [] }
validator: (value) => {
return ['CreateGroup', 'InviteUser'].includes(value)
}
},
title: {
type: String,
default: '创建群聊'
}
}); });
const emits = defineEmits(['submit']) const emits = defineEmits(['submit']);
const friends = ref([])
const friends = ref([]);
const groupName = ref(''); const groupName = ref('');
const selected = ref(new Set()); // 使用 Set 处理选中逻辑更简洁 const selected = ref(new Set());
const toggle = (id) => { // 纯同步过滤
selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id); const excludeSet = computed(() => new Set(props.excludeIds || []));
};
const submit = async () => { const available = computed(() =>
(friends.value || []).filter(f => !excludeSet.value.has(f.targetId))
);
emits('submit', selected.value, groupName.value) const toggle = (id) => { selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id); };
}; const submit = () => emits('submit', selected.value, groupName.value);
onMounted(async () =>{ watch(() => props.modelValue, async (v) => {
friends.value = (await friendService.getFriendList()).data; 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>
@@ -60,18 +54,19 @@ onMounted(async () =>{
<main> <main>
<input v-if="props.type == 'CreateGroup'" 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)">
<AsyncImage :raw-url="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&& props.type == 'CreateGroup') || !selected.size" class="btn"> <button @click="submit" :disabled="(!groupName && props.type == 'CreateGroup') || !selected.size" class="btn">
{{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }}) {{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
</button> </button>
</footer> </footer>
@@ -81,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; }
:deep(.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>
@@ -4,29 +4,44 @@
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,8 +1,7 @@
<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">
<input type="file" style="display: none;" ref="input" @change="fileUploadHandler"> <input type="file" style="display: none;" ref="input" @change="fileUploadHandler">
<img :src="groupData.targetAvatar" class="group-main-avatar" @click="uploadGroupAvatar"/> <img :src="groupData.targetAvatar" class="group-main-avatar" @click="uploadGroupAvatar"/>
@@ -13,20 +12,24 @@
<p class="group-id">群ID: {{ groupData.targetId }}</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">
{{ groupInfo ? groupInfo.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">{{ groupInfo.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">
@@ -38,15 +41,16 @@
</div> </div>
<div <div
v-for="member in groupInfo.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">
<async-image :raw-url="member.avatar" class="member-img"/> <async-image :raw-url="member.avatar" class="member-img"/>
<span v-if="member.role === GROUP_MEMBER_ROLE.ADMIN || member.role === GROUP_MEMBER_ROLE.MASTER" class="role-badge"></span> <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>
@@ -54,43 +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>
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友" @submit="inviteUserHandler"/> <create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友"
:excludeIds="groupMemberIds" @submit="inviteUserHandler"/>
</aside> </aside>
</transition>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, useTemplateRef, watch } 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 { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
import { uploadService } from '../../services/upload/uploadService'; import { uploadService } from '../../services/upload/uploadService';
import { groupService } from '../../services/group'; import { groupService } from '../../services/group';
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'; import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
import { useMessage } from './useAlert'; import { useMessage } from './useAlert';
import { getFileHash } from '../../utils/uploadTools';
import CreateGroup from '../groups/CreateGroup.vue'; import CreateGroup from '../groups/CreateGroup.vue';
import AsyncImage from '../AsyncImage.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,
@@ -103,24 +109,105 @@ 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]
}); });
const input = useTemplateRef('input') const input = useTemplateRef('input')
const message = useMessage(); const message = useMessage();
const conversationStore = useConversationStore();
const groupStore = useGroupStore();
const groupInfo = ref({ const groupInfo = ref({
members: [] members: []
}) })
const groupInviteModal = ref(false) 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)
defineEmits(['close', 'viewAll']); // --- 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 = () => { const uploadGroupAvatar = () => {
input.value.click() input.value.click()
@@ -128,9 +215,9 @@ const uploadGroupAvatar = () => {
const fileUploadHandler = async (e) => { const fileUploadHandler = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
const hash = await getFileHash(file) const { data } = await uploadService.uploadSmallFile(file, true);
const { data } = await uploadService.uploadSmallFile(file, hash); const res = await groupService.updateGroupInfo({
const res = await groupService.updateGroupInfo(props.groupData.targetId, { groupId: props.groupData.targetId,
avatar: data.url avatar: data.url
}) })
@@ -140,30 +227,42 @@ const fileUploadHandler = async (e) => {
message.error(res.message) message.error(res.message)
} }
} }
const inviteHandler = async () => {
const inviteHandler = () => { // 打开弹窗前确保成员数据已加载
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 groupInviteModal.value = true
} }
const inviteUserHandler = async (selectedUsers) => { const inviteUserHandler = async (selectedUsers) => {
const res = await groupService.inviteUser(props.groupData.targetId, [...selectedUsers]) const userIds = [...selectedUsers];
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message) let allSuccess = true;
message.success('成功') 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('部分邀请发送失败')
}
} }
// 判断当前用户是否为管理员
const isAdmin = computed(() => {
// 逻辑:在 members 数组中找到当前用户并检查 role
return true; // 演示用,默认 true
});
watch( watch(
() => props.groupData.id, () => props.groupData.id,
async (newVal, oldVal) => { async (newVal, oldVal) => {
if (props.chatType == MESSAGE_TYPE.GROUP && newVal != oldVal) { if (props.chatType == CHAT_TYPE.GROUP && newVal != oldVal) {
groupInfo.value = (await groupService.getGroupInfo(props.groupData.targetId)).data groupInfo.value = (await groupService.getGroupInfo(props.groupData.targetId)).data
groupInfo.value.members = (await groupService.getGroupMember(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 } { immediate: true }
@@ -444,6 +543,11 @@ onMounted(async () => {
.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; }
@@ -3,25 +3,24 @@
<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,32 +48,32 @@ const cardStyle = reactive({
top: '0px', top: '0px',
left: '0px' left: '0px'
}); });
const cardRef = ref(null);
let timer = null; const hide = () => {
isVisible.value = false;
};
const onDocumentClick = (e) => {
if (!isVisible.value) return;
// 点击卡片内部不关闭
if (cardRef.value && cardRef.value.contains(e.target)) return;
hide();
};
onMounted(() => document.addEventListener('click', onDocumentClick, true));
onUnmounted(() => document.removeEventListener('click', onDocumentClick, true));
const show = (el, data) => { const show = (el, data) => {
clearTimer();
currentUser.value = data; currentUser.value = data;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
// IM 习惯:通常在头像右侧或下方弹出
// 这里设置为在头像中心水平对齐,下方弹出
cardStyle.top = `${rect.bottom + 8}px`; cardStyle.top = `${rect.bottom + 8}px`;
cardStyle.left = `${rect.left}px`; cardStyle.left = `${rect.left}px`;
isVisible.value = true; isVisible.value = true;
}; };
const hide = () => {
timer = setTimeout(() => {
isVisible.value = false;
}, 300);
};
const clearTimer = () => {
if (timer) clearTimeout(timer);
};
const onAdd = () => { const onAdd = () => {
console.log('申请添加好友:', currentUser.value.id); console.log('申请添加好友:', currentUser.value.id);
// 这里写你的逻辑 // 这里写你的逻辑
@@ -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 {
@@ -5,20 +5,23 @@ export const GROUP_MEMBER_ROLE = Object.freeze({
}) })
/** /**
* 群请求状态枚举 (对应后端 String 输出) * 群入群请求状态 (对应后端 string)
* State: "Pending" 待管理员同意, "Declined" 已拒绝, "Passed" 已通过
*/ */
export const GROUP_REQUEST_STATUS = Object.freeze({ export const GROUP_REQUEST_STATUS = Object.freeze({
/** 待管理员处理 */
PENDING: 'Pending', PENDING: 'Pending',
/** 管理员已拒绝 */
DECLINED: 'Declined', DECLINED: 'Declined',
/** 管理员已同意 */
PASSED: 'Passed', PASSED: 'Passed',
/** 待对方同意 */ })
TARGET_PENDING: 'TargetPending',
/** 对方拒绝 */ /**
TARGET_DECLINED: 'TargetDeclined', * 群邀请状态 (对应后端 string)
TARGET_PASSED: 'TargetPassed' * State: "Pending" 待被邀请人同意, "Passed" 已同意, "Reject" 拒绝
*/
export const GROUP_INVITATION_STATUS = Object.freeze({
PENDING: 'Pending',
ACCEPTED: 'Passed',
REJECTED: 'Reject',
}) })
export const GROUP_REQUEST_ACTION = Object.freeze({ export const GROUP_REQUEST_ACTION = Object.freeze({
@@ -35,14 +38,7 @@ export const getGroupRequestStatusTxt = (status) => {
return '管理员已拒绝'; return '管理员已拒绝';
case GROUP_REQUEST_STATUS.PASSED: case GROUP_REQUEST_STATUS.PASSED:
return '管理员已同意'; return '管理员已同意';
case GROUP_REQUEST_STATUS.TARGET_PENDING:
return '待对方同意';
case GROUP_REQUEST_STATUS.TARGET_DECLINED:
return '对方拒绝';
case GROUP_REQUEST_STATUS.TARGET_PASSED:
return '对方同意';
default: default:
return '未知状态'; return '未知状态';
} }
} }
@@ -1,3 +1,23 @@
/** 会话类型 (对应后端 ChatType string) */
export const CHAT_TYPE = Object.freeze({
PRIVATE: 'PRIVATE',
GROUP: 'GROUP'
})
/** 消息类型 (对应后端 MsgType string) */
export const MSG_TYPE = Object.freeze({
Text: 'Text',
Image: 'Image',
Voice: 'Voice',
Video: 'Video',
File: 'File',
VoiceCall: 'VoiceChat',
VideoCall: 'VideoChat'
})
/**
* @deprecated 请使用 CHAT_TYPE
*/
export const MESSAGE_TYPE = Object.freeze({ export const MESSAGE_TYPE = Object.freeze({
PRIVATE: 'PRIVATE', PRIVATE: 'PRIVATE',
GROUP: 'GROUP' GROUP: 'GROUP'
@@ -1,37 +1,16 @@
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',
@@ -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'
}) })
@@ -1,31 +1,50 @@
import { useConversationStore } from "@/stores/conversation" import { useConversationStore } from "@/stores/conversation"
import { MESSAGE_TYPE } from "../constants/MessageType"; 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 => {
// 1. 如果是私聊:目标 ID 必须是对方(可能是发送者,也可能是接收者)
if (msg.chatType === MESSAGE_TYPE.PRIVATE) {
return x.chatType === MESSAGE_TYPE.PRIVATE &&
(x.targetId === msg.senderId || x.targetId === msg.receiverId);
}
// 2. 如果是群聊:目标 ID 必须是群 ID(即消息的 receiverId let conversation = conversationStore.conversations.find(x => {
if (msg.chatType === MESSAGE_TYPE.GROUP) { if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
return x.chatType === MESSAGE_TYPE.GROUP && return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
x.targetId === msg.receiverId; (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; return false;
}); });
if (!conversation) return; // 容错处理:如果没找到会话,不执行后续逻辑 // 会话不存在 → 从服务器拉取
conversation.lastMessage = msg.content; if (!conversation) {
if (conversation.targetId == msg.receiverId) { await conversationStore.fetchConversationsFromServier();
conversation.unreadCount = 0; conversation = conversationStore.conversations.find(x => {
} else { if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
conversation.unreadCount += 1; return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
(x.targetId === msg.senderId || x.targetId === msg.targetId);
} }
conversation.dateTime = new Date().toISOString(); if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
x.targetId === msg.targetId;
}
return false;
});
}
if (!conversation) 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());
} }
@@ -54,6 +54,12 @@ 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',
+84 -34
View File
@@ -1,4 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { ref } from 'vue'
import { useMessage } from '@/components/messages/useAlert' import { useMessage } from '@/components/messages/useAlert'
import router from '@/router' import router from '@/router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -10,13 +11,29 @@ 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 = () => { const pushLoginElectron = () => {
window.api.window.close() window.api.window.close()
window.api.window.newWindow('/auth/login', null, 420, 540) 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: {}
}) })
@@ -35,65 +52,98 @@ api.interceptors.request.use(
} }
) )
api.interceptors.response.use( const redirectToLogin = () => {
(response) => {
return response.data
},
async (err) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const { config, response } = err
if (response) {
switch (response.status) {
case 401:
if (authURL.some((x) => config.url.includes(x))) {
authStore.logout() authStore.logout()
message.error('登录,请登录后操作。') message.error('登录已失效,请重新登录。')
router.push('/auth/login') if (window.api?.window) pushLoginElectron()
break else router.push('/auth/login')
} }
if (config._retry) {
break const rejectWaitingRequests = (error) => {
waitqueue.forEach(({ reject }) => reject(error))
waitqueue = []
}
const refreshAndRetry = async (config, originalError) => {
const authStore = useAuthStore()
if (!config || authURL.some((x) => config.url?.includes(x)) || config._retry) {
redirectToLogin()
return Promise.reject(originalError)
} }
config._retry = true config._retry = true
// 已经在刷新 → 排队
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
waitqueue.push((token) => { waitqueue.push({
resolve: (token) => {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
resolve(api(config)) resolve(api(config))
},
reject,
}) })
}) })
} }
isRefreshing = true
const refreshToken = authStore.refreshToken const refreshToken = authStore.refreshToken
if (refreshToken != null && refreshToken != '') { if (!refreshToken) {
redirectToLogin()
return Promise.reject(originalError)
}
isRefreshing = true
try {
const res = await authService.refresh(refreshToken) const res = await authService.refresh(refreshToken)
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo) if (res.code !== 0 || !res.data?.token) throw new Error(res.message || '刷新登录状态失败')
waitqueue.forEach((cb) => cb(authStore.token)) authStore.setLoginInfo(res.data)
waitqueue.forEach(({ resolve }) => resolve(authStore.token))
waitqueue = [] waitqueue = []
config.headers.Authorization = `Bearer ${authStore.token}` config.headers.Authorization = `Bearer ${authStore.token}`
return api(config) return api(config)
} catch (error) {
rejectWaitingRequests(error)
redirectToLogin()
return Promise.reject(error)
} finally {
isRefreshing = false
} }
authStore.logout() }
message.error('未登录,请登录后操作。')
router.push('/auth/login') api.interceptors.response.use(
break (response) => {
// 任何成功的响应都清除网络错误状态
clearNetworkError()
// 滚动发布期间兼容旧后端的 HTTP 200 + code=1006。
if (response.data?.code === 1006) {
return refreshAndRetry(response.config, response.data)
}
return response.data
},
async (err) => {
const { config, response } = err
// 无响应 → 网络不通
if (!response) {
setNetworkError('网络连接异常')
return Promise.reject(err)
}
switch (response.status) {
case 401:
return refreshAndRetry(config, err)
case 400: case 400:
if (response.data && response.data.code == 1003) { if (response.data && response.data.code == 1003) {
message.error(response.data.message) message.error(response.data.message)
break
} }
break
default: default:
message.error('请求错误,请检查网络。') // 其他 HTTP 错误用 toast(用户主动操作触发,需要即时反馈)
message.error(response.data?.message || `请求错误 (${response.status})`)
break break
} }
return Promise.reject(err) return Promise.reject(err)
} else {
message.error('请求错误,请检查网络。')
return Promise.reject(err)
}
} }
) )
@@ -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 })
} }
@@ -1,44 +1,68 @@
import { request } from "./api"; import { request } from "./api";
import { FRIEND_ACTIONS } from "@/constants/friendAction";
export const friendService = { export const friendService = {
/** /**
* 获取好友列表 * 获取好友列表
* @param {*} page 当前页 * @returns Result<List<FriendResponse>>
* @param {*} limit 页大小
* @returns
*/
getFriendList: (page = 1, limit = 100) => request.get(`/friend/list?page=${page}&limit=${limit}`),
/**
* 搜索好友
* @param {*} username
* @returns
*/ */
getFriendList: () => request.get('/friend/list'),
findUser: (username) => request.get(`/user/findbyusername?username=${username}`),
/** /**
* 申请添加好友 * 删除好友
* @param {*} params * @param {string} friendId
* @returns * @returns Result<boolean>
*/ */
requestFriend: (params) => request.post('/friend/request', params), deleteFriend: (friendId) => request.post(`/friend/delete?friendId=${friendId}`),
/**
* 拉黑好友
* @param {string} friendId
* @returns Result<boolean>
*/
blockFriend: (friendId) => request.post(`/friend/block?friendId=${friendId}`),
/**
* 检查好友关系
* @param {string} userId
* @param {string} targetId
* @returns Result<boolean>
*/
checkFriend: (userId, targetId) => request.get(`/friend/checkFriend?userId=${userId}&targetId=${targetId}`),
/**
* 按用户名模糊搜索用户
* @param {string} keyword
* @returns Result<UserResponse>
*/
findUser: (keyword) => request.get(`/user/findByUname?username=${encodeURIComponent(keyword)}`),
/**
* 发起好友申请
* @param {{ targetId, description?, remarkName? }} params
* @returns Result<FriendRequestResponse>
*/
requestFriend: (params) => request.post('/friendRequest/add', {
targetId: params.targetId,
description: params.description,
remarkName: params.remarkName,
}),
/** /**
* 获取好友请求列表 * 获取好友请求列表
* @param {*} page * @returns Result<List<FriendRequestResponse>>
* @param {*} limit
* @returns
*/ */
getFriendRequests: (page = 1, limit = 100) => request.get(`/friend/requests?page=${page}&limit=${limit}`), getFriendRequests: () => request.get('/friendRequest/list'),
/** /**
* 处理好友请求 * 处理好友请求
* @param {*} friendRequestId * @param {string} requestId 请求ID
* @param {typeof FRIEND_ACTIONS[keyof typeof FRIEND_ACTIONS]} action * @param {number} action 0=同意, 1=拒绝, 2=拉黑
* @returns * @param {string} remarkName 备注名(同意时必填)
* @returns Result<boolean>
*/ */
handleFriendRequest: (friendRequestId, action, remarkname) => request.post(`/Friend/HandleRequest?id=${friendRequestId}`, { handleFriendRequest: (requestId, action, remarkName) => request.post('/friendRequest/handle', {
remarkName: remarkname, requestId,
action: action action,
}) remarkName,
}),
} }
@@ -1,62 +1,115 @@
import { request } from "./api" import { request } from "./api";
export const groupService = { export const groupService = {
/** /**
* 创建群聊 * 我加入的群列表
* @param {*} data * @returns Result<List<GroupResponse>>
* @returns
*/ */
createGroup: (data) => request.post('/Group/CreateGroup', data), getAllGroups: () => request.get('/group/getAll'),
/** /**
* 查询群组成员 * 群详情
* @param {*} groupId * @param {string} groupId
* @returns * @returns Result<GroupResponse>
*/ */
getGroupMember: (groupId) => request.get(`/Group/GetGroupMembers?groupId=${groupId}`), getGroupInfo: (groupId) => request.get(`/group/getOne?groupId=${groupId}`),
/**
* 创建群聊
* @param {{ name }} data
* @returns Result<GroupResponse>
*/
createGroup: (data) => request.post('/group/create', { name: data.name }),
/** /**
* 更新群组信息 * 更新群组信息
* @param {*} groupId * @param {{ groupId, groupName?, avatar?, description? }} params
* @param {*} params * @returns Result<GroupResponse>
* @returns
*/ */
updateGroupInfo: (params) => request.post('/group/update', {
updateGroupInfo: (groupId, params) => request.post(`/Group/UpdateGroup?groupId=${groupId}`, params), groupId: params.groupId,
/** groupName: params.groupName,
* 查询群组信息 avatar: params.avatar,
* @param {*} groupId description: params.description,
* @returns }),
*/
getGroupInfo: (groupId) => request.get(`/Group/GetGroupInfo?groupId=${groupId}`),
/**
* 邀请入群
* @param {*} groupId
* @param {*} users
* @returns
*/
inviteUser: (groupId, users) =>
request.post('/Group/InviteUser', { groupId: groupId, ids: users }),
/** /**
* 获取群聊通知 * 查询群成员列表
* @returns * @param {string} groupId
* @returns Result<List<GroupMemberResponse>>
*/ */
getGroupNotification: () => request.get('/Group/GetGroupNotification'), getGroupMember: (groupId) => request.get(`/groupMember/list?groupId=${groupId}`),
/** /**
* 处理入群邀请 * 检查群成员
* @param {*} inviteId * @param {string} userId
* @param {*} action * @param {string} groupId
* @returns * @returns Result<boolean>
*/ */
handleGroupInvite: (inviteId, action) => checkMember: (userId, groupId) => request.get(`/groupMember/checkMember?userId=${userId}&groupId=${groupId}`),
request.post('/Group/HandleGroupInvite', { inviteId: inviteId, action: action }),
/** /**
* 处理入群请求 * 移除群成员
* @param {*} requestId * @param {string} memberId
* @param {*} action * @returns Result<boolean>
* @returns
*/ */
handleGroupRequest: (requestId, action) => deleteMember: (memberId) => request.post(`/groupMember/delete?memberId=${memberId}`),
request.post('/Group/HandleGroupRequest', { requestId: requestId, action: action })
/** 当前成员主动退群。 */
leaveGroup: (groupId) => request.post(`/groupMember/leave?groupId=${groupId}`),
/** 群主解散群。 */
dissolveGroup: (groupId) => request.post(`/group/dissolve?groupId=${groupId}`),
/**
* 邀请单个用户入群
* @param {string} groupId
* @param {string} userId
* @returns Result<GroupInvitationResponse>
*/
inviteUser: (groupId, userId) => request.post('/groupInvitation/send', { groupId, userId }),
/**
* 获取邀请详情
* @param {string} invitationId
* @returns Result<GroupInvitationResponse>
*/
getInvitation: (invitationId) => request.get(`/groupInvitation/get?invitationId=${invitationId}`),
/**
* 处理群邀请
* @param {string} invitationId
* @param {'Accept'|'Reject'} action
* @returns Result<boolean>
*/
handleGroupInvite: (invitationId, action) => request.post(`/groupInvitation/handle?invitationId=${invitationId}&action=${action}`),
/**
* 申请入群
* @param {string} groupId
* @param {string} desc 入群描述 (≤20字符)
* @returns Result<GroupRequestResponse>
*/
sendGroupRequest: (groupId, desc) => request.post('/groupRequest/send', { groupId, desc }),
/**
* 处理入群申请
* @param {string} requestId
* @param {'Accept'|'Reject'} action
* @returns Result<boolean>
*/
handleGroupRequest: (requestId, action) => request.post(`/groupRequest/handle?requestId=${requestId}&action=${action}`),
/**
* 查询入群申请详情
* @param {string} id
* @returns Result<GroupRequestResponse>
*/
findGroupRequest: (id) => request.get(`/groupRequest/find?id=${id}`),
/**
* 入群申请列表(替代旧 getGroupNotification,只展示入群申请;邀请通知走私聊消息)
* @returns Result<List<GroupRequestResponse>>
*/
getGroupNotification: () => request.get('/groupRequest/list'),
} }
@@ -2,41 +2,49 @@ import { request } from "./api";
export const messageService = { export const messageService = {
/** /**
* 获取当前用户会话 * 获取当前用户会话列表
* @returns * @returns Result<List<ConversationResponse>>
*/ */
getConversations: () => request.get('/conversation/list'), getConversations: () => request.get('/conversation/list'),
/**
* 清空所有会话消息
* @returns
*/
clearConversation: () => request.post(''),
/** /**
* 获取单个会话信息 * 获取单个会话信息
* @param {*} conversationId * @param {string} id 会话ID
* @returns * @returns Result<ConversationResponse>
*/ */
getConversationById: (conversationId) => request.get(`/conversation/get?conversationId=${conversationId}`), getConversationById: (id) => request.get(`/conversation/get?id=${id}`),
/** /**
* 获取历史消息列表 * 获取消息列表
* @param {*} conversationId 指定会话 * @param {string} conversationId 会话ID
* @param {*} msgId * @param {number|null} cursor 游标 (sequenceId),查最新消息传 null
* @param {*} pageSize * @param {number} direction 0=查历史消息, 1=查锚点后的消息
* @returns
*/
//getHistoryMessages: (conversationId, msgId, pageSize = 10) => request.get(`/message/getmessageList?conversationId=${conversationId}&msgId=${msgId}&pageSize=${pageSize}`),
/**
* 获取消息
* @param {*} conversationId 会话ID
* @param {Number} cursor 锚点(对应sequenceId),查询最新消息传null
* @param {Number} direction 方向 0为查历史消息 1为查锚点后的消息,查询最新消息传0 需配合cursor
* @param {number} limit 单次查询消息数 * @param {number} limit 单次查询消息数
* @returns * @returns Result<{ messages: MessageResponse[], hasmore: boolean }>
*/ */
getMessages: (conversationId, cursor, direction, limit) => request.get( getMessages: (conversationId, cursor, direction, limit) =>
`/message/getmessageList?conversationId=${conversationId}${cursor ? '&cursor=' + cursor : ''}&direction=${direction}&limit=${limit}` request.get(
`/message/getMessages?conversationId=${conversationId}${cursor != null ? '&cursor=' + cursor : ''}&direction=${direction}&limit=${limit}`
), ),
sendMessage: (msg) => request.post('/Message/SendMessage', msg) /**
* 发送消息
* @param {{ clientMsgId, targetId, chatType, msgType, quoteMessageId?, ext?, text?, url?, width?, height?, thumb?, duration?, fileId?, fileName?, fileSize?, fileFormat? }} msg
* @returns Result<MessageResponse>
*/
sendMessage: (msg) => request.post('/message/send', msg),
/**
* 撤回消息
* @param {string} msgId
* @returns Result<boolean>
*/
withdraw: (msgId) => request.post(`/message/withDraw?msgId=${msgId}`),
/**
* 清零会话未读数
* @param {string} conversationId
* @returns Result<boolean>
*/
markRead: (conversationId) => request.post(`/conversation/markRead?conversationId=${conversationId}`),
} }
@@ -1,52 +1,106 @@
import { request } from "../api"; import { request } from "../api";
import axios from 'axios';
export const uploadService = { export const uploadService = {
/** /**
* 创建文件上传任务 * 小文件直传(头像/封面等),后端自动秒传
* @param {*} fileName 文件 * @param {File} file 文件对象
* @param {*} fileSize 文件大小 * @param {boolean} isPublic true 落公开桶返回直链
* @param {*} contentType 文件类型 * @returns Result<FileResponse>
* @param {*} fileHash 文件哈希
* @returns
*/ */
createUploadTask: (fileName, fileSize, contentType, fileHash) => request.post('/Upload/CreateTask', { uploadSmallFile: (file, isPublic = true) => {
fileName: fileName,
fileSize: fileSize,
contentType: contentType,
fileHash: fileHash
}),
/**
* 创建分段任务
* @param {*} taskId 任务ID
* @param {*} partNum 分段序号
* @returns
*/
createPartTask: (taskId, partNum) => request.post(`/Upload/CreatePart?taskId=${taskId}&partNum=${partNum}`),
completeTask: (taskId, data) => request.post(`/Upload/CompleteTask?taskId=${taskId}`, data),
uploadPart: (uploadUrl, headers, file, onProgress) => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('isPublic', isPublic)
return request.post('/file/simple-upload', formData)
},
return request.post( /**
uploadUrl, * 初始化分片上传任务
formData, * @param {{ conversationId, chatType, targetId, fileName, fileSize, contentType, checkSum }} params
{ * @returns Result<{ taskId, uploadSessionId, storageLocation }>
baseURL: '', */
headers, // 不要包含 Content-Type initUploadTask: (params) => request.post('/fileTask/init', {
onUploadProgress: e => { conversationId: params.conversationId,
chatType: params.chatType,
targetId: params.targetId,
fileName: params.fileName,
fileSize: params.fileSize,
contentType: params.contentType,
checkSum: params.checkSum,
}),
/**
* 获取分片上传地址
* @param {string} sessionId
* @param {number} partNum 分片序号 (1-based)
* @returns Result<string> 预签名上传 URL
*/
getUploadUrl: (sessionId, partNum) => request.get(`/fileTask/getuploadurl?sessionId=${sessionId}&partNum=${partNum}`),
/**
* 查询分片上传进度
* @param {string} sessionId
* @returns Result<{ sessionId, taskId, fileSize, totalPartCount, completedPartCount, uploadedBytes, progressPercent }>
*/
getProgress: (sessionId) => request.get(`/fileTask/progress?sessionId=${sessionId}`),
/** 查询异步合并及最终文件结果。 */
getTaskStatus: (taskId) => request.get(`/fileTask/status?taskId=${taskId}`),
/** 鉴权读取私有文件内容。 */
downloadFile: (fileId) => request.get(`/file/${fileId}/content`, { responseType: 'blob' }),
/**
* 完成分片上传(合并)
* @param {string} sessionId
* @param {{ partNumber: number, eTag: string, size: number }[]} parts
* @returns Result<UploadTaskResponse>
*/
completeTask: (sessionId, parts) => request.post('/fileTask/complete', { sessionId, parts }),
/**
* 本地上传单个分片
* @param {string} sessionId
* @param {number} partNumber
* @param {File|Blob} file 分片二进制
* @param {(pct: number) => void} onProgress
* @returns Result<any>
*/
uploadPart: (sessionId, partNumber, file, onProgress) => {
const formData = new FormData()
formData.append('sessionId', sessionId)
formData.append('partNumber', partNumber)
formData.append('file', file)
return request.post('/fileTask/local/parts/upload', formData, {
onUploadProgress: (e) => {
if (onProgress && e.total) { if (onProgress && e.total) {
onProgress(e.loaded / e.total) onProgress(e.loaded / e.total)
} }
} },
} })
)
}, },
uploadSmallFile: (file, hash) => { /**
const formData = new FormData() * 分片上传到预签名 URL (storageLocation=1 时)
formData.append('file', file) * @param {{url:string, method:string, headers:Object}} presigned 预签名请求描述
return request.post(`/Upload/upload/${hash}`, formData); * @param {File|Blob} file 分片
* @param {(pct: number) => void} onProgress
* @returns
*/
uploadPartToUrl: async (presigned, file, onProgress) => {
const response = await axios.request({
url: presigned.url,
method: presigned.method || 'PUT',
data: file,
headers: presigned.headers || {},
onUploadProgress: (e) => {
if (onProgress && e.total) {
onProgress(e.loaded / e.total)
} }
},
})
return { eTag: response.headers?.etag || response.headers?.ETag || '' }
},
} }
@@ -1,122 +1,168 @@
import { reactive } from "vue"; import { reactive } from "vue";
import { uploadService } from "./uploadService"; import { uploadService } from "./uploadService";
import { getFileHash, sliceFile } from "@/utils/uploadTools"; import { getFileHash, sliceFile } from "@/utils/uploadTools";
import { request } from "../api";
import { UPLOAD_STATUS } from "@/constants/uploadStatus"; import { UPLOAD_STATUS } from "@/constants/uploadStatus";
const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
/**
* 大文件分片上传
* 流程:init → uploadParts → complete → status
* 秒传:init 显式返回 instant=true 和最终 file。
*/
export const uploadFile = async (file, { export const uploadFile = async (file, {
onProgress, onProgress,
onPartComplete onPartComplete,
conversationId,
chatType,
targetId,
} = {}) => { } = {}) => {
const fileHash = await getFileHash(file); const fileHash = await getFileHash(file);
const { taskId, chunkSize, concurrency, skip, url } = (await uploadService.createUploadTask(file.name, file.size, file.type, fileHash)).data;
if (skip) { // Step 1: init 上传任务
const initRes = await uploadService.initUploadTask({
conversationId,
chatType,
targetId,
fileName: file.name,
fileSize: file.size,
contentType: file.type || 'application/octet-stream',
checkSum: fileHash,
});
const {
taskId,
uploadSessionId,
uploadMode,
totalPartCount: initializedPartCount,
partSizeBytes = DEFAULT_PART_SIZE,
instant,
} = initRes.data;
if (instant && initRes.data.file) {
const uploadStatus = { const uploadStatus = {
status: UPLOAD_STATUS.COMPLETE, status: UPLOAD_STATUS.COMPLETE,
progress: 100, progress: 100,
taskId: taskId, taskId,
url: url fileId: initRes.data.file.id,
} url: initRes.data.file.url || null,
onProgress?.(uploadStatus) file: initRes.data.file,
return; };
onProgress?.(uploadStatus);
return initRes.data.file;
} }
const chunks = sliceFile(file, chunkSize); // Step 2: 获取 totalPartCount
let totalPartCount = initializedPartCount;
if (!totalPartCount) {
const progressRes = await uploadService.getProgress(uploadSessionId);
totalPartCount = progressRes.data.totalPartCount;
}
const comleteData = []; // 切片
const chunks = sliceFile(file, partSizeBytes);
if (chunks.length !== totalPartCount) {
throw new Error(`分片数量不一致:客户端 ${chunks.length},服务端 ${totalPartCount}`);
}
const parts = [];
let chunkProgress = reactive(new Array(chunks.length).fill(0)) let chunkProgress = reactive(new Array(chunks.length).fill(0));
const tasks = chunks.map((chunk, index) => { // Step 3: 逐个上传分片
const uploadTasks = chunks.map((chunk, index) => {
return async () => { return async () => {
const partNum = index + 1; const partNumber = index + 1;
const { skip, method, url, headers, partNumber } = (await uploadService.createPartTask(taskId, partNum)).data;
if (!skip) { if (uploadMode === 'Presigned') {
const { data } = await uploadService.uploadPart(url, headers, chunks[index], p => { // 预签名 URL 直传(非本地存储)
const urlRes = await uploadService.getUploadUrl(uploadSessionId, partNumber);
const result = await uploadService.uploadPartToUrl(urlRes.data, chunk, (p) => {
chunkProgress[index] = p; chunkProgress[index] = p;
// 第三步:计算总进度 reportProgress(chunkProgress, chunks.length, taskId, onProgress);
// 把账本上所有的百分比加起来
const sum = chunkProgress.reduce((acc, cur) => acc + cur, 0);
const total = (sum / chunks.length) * 100;
const displayTotal = total.toFixed(2);
const uploadStatus = {
status: displayTotal == 100 ? UPLOAD_STATUS.UPLOADED : UPLOAD_STATUS.UPLOADING,
progress: displayTotal,
taskId: taskId,
url: null
}
onProgress?.(uploadStatus)
}); });
onPartComplete?.(partNum) parts.push({ partNumber, eTag: result?.eTag || '', size: chunk.size });
return data;
} else { } else {
return { skip, partNumber }; // 本地分片上传
} const result = await uploadService.uploadPart(uploadSessionId, partNumber, chunk, (p) => {
} chunkProgress[index] = p;
reportProgress(chunkProgress, chunks.length, taskId, onProgress);
});
onPartComplete?.(partNumber);
parts.push({ partNumber, eTag: result?.data?.eTag || '', size: chunk.size });
}
};
}); });
const results = await concurrentUpload(tasks, concurrency);
const errors = results.filter(r => r.status === 'rejected'); // 并发 3
await concurrentUpload(uploadTasks, 3);
if (errors.length > 0) return; onProgress?.({ status: UPLOAD_STATUS.MERGING, progress: 100, taskId, url: null });
await uploadService.completeTask(uploadSessionId, parts);
await uploadService.completeTask(taskId, comleteData); // Step 4: 等待异步合并落库,最终结果以 status 为准。
const MAX_POLL_ATTEMPTS = 120;
const evtSource = new EventSource(`${request.instance.defaults.baseURL}/upload/events/${taskId}`); for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
const statusRes = await uploadService.getTaskStatus(taskId);
evtSource.onmessage = (event) => { const task = statusRes.data;
const data = JSON.parse(event.data); if (task.state === 'Failed') {
throw new Error(task.failureReason || '文件合并失败');
}
if (task.state === 'Completed' && task.file) {
const uploadStatus = { const uploadStatus = {
status: data.progress == 100 ? UPLOAD_STATUS.COMPLETE : UPLOAD_STATUS.MERGING, status: UPLOAD_STATUS.COMPLETE,
taskId: taskId, progress: 100,
progress: data.progress, taskId,
url: data.url fileId: task.file.id,
} url: task.file.url || null,
onProgress?.(uploadStatus) file: task.file,
if (data.status === "Completed") {
evtSource.close();
}
}; };
onProgress?.(uploadStatus);
return task.file;
}
await sleep(1000);
}
throw new Error('等待文件合并超时');
};
evtSource.onerror = (err) => { function reportProgress(chunkProgress, total, taskId, onProgress) {
console.error("SSE 连接异常", err); const sum = chunkProgress.reduce((acc, cur) => acc + cur, 0);
}; const totalProgress = (sum / total) * 100;
onProgress?.({
status: totalProgress >= 100 ? UPLOAD_STATUS.UPLOADED : UPLOAD_STATUS.UPLOADING,
progress: totalProgress.toFixed(2),
taskId,
url: null,
});
} }
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
const concurrentUpload = async (tasks, limit = 3, maxRetries = 3) => { const concurrentUpload = async (tasks, limit = 3, maxRetries = 3) => {
const results = []; const results = [];
const executing = []; const executing = [];
for (const task of tasks) { for (const task of tasks) {
const retryTask = async (task) => { const retryTask = async (t) => {
let attempt = 0; let attempt = 0;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
try { try {
return await task(); return await t();
} catch (e) { } catch (e) {
attempt++; attempt++;
if (attempt > maxRetries) { if (attempt > maxRetries) throw e;
throw e;
}
}
} }
} }
};
const p = Promise.resolve().then(() => retryTask(task)); const p = Promise.resolve().then(() => retryTask(task));
results.push(p); results.push(p);
if (limit <= tasks.length) { if (limit <= tasks.length) {
const e = p.finally(() => executing.splice(executing.indexOf(e), 1)); const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
executing.push(e); executing.push(e);
if (executing.length >= limit) await Promise.race(executing);
if (executing.length >= limit) {
await Promise.race(executing);
} }
} }
} return Promise.all(results);
};
return Promise.allSettled(results);
}
@@ -1,6 +1,37 @@
import { request } from "./api"; import { request } from "./api";
export const userService = { export const userService = {
updateUserInfo: (params) => request.post('/User/Profile', params), /**
getInfo: () => request.get('/user/me') * 当前用户信息
* @returns Result<UserResponse>
*/
getInfo: () => request.get('/user/me'),
/**
* 更新用户资料
* @param {*} params { nickName?, region?, avatar?, description? }
* @returns Result<UserResponse>
*/
updateUserInfo: (params) => request.post('/user/update', params),
/**
* 查询指定用户 (按 userId)
* @param {string} userId
* @returns Result<UserResponse>
*/
findUser: (userId) => request.get(`/user/find?userId=${userId}`),
/**
* 按用户名模糊搜索用户
* @param {string} userName
* @returns Result<UserResponse>
*/
findByUname: (userName) => request.get(`/user/findByUname?username=${encodeURIComponent(userName)}`),
/**
* 批量查询用户
* @param {string[]} ids Guid 数组
* @returns Result<List<UserResponse>>
*/
getUsersByIds: (ids) => request.post('/user/getUsersByIds', ids),
} }
+39 -12
View File
@@ -1,10 +1,20 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
// 安全解析 localStorage 中的 JSON,避免存入 "undefined" 字符串时崩溃
const safeParse = (raw) => {
if (!raw || raw === 'undefined' || raw === 'null') return {};
try {
return JSON.parse(raw) || {};
} catch {
return {};
}
};
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('user_token') || ''); const token = ref(localStorage.getItem('user_token') || '');
const refreshToken = ref(localStorage.getItem('refresh_token') || ''); const refreshToken = ref(localStorage.getItem('refresh_token') || '');
const userInfo = ref(JSON.parse(localStorage.getItem('user_info')) || {}); const userInfo = ref(safeParse(localStorage.getItem('user_info')));
//判断是否已登录 //判断是否已登录
const isLoggedIn = computed(() => !!refreshToken.value); const isLoggedIn = computed(() => !!refreshToken.value);
@@ -15,15 +25,27 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const base64Url = t.split('.')[1]; const base64Url = t.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// 处理 Unicode 字符解码
return JSON.parse(decodeURIComponent(atob(base64).split('').map(c => return JSON.parse(decodeURIComponent(atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join(''))); ).join('')));
} catch (e) { } catch {
return null; return null;
} }
}; };
// user_info 可能被写坏(如 setUserInfo 传入 undefined 存成字符串 "undefined"),
// 会话恢复时若缺 id,用 JWT 的 nameidentifier 兜底补齐(该声明即服务端认可的用户 id)
if (token.value && !userInfo.value?.id) {
try {
const payload = getPayload(token.value);
const jwtId = payload?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'];
if (jwtId) {
userInfo.value = { ...userInfo.value, id: jwtId };
localStorage.setItem('user_info', JSON.stringify(userInfo.value));
}
} catch { /* 忽略,保持现状 */ }
}
// 检查 Token 是否过期 // 检查 Token 是否过期
const isTokenExpired = computed(() => { const isTokenExpired = computed(() => {
if (!token.value) return true; if (!token.value) return true;
@@ -36,20 +58,25 @@ export const useAuthStore = defineStore('auth', () => {
/** /**
* 登录成功保存状态 * 登录成功保存状态
* @param {String} newToken 用户凭证 * LoginResponse 扁平化:
* @param {*} user 用户信息 * { userId, token, refreshToken, expired, userName, nickName, avatar, creationTime }
* @param {Object} loginData res.data (LoginResponse)
*/ */
function setLoginInfo(newToken, newRefreshToken, user) { function setLoginInfo(loginData) {
console.log(`设置凭证:\ntoken${newToken}\nrefreshToken:${newRefreshToken}`) if (!loginData || typeof loginData !== 'object') return;
token.value = newToken; const { token: t, refreshToken: rt, userId, userName, nickName, avatar, creationTime } = loginData;
refreshToken.value = newRefreshToken token.value = t;
refreshToken.value = rt;
const user = { id: userId, userName, nickName, avatar, creationTime };
userInfo.value = user; userInfo.value = user;
localStorage.setItem('user_token', newToken); localStorage.setItem('user_token', t);
localStorage.setItem('refresh_token', newRefreshToken) localStorage.setItem('refresh_token', rt);
localStorage.setItem('user_info', JSON.stringify(user)) localStorage.setItem('user_info', JSON.stringify(user));
}; };
function setUserInfo(user){ function setUserInfo(user){
// 防御:避免把 user_info 写成字符串 "undefined"
if (!user || typeof user !== 'object') return;
userInfo.value = user; userInfo.value = user;
localStorage.setItem('user_info',JSON.stringify(user)); localStorage.setItem('user_info',JSON.stringify(user));
} }
+57 -25
View File
@@ -9,25 +9,63 @@ export const useChatStore = defineStore('chat', {
maxSequenceId: null, maxSequenceId: null,
isEnded: false, isEnded: false,
messages: [], messages: [],
pageSize: 20 pageSize: 20,
// 本地删除的消息 ID 集合(refresh 后从服务器还会拉回来,需要过滤)
deletedMsgKeys: new Set(JSON.parse(localStorage.getItem('deleted_msg_keys') || '[]'))
}), }),
actions: { actions: {
_normalizeMessage(message) {
const body = typeof message.content === 'object' ? message.content?.body : null;
return {
...message,
fileId: message.fileId ?? body?.fileId,
url: message.url ?? body?.url,
thumb: message.thumb ?? body?.thumb,
width: message.width ?? body?.width,
height: message.height ?? body?.height,
duration: message.duration ?? body?.duration,
fileName: message.fileName ?? body?.fileName,
fileSize: message.fileSize ?? body?.size,
fileFormat: message.fileFormat ?? body?.format,
};
},
// 统一过滤已删除消息
_filterDeleted(msgs) {
return msgs.filter(m => {
const key = m.msgId || m.clientMsgId || m.id
return !this.deletedMsgKeys.has(key)
})
},
deleteMessage(msg) {
const key = msg.msgId || msg.clientMsgId || msg.id
if (key) {
this.deletedMsgKeys.add(key)
localStorage.setItem('deleted_msg_keys', JSON.stringify([...this.deletedMsgKeys]))
// 从当前列表移除
const idx = this.messages.indexOf(msg)
if (idx >= 0) this.messages.splice(idx, 1)
// 从 IndexedDB 删除
messagesDb.delete(key).catch(() => {})
}
},
// 抽取统一的排序去重方法 // 抽取统一的排序去重方法
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) { async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
const filtered = this._filterDeleted(newMsgs).map((m) => this._normalizeMessage(m))
if (shouldSaveToDb) { if (shouldSaveToDb) {
for (const m of newMsgs) { for (const m of filtered) {
if (m.type != 'Text' && !m.isLoading && !m.isError && !m.isImgLoading) { await messagesDb.save({
m.content = JSON.parse(m.content); ...m,
} msgId: m.msgId || m.clientMsgId || m.id,
await messagesDb.save({ ...m, sessionId }); sessionId
});
} }
} }
if (sessionId == this.activeSessionId) { if (sessionId == this.activeSessionId) {
const combined = [...this.messages, ...newMsgs]; const combined = [...this.messages, ...filtered];
// 1. 根据 msgId 或唯一 key 去重 // 根据 msgId / clientMsgId / id 去重,降级 sequenceId
const uniqueMap = new Map(); const uniqueMap = new Map();
combined.forEach(m => uniqueMap.set(m.msgId || m.sequenceId, m)); combined.forEach(m => uniqueMap.set(m.msgId || m.clientMsgId || m.id || m.sequenceId, m));
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后) // 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
this.messages = Array.from(uniqueMap.values()).sort((a, b) => { this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
@@ -48,8 +86,8 @@ export const useChatStore = defineStore('chat', {
this.activeConversationId = conversationId; this.activeConversationId = conversationId;
this.messages = []; this.messages = [];
this.isEnded = false; this.isEnded = false;
//先从浏览器缓存加载一部分消息列表 const localHistory = this._filterDeleted(await messagesDb.getLatestMessages(sessionId, this.pageSize))
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize); .map((m) => this._normalizeMessage(m));
if (localHistory.length > 0) { if (localHistory.length > 0) {
this.messages = localHistory; this.messages = localHistory;
this.maxSequenceId = this.messages.reduce((max, m) => this.maxSequenceId = this.messages.reduce((max, m) =>
@@ -64,12 +102,10 @@ export const useChatStore = defineStore('chat', {
* @returns * @returns
*/ */
async fetchNewMsgFromServier(conversationId, sequenceId) { async fetchNewMsgFromServier(conversationId, sequenceId) {
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data; const res = await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize);
if (newMsg.length > 0) { const newMsgs = res.data?.messages || [];
return newMsg; // 注意:不在这里设置 isEnded,避免 direction=1(向前) 的 hasmore=false 错误地阻断历史加载
} else { return newMsgs;
return [];
}
}, },
/** /**
* 从服务器加载历史消息 * 从服务器加载历史消息
@@ -78,14 +114,10 @@ export const useChatStore = defineStore('chat', {
* @returns * @returns
*/ */
async fetchHistoryFromServer(conversationId, sequenceId) { async fetchHistoryFromServer(conversationId, sequenceId) {
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data; const res = await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize);
const msgs = res.data?.messages || [];
if (res.length > 0) { this.isEnded = !(res.data?.hasmore);
const sessionId = this.activeSessionId; return msgs;
return res;
} else {
return [];
}
}, },
/** /**
* 加载更多历史消息 * 加载更多历史消息
@@ -1,9 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { messageService } from "@/services/message"; import { messageService } from "@/services/message";
import { conversationDb } from "@/utils/db/conversationDB"; import { conversationDb } from "@/utils/db/conversationDB";
import { useMessage } from "@/components/messages/useAlert";
const message = useMessage();
export const useConversationStore = defineStore('conversation', { export const useConversationStore = defineStore('conversation', {
state: () => ({ state: () => ({
@@ -11,14 +8,21 @@ export const useConversationStore = defineStore('conversation', {
}), }),
// stores/conversation.js // stores/conversation.js
getters: { getters: {
// 始终根据时间倒序排列 // 置顶的排在前面,其余按时间倒序
sortedConversations: (state) => { sortedConversations: (state) => {
return [...state.conversations].sort((a, b) => return [...state.conversations].sort((a, b) => {
new Date(b.dateTime) - new Date(a.dateTime) const pinA = localStorage.getItem(`conv_${a.id}_pin`) === '1' ? 1 : 0
); const pinB = localStorage.getItem(`conv_${b.id}_pin`) === '1' ? 1 : 0
if (pinA !== pinB) return pinB - pinA
return new Date(b.dateTime) - new Date(a.dateTime)
});
} }
}, },
actions: { actions: {
async removeConversation(id) {
this.conversations = this.conversations.filter((item) => item.id !== id);
await conversationDb.delete(id);
},
async addConversation(conversation) { async addConversation(conversation) {
await conversationDb.save(conversation); await conversationDb.save(conversation);
this.conversations.unshift(conversation) this.conversations.unshift(conversation)
@@ -32,15 +36,14 @@ export const useConversationStore = defineStore('conversation', {
const covnersationsCache = await conversationDb.getAll(); const covnersationsCache = await conversationDb.getAll();
if (covnersationsCache && covnersationsCache.length > 0) { if (covnersationsCache && covnersationsCache.length > 0) {
this.conversations = covnersationsCache.sort((a, b) => { this.conversations = covnersationsCache.sort((a, b) => {
return new Date(a.dateTime) - new Date(b.dateTime); return new Date(b.dateTime || 0) - new Date(a.dateTime || 0);
}) })
} }
} catch (e) { } catch (e) {
message.error('读取本地会话缓存失败...');
console.log('读取本地会话缓存失败:', e); console.log('读取本地会话缓存失败:', e);
} }
} }
//await this.fetchConversationsFromServier() await this.fetchConversationsFromServier()
}, },
/** /**
* 从服务器加载新消息 * 从服务器加载新消息
@@ -48,24 +51,16 @@ export const useConversationStore = defineStore('conversation', {
* @returns * @returns
*/ */
async fetchConversationsFromServier() { async fetchConversationsFromServier() {
const newConversations = (await messageService.getConversations()).data; try {
if (newConversations.length > 0) { const res = await messageService.getConversations();
// 1. 将当前的本地数据转为 Map,方便通过 ID 快速查找 (O(1) 复杂度) const newConversations = res.data || [];
const localMap = new Map(this.conversations.map(item => [item.id, item])); const localMap = new Map(this.conversations.map(item => [item.id, item]));
newConversations.forEach(item => { this.conversations = newConversations.map((item) =>
const existingItem = localMap.get(item.id); Object.assign(localMap.get(item.id) || {}, item)
if (existingItem) { );
// --- 局部更新 --- await conversationDb.replaceAll(this.conversations);
// 使用 Object.assign 将新数据合并到旧对象上,保持响应式引用 } catch (e) {
Object.assign(existingItem, item); console.error('获取会话列表失败:', e);
} else {
// --- 插入新会话 ---
this.conversations.unshift(item);
}
// 同步到本地数据库
conversationDb.save(item);
});
} }
} }
} }
@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { groupService } from '@/services/group'
import { useMessage } from '@/components/messages/useAlert'
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus'
export const useGroupStore = defineStore('group', {
state: () => ({
groups: []
}),
actions: {
removeGroup(groupId) {
this.groups = this.groups.filter((group) => (group.id || group.groupId) !== groupId)
},
async loadGroups() {
const message = useMessage()
try {
const res = await groupService.getAllGroups()
if (res.code === SYSTEM_BASE_STATUS.SUCCESS) {
this.groups = res.data || []
} else {
message.error(res.message || '获取群列表失败')
}
} catch (e) {
console.error('获取群列表失败:', e)
}
}
}
})
@@ -1,5 +1,4 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
import { groupService } from '../services/group' import { groupService } from '../services/group'
import { useMessage } from '../components/messages/useAlert' import { useMessage } from '../components/messages/useAlert'
import { SYSTEM_BASE_STATUS } from '../constants/systemBaseStatus' import { SYSTEM_BASE_STATUS } from '../constants/systemBaseStatus'
@@ -14,6 +13,7 @@ export const useGroupRequestStore = defineStore('groupRequest', {
this.groupRequest = await groupRequestDb.getAll() this.groupRequest = await groupRequestDb.getAll()
const message = useMessage() const message = useMessage()
// 新 API: 群通知页只展示入群申请列表(邀请通知走私聊消息)
const res = await groupService.getGroupNotification() const res = await groupService.getGroupNotification()
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message) if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
@@ -1,20 +1,45 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { reactive } from "vue";
const NOTIFICATION_KEY = 'notification_options'
const GENERAL_KEY = 'general_options'
const safeParse = (raw, fallback) => {
if (!raw || raw === 'undefined' || raw === 'null') return fallback
try {
return { ...fallback, ...JSON.parse(raw) }
} catch {
return fallback
}
}
const DEFAULT_NOTIFICATION = {
systemUpdate: true,
newMsg: true, // 新消息提醒(总开关)
desktopPopup: true, // 桌面弹窗
emailDigest: false
}
const DEFAULT_GENERAL = {
theme: 'system', // light | dark | system
language: 'zh-CN',
autoStart: false,
closeBehavior: 'tray'
}
export const useSettingsStore = defineStore('settings', { export const useSettingsStore = defineStore('settings', {
state: () => ({ state: () => ({
notificationOptions: reactive(JSON.parse(localStorage.getItem('notification_options')) || {}), notificationOptions: safeParse(localStorage.getItem(NOTIFICATION_KEY), DEFAULT_NOTIFICATION),
generalOptions: reactive(JSON.parse(localStorage.getItem('generaN_options')) || {}) generalOptions: safeParse(localStorage.getItem(GENERAL_KEY), DEFAULT_GENERAL)
}), }),
actions: { actions: {
setNotificationOptions(options) { setNotificationOptions(options) {
this.notificationOptions = options; this.notificationOptions = { ...this.notificationOptions, ...options }
localStorage.setItem('notification_options', options) localStorage.setItem(NOTIFICATION_KEY, JSON.stringify(this.notificationOptions))
}, },
setGeneralOptions(options){ setGeneralOptions(options) {
this.generalOptions = options; this.generalOptions = { ...this.generalOptions, ...options }
localStorage.setItem('generaN_options', options) localStorage.setItem(GENERAL_KEY, JSON.stringify(this.generalOptions))
} }
} }
}) })
@@ -1,13 +1,10 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import * as signalR from '@microsoft/signalr'; import * as signalR from '@microsoft/signalr';
import { useMessage } from "@/components/messages/useAlert";
import { useAuthStore } from "./auth"; import { useAuthStore } from "./auth";
import { useChatStore } from "./chat";
import { authService } from "@/services/auth"; import { authService } from "@/services/auth";
import { generateSessionId } from "@/utils/sessionIdTools";
import { messageHandler } from "@/handler/messageHandler";
import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler"; import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler";
import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler"; import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler";
import { networkError } from "@/services/api";
export const useSignalRStore = defineStore('signalr', { export const useSignalRStore = defineStore('signalr', {
state: () => ({ state: () => ({
@@ -16,9 +13,8 @@ export const useSignalRStore = defineStore('signalr', {
}), }),
actions: { actions: {
async initSignalR() { async initSignalR() {
const message = useMessage()
const authStore = useAuthStore() const authStore = useAuthStore()
const url = import.meta.env.VITE_SIGNALR_BASE_URL || 'http://localhost:5202/chat/'; const url = import.meta.env.DEV ? '/chat' : (import.meta.env.VITE_SIGNALR_BASE_URL || 'http://localhost:5202/chat');
this.connection = new signalR.HubConnectionBuilder() this.connection = new signalR.HubConnectionBuilder()
.withUrl(url, .withUrl(url,
{ {
@@ -26,7 +22,7 @@ export const useSignalRStore = defineStore('signalr', {
accessTokenFactory: async () => { accessTokenFactory: async () => {
if (authStore.isTokenExpired) { if (authStore.isTokenExpired) {
const res = await authService.refresh(authStore.refreshToken) const res = await authService.refresh(authStore.refreshToken)
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo) authStore.setLoginInfo(res.data)
} }
return authStore.token; return authStore.token;
} }
@@ -37,65 +33,31 @@ export const useSignalRStore = defineStore('signalr', {
try { try {
await this.connection.start(); await this.connection.start();
this.isConnected = true; this.isConnected = true;
networkError.value = null;
signalRConnectionEventHandler(); signalRConnectionEventHandler();
console.log('SignalR建立通信成功!') console.log('SignalR建立通信成功!')
} catch (e) { } catch {
message.error('与服务器建立通信失败,请检查网络连接...'); networkError.value = '消息服务连接失败,实时消息不可用';
} }
}, },
registerHandlers() { registerHandlers() {
this.connection.on('ReceiveMessage', (msg) => { this.connection.on('ReceiveNewMessage', (msg) => {
console.log(msg) console.log(msg)
SignalRMessageHandler(msg) SignalRMessageHandler(msg)
}); });
this.connection.onclose(() => { this.connection.onclose(() => {
this.isConnected = false; this.isConnected = false;
networkError.value = '消息服务已断开,正在重连...';
}); });
this.connection.onreconnected(() => { this.connection.onreconnected(() => {
this.isConnected = true; this.isConnected = true;
networkError.value = null;
signalRConnectionEventHandler(); signalRConnectionEventHandler();
}); });
}, },
/**
* 通过signalr发送消息
* @param {*} msg
* @returns
*/
async sendMsg(msg) {
const message = useMessage()
const chatStore = useChatStore()
if (!this.isConnected) {
message.error('与服务器连接中断,请重连后尝试...');
return;
}
try {
// 后端 Hub 定义的方法名通常为 SendMessage
// 参数顺序需要与后端 ChatHub 中的方法签名一致
if (msg.msgId == null) {
msg.msgId = self.crypto.randomUUID();
}
const sessionId = generateSessionId(msg.senderId, msg.receiverId);
this.connection.invoke("SendMessage", msg).then(() => {
const msga = chatStore.messages.find(x => x.msgId == msg.msgId)
if (msga.isLoading) {
msga.isLoading = false;
}
})
;
chatStore.addMessage({ ...msg, isLoading: true }, sessionId);
messageHandler(msg);
console.log("消息发送成功!");
} catch (err) {
console.error("消息发送失败:", err);
message.error("消息发送失败");
}
},
async clearUnreadCount(conversationId) {
await this.connection.invoke("ClearUnreadCount", conversationId)
}
} }
}) })
@@ -1,7 +1,5 @@
export const GetLocalIso = (date) => { export const GetLocalIso = (date) => {
// 考虑到时区偏差,手动构造符合 C# 要求的本地 ISO 字符串 // 手动构造不带时区后缀的本地 ISO 字符串
const offset = -date.getTimezoneOffset();
const diff = offset >= 0 ? '+' : '-';
const pad = (num) => String(num).padStart(2, '0'); const pad = (num) => String(num).padStart(2, '0');
return date.getFullYear() + return date.getFullYear() +
@@ -6,8 +6,8 @@ const CONVERSARION_STORE_NAME = 'conversations'
const CONTACT_STORE_NAME = 'contacts' const CONTACT_STORE_NAME = 'contacts'
const GROUP_REQUEST_STORE_NAME = 'groupRequests' const GROUP_REQUEST_STORE_NAME = 'groupRequests'
export const dbPromise = openDB(DBNAME, 7, { export const dbPromise = openDB(DBNAME, 8, {
upgrade(db) { upgrade(db, oldVersion) {
if (!db.objectStoreNames.contains(STORE_NAME)) { if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' }) const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' })
store.createIndex('by-sessionId', 'sessionId') store.createIndex('by-sessionId', 'sessionId')
@@ -26,9 +26,13 @@ export const dbPromise = openDB(DBNAME, 7, {
store.createIndex('by-friendId', 'friendId', { unique: true }) store.createIndex('by-friendId', 'friendId', { unique: true })
} }
// v8: groupRequests 主键由 requestId 改为 id(接口数据字段为 id,原 requestId 导致 put 抛 DataError
if (oldVersion < 8 && db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) {
db.deleteObjectStore(GROUP_REQUEST_STORE_NAME)
}
if (!db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) { if (!db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) {
const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'requestId' }) const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'id' })
store.createIndex('by-id', 'requestId') store.createIndex('by-id', 'id')
store.createIndex('by-userid', 'userId') store.createIndex('by-userid', 'userId')
store.createIndex('by-groupid', 'groupId') store.createIndex('by-groupid', 'groupId')
} }
@@ -12,6 +12,16 @@ export const conversationDb = {
async getAll() { async getAll() {
return (await dbPromise).getAll(STORE_NAME); return (await dbPromise).getAll(STORE_NAME);
}, },
async replaceAll(conversations) {
const db = await dbPromise;
const tx = db.transaction(STORE_NAME, 'readwrite');
await tx.store.clear();
await Promise.all(conversations.map((item) => tx.store.put(item)));
await tx.done;
},
async delete(id) {
return (await dbPromise).delete(STORE_NAME, id);
},
async clearAll() { async clearAll() {
(await dbPromise).clear(STORE_NAME); (await dbPromise).clear(STORE_NAME);
} }
@@ -7,6 +7,6 @@ export const groupRequestDb = {
(await dbPromise).put(STORE_NAME, request) (await dbPromise).put(STORE_NAME, request)
}, },
async getAll(){ async getAll(){
(await dbPromise).getAll(STORE_NAME) return await (await dbPromise).getAll(STORE_NAME)
} }
} }
@@ -0,0 +1,13 @@
/**
* 防抖:连续触发时只在最后一次调用结束后等待 wait 毫秒再执行。
* @param {Function} fn 要防抖的函数
* @param {number} wait 等待毫秒数,默认 500
* @returns {Function} 防抖后的函数
*/
export function debounce(fn, wait = 500) {
let timer = null
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), wait)
}
}
@@ -1,7 +1,9 @@
export function formatDate(dateStr) { export function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr); const date = new Date(dateStr);
if (isNaN(date.getTime())) return ''
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // 补零 const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
@@ -78,7 +78,7 @@ export function getVideoThumbnailBlob(file, seekTime = 1) {
// 如果导出的 blob 大小极小(可能是空的),可以在这里进一步检查 // 如果导出的 blob 大小极小(可能是空的),可以在这里进一步检查
resolve(blob); resolve(blob);
}, 'image/jpeg', 0.8); }, 'image/jpeg', 0.8);
} catch (e) { } catch {
resolveBlackThumbnail(); resolveBlackThumbnail();
} }
}; };
@@ -1,28 +1,65 @@
import { useBrowserNotification } from '@/services/useBrowserNotification' import { useBrowserNotification } from '@/services/useBrowserNotification'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { messageHandler } from '@/handler/messageHandler' import { messageHandler } from '@/handler/messageHandler'
import { generateSessionId } from '../sessionIdTools' import { generateSessionId } from '../sessionIdTools'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { MESSAGE_TYPE } from '@/constants/MessageType' import { CHAT_TYPE } from '@/constants/MessageType'
import { NOTIFICATION_TYPE } from '../../constants/notificationType' import { useSettingsStore } from '@/stores/settings'
export const SignalRMessageHandler = (raw) => {
const arr = raw['arguments'] || []
const msg = arr[0] || raw.data || raw
if (!msg || !msg.senderId) return
// 归一化
msg.msgId = msg.msgId || msg.clientId || msg.id
msg.timeStamp = msg.pushTimestamp ? new Date(msg.pushTimestamp).toISOString() : (msg.timeStamp || new Date().toISOString())
if (msg.content?.body) {
msg.content.body.text = msg.content.body.text || msg.content.body.Text || ''
}
export const SignalRMessageHandler = (data) => {
const msg = data.data
const type = data.type
const chatStore = useChatStore() const chatStore = useChatStore()
const browserNotification = useBrowserNotification() const settingsStore = useSettingsStore()
const { newMsg, desktopPopup } = settingsStore.notificationOptions
const sessionId = generateSessionId( const sessionId = generateSessionId(
msg.senderId, msg.senderId,
msg.receiverId, msg.targetId,
msg.chatType == MESSAGE_TYPE.GROUP msg.chatType == CHAT_TYPE.GROUP
) )
messageHandler(msg) messageHandler(msg) // asyncawait 可选——不阻塞 UI
chatStore.pushAndSortMessagesAsync([msg], sessionId) chatStore.pushAndSortMessagesAsync([msg], sessionId)
const conversation = useConversationStore().conversations.find((x) => x.targetId == msg.senderId)
if (!newMsg) return
const conversationStore = useConversationStore()
const conversation = conversationStore.conversations.find((x) => x.targetId == msg.senderId)
const contentText = typeof msg.content === 'object'
? (msg.content?.body?.text || msg.content?.fallback || '')
: msg.content;
const isMuted = conversation ? localStorage.getItem(`conv_${conversation.id}_mute`) === '1' : false
if (isMuted) return
// 闪动 & 声音:任何新消息都触发,不依赖会话是否已加载
try { window.api?.window?.flash?.() } catch { /* Desktop attention feedback is best-effort. */ }
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.frequency.value = 800; gain.gain.value = 0.08
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.15)
} catch { /* Audio feedback may be blocked by the runtime or user settings. */ }
// 桌面弹窗需要会话信息(名称+头像)
if (conversation && desktopPopup) {
const browserNotification = useBrowserNotification()
browserNotification.send(`${conversation.targetName}发来一条消息`, { browserNotification.send(`${conversation.targetName}发来一条消息`, {
body: msg.content, body: contentText,
icon: conversation.targetAvatar icon: conversation.targetAvatar
}) })
}
} }
@@ -2,7 +2,7 @@ import { useConversationStore } from "@/stores/conversation"
export const signalRConnectionEventHandler = () => { export const signalRConnectionEventHandler = () => {
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
conversationStore.fetchConversationsFromServier().then(res => { conversationStore.fetchConversationsFromServier().then(() => {
conversationStore.conversations.forEach(element => { conversationStore.conversations.forEach(element => {
element.isInitialized = false; element.isInitialized = false;
}); });
@@ -222,12 +222,7 @@ onMounted(async () => {
} }
/* 头像样式统一 */ /* 头像样式统一 */
:deep(.avatar-std) { :deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0; }
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
}
.avatar-chat { .avatar-chat {
width: 38px; width: 38px;
@@ -9,7 +9,6 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useCacheStore } from '../stores/cache'; import { useCacheStore } from '../stores/cache';
import { FILE_TYPE } from '../constants/fileTypeDefine'; import { FILE_TYPE } from '../constants/fileTypeDefine';
import AsyncImage from '../components/AsyncImage.vue';
import Dropdown from '../components/Dropdown.vue'; import Dropdown from '../components/Dropdown.vue';
@@ -29,7 +29,7 @@
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox"> 自动登录 <input type="checkbox"> 自动登录
</label> </label>
<router-link to="/forget" class="link">找回密码</router-link> <a href="#" class="link" @click.prevent="message.info('找回密码功能开发中')">找回密码</a>
</div> </div>
<button type="submit" class="login-btn" :disabled="loading"> <button type="submit" class="login-btn" :disabled="loading">
@@ -97,7 +97,7 @@ const handleLogin = async () => {
const res = await authService.login(form); const res = await authService.login(form);
if (res.code === 0) { // Assuming 0 is success if (res.code === 0) { // Assuming 0 is success
message.success('登录成功') message.success('登录成功')
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo); authStore.setLoginInfo(res.data);
signalRStore.initSignalR(); signalRStore.initSignalR();
router.push('/messages') router.push('/messages')
} else { } else {
@@ -141,10 +141,9 @@ const handleRegister = async () => {
try { try {
loading.value = true; loading.value = true;
// 调用注册接口,过滤掉本地状态如 confirmPassword 和 agree // API.md Register 参数: userName, password, nickName (不含 email)
const res = await authService.register({ const res = await authService.register({
username: form.username, userName: form.username,
email: form.email,
password: form.password, password: form.password,
nickName: form.nickName nickName: form.nickName
}); });
@@ -48,37 +48,23 @@ import { ref, computed, onMounted } from 'vue'
import GroupChatModal from '@/components/groups/GroupChatModal.vue' import GroupChatModal from '@/components/groups/GroupChatModal.vue'
import feather from 'feather-icons'; import feather from 'feather-icons';
import { useContactStore } from '@/stores/contact'; import { useContactStore } from '@/stores/contact';
import { useRouter } from 'vue-router'; import { useGroupStore } from '@/stores/group';
import contactShow from '@/components/contacts/contactShow.vue'; import contactShow from '@/components/contacts/contactShow.vue';
import groupsShow from '@/components/groups/groupsShow.vue'; import groupsShow from '@/components/groups/groupsShow.vue';
const searchQuery = ref('') const searchQuery = ref('')
const contactStore = useContactStore(); const contactStore = useContactStore();
const groupStore = useGroupStore();
const groupModal = ref(false); const groupModal = ref(false);
const contactTab = ref(0); const contactTab = ref(0);
const myGroups = ref([ const myGroups = computed(() => {
{ const searchKey = searchQuery.value.toString().trim();
id: 1, if (!searchKey) return groupStore.groups;
name: "产品设计交流群", return groupStore.groups.filter(g => (g.name || '').includes(searchKey));
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1", });
lastMessage: "那个UI设计的初稿已经发在群文件了,大家记得看下。",
lastTime: "14:20",
unread: 3,
online: true
},
{
id: 2,
name: "周五羽毛球小分队",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
lastMessage: "这周五晚上 8 点,老地方见!",
lastTime: "昨天",
unread: 0,
online: false
}
]);
const filteredContacts = computed(() => { const filteredContacts = computed(() => {
@@ -93,9 +79,10 @@ const filteredContacts = computed(() => {
if (!c) return false if (!c) return false
const remark = c.remarkName || '' const remark = c.remarkName || ''
const username = c.userInfo.username || '' const nickname = c.nickName || ''
const userName = c.targetId || c.userName || ''
return remark.includes(searchKey) || username.includes(searchKey) return remark.includes(searchKey) || nickname.includes(searchKey) || userName.includes(searchKey)
}) })
}) })
@@ -104,13 +91,17 @@ const filteredContacts = computed(() => {
// 发送事件给父组件(用于切换回聊天Tab并打开会话) // 发送事件给父组件(用于切换回聊天Tab并打开会话)
const emit = defineEmits(['start-chat']) const emit = defineEmits(['start-chat'])
// const showGroupList = () => { // 群聊弹窗选择回调
// } const handleChatSelect = (group) => {
groupModal.value = false
emit('start-chat', group)
}
onMounted(async () => { onMounted(async () => {
await contactStore.loadContactList(); await contactStore.loadContactList();
await groupStore.loadGroups();
}) })
</script> </script>
@@ -9,19 +9,19 @@
<div class="request-group"> <div class="request-group">
<div v-for="item in requests" :key="item.id" class="minimal-item"> <div v-for="item in requests" :key="item.id" class="minimal-item">
<img :src="item.avatar" class="avatar" /> <AsyncImage :raw-url="item.ownerId != authStore.userInfo.id ? item.ownerAvatar : item.targetAvatar" class="avatar" />
<div class="info"> <div class="info">
<div class="title-row"> <div class="title-row">
<span class="name">{{ item.nickName }}</span> <span class="name">{{ item.ownerId != authStore.userInfo.id ? (item.ownerNickName || item.ownerId) : (item.targetNickName || item.targetId) }}</span>
<span class="date">{{ formatDate(item.created) }}</span> <span class="date">{{ formatDate(item.creationTime) }}</span>
</div> </div>
<p class="sub-text">{{ item.description }}</p> <p class="sub-text">{{ item.description }}</p>
<p v-if="item.remark" class="remark-text">{{ item.remark }}</p> <p v-if="item.remarkName" class="remark-text">备注{{ item.remarkName }}</p>
</div> </div>
<div class="actions"> <div class="actions">
<template v-if="item.state === FRIEND_REQUEST_STATUS.Pending && item.requestUser != authStore.userInfo.id"> <template v-if="item.state === FRIEND_REQUEST_STATUS.Pending && item.ownerId != authStore.userInfo.id">
<button class="btn-text btn-reject" @click="confirmReject(item)">拒绝</button> <button class="btn-text btn-reject" @click="confirmReject(item)">拒绝</button>
<button class="btn-text btn-accept" @click="handleOpenDialog(item)">接受</button> <button class="btn-text btn-accept" @click="handleOpenDialog(item)">接受</button>
</template> </template>
@@ -29,12 +29,12 @@
待对方同意 待对方同意
</span> </span>
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Declined" class="status-label"> <span v-else-if="item.state === FRIEND_REQUEST_STATUS.Declined" class="status-label">
{{item.requestUser != authStore.userInfo.id ? '已拒绝' : '对方拒绝'}} {{item.ownerId != authStore.userInfo.id ? '已拒绝' : '对方拒绝'}}
</span> </span>
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Passed" class="status-label"> <span v-else-if="item.state === FRIEND_REQUEST_STATUS.Passed" class="status-label">
已添加 已添加
</span> </span>
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Blocked && item.requestUser != authStore.userInfo.id" class="status-label"> <span v-else-if="item.state === FRIEND_REQUEST_STATUS.Blocked && item.ownerId != authStore.userInfo.id" class="status-label">
已拉黑 已拉黑
</span> </span>
<span v-else class="status-label"> <span v-else class="status-label">
@@ -42,6 +42,11 @@
</span> </span>
</div> </div>
</div> </div>
<div v-if="requests.length === 0" class="empty-placeholder">
<i v-html="feather.icons['user-plus'].toSvg({ width: 40, height: 40 })"></i>
<p class="empty-text">暂无好友申请</p>
</div>
</div> </div>
</div> </div>
<div v-if="showDialog" class="modal-mask"> <div v-if="showDialog" class="modal-mask">
@@ -68,7 +73,9 @@ import { formatDate } from '@/utils/formatDate';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction'; import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus'; import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import feather from 'feather-icons';
import WindowControls from '../../components/WindowControls.vue'; import WindowControls from '../../components/WindowControls.vue';
import AsyncImage from '../../components/AsyncImage.vue';
const message = useMessage(); const message = useMessage();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -90,7 +97,8 @@ const activeItem = ref(null);
const handleOpenDialog = (item) => { const handleOpenDialog = (item) => {
activeItem.value = item; activeItem.value = item;
remarkName.value = item.nickName; // 默认备注为昵称 // 对方是请求发起人(ownerId),默认备注用对方的昵称
remarkName.value = item.ownerNickName || item.ownerId || '';
showDialog.value = true; showDialog.value = true;
}; };
@@ -168,6 +176,16 @@ onMounted(async () => {
padding-left: 4px; padding-left: 4px;
} }
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 20px;
color: #c1c1c6;
}
.empty-placeholder i { color: #d2d2d7; margin-bottom: 14px; line-height: 0; }
.empty-text { font-size: 14px; color: #86868b; margin: 0; }
/* 3. 列表项:去掉外框和投影,靠间距呼吸 */ /* 3. 列表项:去掉外框和投影,靠间距呼吸 */
.minimal-item { .minimal-item {
display: flex; display: flex;
@@ -178,11 +196,10 @@ onMounted(async () => {
} }
/* 4. 头像:小而圆润 */ /* 4. 头像:小而圆润 */
.avatar { :deep(.avatar) {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; /* 纯圆更简洁 */ border-radius: 50%;
background-color: #f5f5f7;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -0,0 +1,218 @@
<template>
<div id="GroupInfoContainer">
<WindowControls/>
<main class="profile-main">
<div v-if="groupInfo" class="profile-card">
<header class="profile-header">
<div class="text-info">
<h2 class="display-name">{{ groupInfo.name }}</h2>
<p class="sub-text">群ID{{ groupInfo.id }}</p>
<p class="sub-text">成员{{ members.length }} </p>
</div>
<AsyncImage :raw-url="groupInfo.avatar" class="big-avatar" />
</header>
<div class="profile-body">
<div class="info-block">
<span class="label">群公告</span>
<p class="announce">{{ groupInfo.announcement || '暂无群公告' }}</p>
</div>
<div class="info-block">
<span class="label">群成员</span>
<div class="member-grid">
<div v-for="m in members" :key="m.id" class="member-item">
<AsyncImage :raw-url="m.avatar" class="member-avatar" />
<span class="member-name">{{ m.groupNickName || '成员' }}</span>
<span v-if="m.role === GROUP_MEMBER_ROLE.MASTER" class="role-tag master">群主</span>
<span v-else-if="m.role === GROUP_MEMBER_ROLE.ADMIN" class="role-tag admin">管理</span>
</div>
</div>
</div>
</div>
<footer class="profile-footer">
<button class="btn-primary" @click="handleGoToChat">发消息</button>
</footer>
</div>
<div v-else class="loading-state">加载中...</div>
</main>
</div>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { groupService } from '@/services/group';
import { useConversationStore } from '@/stores/conversation';
import { useMessage } from '@/components/messages/useAlert';
import { GROUP_MEMBER_ROLE } from '@/constants/GroupDefine';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import WindowControls from '../../components/WindowControls.vue';
import AsyncImage from '../../components/AsyncImage.vue';
const props = defineProps({
id: { type: String, required: true }
})
const router = useRouter();
const conversationStore = useConversationStore();
const message = useMessage();
const groupInfo = ref(null)
const members = ref([])
const loadGroup = async (groupId) => {
groupInfo.value = null
members.value = []
try {
const infoRes = await groupService.getGroupInfo(groupId)
if (infoRes.code === SYSTEM_BASE_STATUS.SUCCESS) {
groupInfo.value = infoRes.data
} else {
message.error(infoRes.message || '获取群信息失败')
return
}
const memRes = await groupService.getGroupMember(groupId)
if (memRes.code === SYSTEM_BASE_STATUS.SUCCESS) {
members.value = memRes.data || []
}
} catch (e) {
console.error('加载群信息失败:', e)
message.error('加载群信息失败')
}
}
const handleGoToChat = () => {
// 会话的 targetId 即群 ID
const c = conversationStore.conversations.find(x => x.targetId == props.id)
if (c) {
router.push(`/messages/chat/${c.id}`)
} else {
message.info('暂无该群会话')
}
}
watch(() => props.id, (newId) => { if (newId) loadGroup(newId) })
onMounted(() => loadGroup(props.id))
</script>
<style scoped>
.profile-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
min-width: 0;
}
.profile-card {
width: 460px;
padding: 20px;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 24px;
border-bottom: 1px solid #e7e7e7;
margin-bottom: 24px;
}
.display-name {
font-size: 24px;
color: #000;
margin: 0 0 8px;
}
.sub-text {
font-size: 13px;
color: #888;
margin: 3px 0;
}
:deep(.big-avatar) { width: 70px; height: 70px; border-radius: 12px; }
.info-block {
margin-bottom: 28px;
}
.label {
display: block;
font-size: 13px;
color: #999;
margin-bottom: 10px;
}
.announce {
font-size: 14px;
color: #333;
line-height: 1.6;
margin: 0;
}
.member-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px 8px;
}
.member-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
:deep(.member-avatar) { width: 44px; height: 44px; border-radius: 8px; }
.member-name {
font-size: 11px;
color: #666;
margin-top: 4px;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.role-tag {
position: absolute;
top: -4px;
right: 4px;
font-size: 9px;
padding: 0 4px;
border-radius: 4px;
color: #fff;
line-height: 14px;
}
.role-tag.master { background: #ff9500; }
.role-tag.admin { background: #34c759; }
.profile-footer {
display: flex;
justify-content: center;
margin-top: 10px;
}
.btn-primary {
width: 160px;
padding: 10px;
background: #07c160;
color: #fff;
border: none;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
}
.btn-primary:hover { opacity: 0.85; }
.loading-state {
color: #bbb;
font-size: 14px;
}
</style>
@@ -6,226 +6,138 @@
<div class="section-title">群聊通知</div> <div class="section-title">群聊通知</div>
<div class="request-group"> <div class="request-group">
<div v-for="item in groupRequest" :key="item.requestId" class="minimal-item"> <div v-for="item in groupRequest" :key="item.id" class="minimal-item">
<div class="avatar-wrapper"> <div class="avatar-wrapper">
<img :src="avatarHandle(item)" :class="[ <img :src="item.groupAvatar" class="avatar is-group" />
'avatar',
item.type === GROUP_REQUEST_STATUS.IS_GROUP ||
item.type === GROUP_REQUEST_STATUS.IS_USER
? 'is-group'
: 'is-user'
]" />
</div> </div>
<div class="info"> <div class="info">
<div class="title-row"> <div class="title-row">
<span class="name">{{ item.name }}</span> <span class="name">{{ item.userNickName }}</span>
<span :class="[ <span class="type-tag tag-orange">
'type-tag', 申请入群
item.type === GROUP_REQUEST_TYPE.INVITE ||
item.type === GROUP_REQUEST_TYPE.IS_USER
? 'tag-orange'
: 'tag-green'
]">
{{ getTypeText(item.type) }}
</span> </span>
<span class="date">14:20</span> <span class="date">{{ formatDate(item.created) }}</span>
</div> </div>
<div class="group-info"> <div class="group-info">
<span class="label">目标群聊</span> <span class="label">目标群聊</span>
<span class="group-name">{{ item.groupName }}</span> <span class="group-name">{{ item.groupName }}</span>
</div> </div>
<div v-if="[GROUP_REQUEST_TYPE.INVITE, GROUP_REQUEST_TYPE.INVITED].includes(item.type)"
class="group-info">
<span class="label">目标用户</span>
<span class="group-name">{{
myInfo.id === item.userId ? item.inviteUserNickname : item.nickName
}}</span>
</div>
<p class="sub-text">入群描述{{ item.description }}</p> <p class="sub-text">入群描述{{ item.description }}</p>
</div> </div>
<div v-if="[GROUP_REQUEST_TYPE.INVITED, GROUP_REQUEST_TYPE.IS_GROUP].includes(item.type) && [GROUP_REQUEST_STATUS.PENDING, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(item.status)" class="actions"> <div v-if="item.state === GROUP_REQUEST_STATUS.PENDING" class="actions">
<button class="btn-text btn-reject">忽略</button> <button class="btn-text btn-reject" @click="groupRequestHandler(item, GROUP_REQUEST_ACTION.REJECT)">拒绝</button>
<Dropdown :disable="handleDropDownDisable" class="handleDropDown" v-model="handleValue" :options="[ <button class="btn-text btn-accept" @click="groupRequestHandler(item, GROUP_REQUEST_ACTION.ACCEPT)">同意</button>
{ label: '同意', value: GROUP_REQUEST_ACTION.ACCEPT },
{ label: '拒绝', value: GROUP_REQUEST_ACTION.REJECT }
]" placeholder="处理" @change="groupRequestHandler(item, handleValue)" />
</div> </div>
<div class="actions" v-else> <div class="actions" v-else>
<div class="status-tag" :class="getGroupRequestStatusClass(item.status)"> <div class="status-tag" :class="getGroupRequestStatusClass(item.state)">
<span>{{ getGroupRequestStatusTxt(item.status) }}</span> <span>{{ getGroupRequestStatusTxt(item.state) }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="footer-hint">仅保留最近 30 天的通知记录</div> <div v-if="groupRequest.length === 0" class="empty-placeholder">
<i v-html="feather.icons['users'].toSvg({ width: 40, height: 40 })"></i>
<p class="empty-text">暂无群聊通知</p>
</div>
</div>
<div v-if="groupRequest.length > 0" class="footer-hint">仅保留最近 30 天的通知记录</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, computed, ref, watch } from 'vue'; import { onMounted, computed } from 'vue';
import WindowControls from '../../components/WindowControls.vue'; import WindowControls from '../../components/WindowControls.vue';
import { useGroupRequestStore } from '../../stores/groupRequest'; import { useGroupRequestStore } from '../../stores/groupRequest';
import { useAuthStore } from '../../stores/auth';
import { GROUP_REQUEST_TYPE, getTypeText } from '../../constants/groupRequestTypeDefine';
import { GROUP_REQUEST_ACTION, GROUP_REQUEST_STATUS, getGroupRequestStatusTxt } from '../../constants/GroupDefine'; import { GROUP_REQUEST_ACTION, GROUP_REQUEST_STATUS, getGroupRequestStatusTxt } from '../../constants/GroupDefine';
import Dropdown from '../../components/Dropdown.vue';
import { groupService } from '../../services/group'; import { groupService } from '../../services/group';
import { useMessage } from '../../components/messages/useAlert'; import { useMessage } from '../../components/messages/useAlert';
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'; import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
import { formatDate } from '@/utils/formatDate';
import feather from 'feather-icons';
const groupRequestStore = useGroupRequestStore() const groupRequestStore = useGroupRequestStore()
const myInfo = useAuthStore().userInfo
const handleValue = ref(null)
const handleDropDownDisable = ref(false)
const message = useMessage() const message = useMessage()
const groupRequest = computed(() => { const groupRequest = computed(() => {
if (!groupRequestStore.groupRequest) { if (!groupRequestStore.groupRequest) return [];
return []; return groupRequestStore.groupRequest;
}
return groupRequestStore.groupRequest.map((item) => {
return { ...item, type: getRequestType(item) }
})
}) })
const getGroupRequestStatusClass = (status) => { const getGroupRequestStatusClass = (state) => {
const classMap = { const classMap = {
[GROUP_REQUEST_STATUS.PENDING]: 'status-pending', [GROUP_REQUEST_STATUS.PENDING]: 'status-pending',
[GROUP_REQUEST_STATUS.TARGET_PENDING]: 'status-pending',
[GROUP_REQUEST_STATUS.PASSED]: 'status-passed', [GROUP_REQUEST_STATUS.PASSED]: 'status-passed',
[GROUP_REQUEST_STATUS.DECLINED]: 'status-declined', [GROUP_REQUEST_STATUS.DECLINED]: 'status-declined',
[GROUP_REQUEST_STATUS.TARGET_DECLINED]: 'status-declined',
}; };
return classMap[status] || ''; return classMap[state] || '';
}; };
const avatarHandle = (request) => { // 入群请求处理
switch (request.type) {
case GROUP_REQUEST_STATUS.IS_GROUP:
case GROUP_REQUEST_STATUS.IS_USER:
return request.groupAvatar;
case GROUP_REQUEST_STATUS.INVITE:
return request.userAvatar;
case GROUP_REQUEST_STATUS.INVITED:
return request.inviteUserAvatar;
default:
return request.groupAvatar;
}
}
const getRequestType = (request) => {
if (
request.inviteUser &&
request.inviteUser == myInfo.id &&
[GROUP_REQUEST_STATUS.TARGET_DECLINED, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(
request.status
)
) {
return GROUP_REQUEST_TYPE.INVITE;
}
if (
request.inviteUser &&
request.userId == myInfo.id &&
[GROUP_REQUEST_STATUS.TARGET_DECLINED, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(
request.status
)
) {
return GROUP_REQUEST_TYPE.INVITED;
}
if (request.userId == myInfo.id) {
return GROUP_REQUEST_TYPE.IS_USER;
}
if (request.inviteUser != myInfo.id && request.userId != myInfo.id) {
return GROUP_REQUEST_TYPE.IS_GROUP;
}
}
//入群请求处理
const groupRequestHandler = async (request, action) => { const groupRequestHandler = async (request, action) => {
if (!request || !request.type) return if (!request || !request.id) return;
let requestAction = GROUP_REQUEST_STATUS.PASSED const result = await groupService.handleGroupRequest(request.id, action);
let result = null if (result.code == SYSTEM_BASE_STATUS.SUCCESS) {
switch (request.type) { message.success('操作成功');
case GROUP_REQUEST_TYPE.INVITED: // 重新加载列表
requestAction = action == GROUP_REQUEST_ACTION.ACCEPT ? GROUP_REQUEST_STATUS.TARGET_PASSED : GROUP_REQUEST_STATUS.TARGET_DECLINED; await groupRequestStore.loadGroupRequest();
result = await groupService.handleGroupInvite(request.requestId ,requestAction) } else {
break message.error(result.message);
case GROUP_REQUEST_TYPE.IS_GROUP:
requestAction = action == GROUP_REQUEST_ACTION.ACCEPT ? GROUP_REQUEST_STATUS.PASSED : GROUP_REQUEST_STATUS.DECLINED;
result = await groupService.handleGroupRequest(request.requestId, requestAction)
break
default:
return
} }
if(result.code == SYSTEM_BASE_STATUS.SUCCESS){
message.success('操作成功')
}else{
message.error(result.message)
}
} }
onMounted(async () => { onMounted(async () => {
await groupRequestStore.loadGroupRequest() await groupRequestStore.loadGroupRequest();
console.log(groupRequestStore.groupRequest)
}) })
</script> </script>
<style scoped> <style scoped>
/* 容器基础环境 */
.minimal-page { .minimal-page {
width: 100%; width: 100%;
height: 100vh; height: 100%;
background-color: #f5f5f7; background-color: #f5f5f5;
/* Apple 官网经典的背景灰 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.request-container { .request-container {
width: 100%;
height: 100%;
background-color: #f5f5f5;
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 40px; overflow-y: auto;
position: relative;
} }
.content-limit { .content-limit {
width: 100%; width: 100%;
max-width: 600px; max-width: 640px;
padding: 0 20px; padding: 60px 24px;
} }
.section-title { .section-title {
font-size: 12px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #86868b; color: #86868b;
letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 32px;
margin-bottom: 24px; padding-left: 4px;
padding-left: 10px;
} }
/* 列表项设计 */
.minimal-item { .minimal-item {
display: flex; display: flex;
background: #ffffff; background: #ffffff;
padding: 20px; padding: 20px;
border-radius: 18px; border-radius: 18px;
/* 较圆润的倒角 */
margin-bottom: 12px; margin-bottom: 12px;
transition: transform 0.2s ease; transition: transform 0.2s ease;
border: 1px solid rgba(0, 0, 0, 0.02); border: 1px solid rgba(0, 0, 0, 0.02);
@@ -236,7 +148,6 @@ onMounted(async () => {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
} }
/* 头像差异化处理 */
.avatar-wrapper { .avatar-wrapper {
margin-right: 16px; margin-right: 16px;
} }
@@ -248,18 +159,10 @@ onMounted(async () => {
background: #f2f2f7; background: #f2f2f7;
} }
.is-user {
border-radius: 50%;
}
/* 用户是圆的 */
.is-group { .is-group {
border-radius: 12px; border-radius: 12px;
} }
/* 群组是方圆的 */
/* 信息排版 */
.info { .info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -278,7 +181,6 @@ onMounted(async () => {
margin-right: 8px; margin-right: 8px;
} }
/* 状态标签 */
.type-tag { .type-tag {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -291,18 +193,12 @@ onMounted(async () => {
color: #ff9500; color: #ff9500;
} }
.tag-green {
background: #e8f7ed;
color: #34c759;
}
.date { .date {
font-size: 11px; font-size: 11px;
color: #c1c1c6; color: #c1c1c6;
margin-left: auto; margin-left: auto;
} }
/* 关键信息:群聊名称展示 */
.group-info { .group-info {
margin-bottom: 6px; margin-bottom: 6px;
} }
@@ -316,7 +212,6 @@ onMounted(async () => {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: #007aff; color: #007aff;
/* 链接蓝,暗示可点击 */
} }
.sub-text { .sub-text {
@@ -324,18 +219,15 @@ onMounted(async () => {
color: #86868b; color: #86868b;
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
/* 文字截断 */
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
/* 按钮样式:去掉了生硬的边框,采用色块感 */
.actions { .actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 垂直排列,更具操作仪式感 */
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
margin-left: 20px; margin-left: 20px;
@@ -378,7 +270,16 @@ onMounted(async () => {
margin-top: 32px; margin-top: 32px;
} }
/* 基础容器 */ .empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 20px;
color: #c1c1c6;
}
.empty-placeholder i { color: #d2d2d7; margin-bottom: 14px; line-height: 0; }
.empty-text { font-size: 14px; color: #86868b; margin: 0; }
.status-tag { .status-tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -390,32 +291,21 @@ onMounted(async () => {
width: fit-content; width: fit-content;
} }
/* 待处理:蓝色或橙色 */
.status-pending { .status-pending {
background-color: #fff7e6; background-color: #fff7e6;
color: #fa8c16; color: #fa8c16;
border: 1px solid #ffd591; border: 1px solid #ffd591;
} }
/* 已通过:绿色 */
.status-passed { .status-passed {
background-color: #f6ffed; background-color: #f6ffed;
color: #52c41a; color: #52c41a;
border: 1px solid #b7eb8f; border: 1px solid #b7eb8f;
} }
/* 已拒绝 / 失败:灰色或红色 */
.status-declined { .status-declined {
background-color: #fff1f0; background-color: #fff1f0;
color: #f5222d; color: #f5222d;
border: 1px solid #ffa39e; border: 1px solid #ffa39e;
} }
/* 默认 / 禁用状态 */
.action-disabled {
opacity: 0.8;
cursor: not-allowed;
}
.handleDropDown {}
</style> </style>
@@ -7,21 +7,26 @@
<header class="profile-header"> <header class="profile-header">
<div class="text-info"> <div class="text-info">
<h2 class="display-name"> <h2 class="display-name">
{{ currentContact.remarkName }} {{ currentContact.remarkName || currentContact.nickName }}
<span :class="['gender-tag', 'm']"> <span :class="['gender-tag', 'm']">{{ '♂' }}</span>
{{ '♂' }}
</span>
</h2> </h2>
<p class="sub-text">账号{{ currentContact.userInfo.username }}</p> <p class="sub-text">账号{{ currentContact.targetId }}</p>
<p class="sub-text">地区{{ '未' }}</p> <p class="sub-text">备注{{ editableRemark || '未设置' }}</p>
<p class="sub-text">地区{{ currentContact.region || '未知' }}</p>
</div> </div>
<AsyncImage :raw-url="currentContact.userInfo.avatar" class="big-avatar" /> <AsyncImage :raw-url="currentContact.avatar" class="big-avatar" />
</header> </header>
<div class="profile-body"> <div class="profile-body">
<div class="info-row"> <div class="info-row">
<span class="label">昵称</span> <span class="label">昵称</span>
<span class="value">{{ currentContact.userInfo.nickName }}</span> <span class="value">{{ currentContact.nickName }}</span>
</div>
<div class="info-row" v-if="showRemarkEdit">
<span class="label">修改备注</span>
<input v-model="remarkInput" class="remark-input" placeholder="输入新备注" @keyup.enter="saveRemark" />
<button class="mini-btn" @click="saveRemark">保存</button>
<button class="mini-btn-cancel" @click="showRemarkEdit = false">取消</button>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">个性签名</span> <span class="label">个性签名</span>
@@ -35,55 +40,96 @@
<footer class="profile-footer"> <footer class="profile-footer">
<button class="btn-primary" @click="handleGoToChat">发消息</button> <button class="btn-primary" @click="handleGoToChat">发消息</button>
<button class="btn-ghost">音视频通话</button> <button class="btn-ghost" @click="showRemarkEdit = !showRemarkEdit">修改备注</button>
<button class="btn-ghost" @click="handleDeleteFriend">删除好友</button>
<button class="btn-ghost-danger" @click="handleBlockFriend">拉黑</button>
</footer> </footer>
</div> </div>
</main> </main>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineProps, onMounted, ref } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contact'; import { useContactStore } from '@/stores/contact';
import { onBeforeRouteUpdate, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useConversationStore } from '@/stores/conversation'; import { useConversationStore } from '@/stores/conversation';
import { friendService } from '@/services/friend';
import { useMessage } from '@/components/messages/useAlert';
import WindowControls from '../../components/WindowControls.vue'; import WindowControls from '../../components/WindowControls.vue';
import AsyncImage from '../../components/AsyncImage.vue'; import AsyncImage from '../../components/AsyncImage.vue';
const contactStore = useContactStore(); const contactStore = useContactStore();
const router = useRouter(); const router = useRouter();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const message = useMessage();
const props = defineProps({ const props = defineProps({
id: { id: { type: String, required: true }
type: String,
required: true
}
}) })
const currentContact = ref(null) const currentContact = ref(null)
const showRemarkEdit = ref(false)
const remarkInput = ref('')
const editableRemark = ref('')
function handleGoToChat() { const saveRemark = () => {
if (currentContact.value) { if (currentContact.value) {
const cid = conversationStore.conversations.find(x => x.targetId == currentContact.value.userInfo.id).id; currentContact.value.remarkName = remarkInput.value
router.push(`/messages/chat/${cid}`); editableRemark.value = remarkInput.value
showRemarkEdit.value = false
message.success('备注已更新')
} }
} }
onBeforeRouteUpdate((to, from) => { const handleGoToChat = () => {
currentContact.value = contactStore.contacts.find(x => x.id == to.params.id); if (currentContact.value) {
const c = conversationStore.conversations.find(x => x.targetId == currentContact.value.targetId)
if (c) router.push(`/messages/chat/${c.id}`)
}
}
const handleDeleteFriend = async () => {
if (!confirm('确定要删除该好友吗?')) return
const res = await friendService.deleteFriend(currentContact.value.id)
if (res.code === 0) {
message.success('好友已删除')
router.push('/contacts/index')
} else {
message.error(res.message || '删除失败')
}
}
const handleBlockFriend = async () => {
if (!confirm('确定要拉黑该好友吗?')) return
const res = await friendService.blockFriend(currentContact.value.id)
if (res.code === 0) {
message.success('已拉黑')
router.push('/contacts/index')
} else {
message.error(res.message || '拉黑失败')
}
}
watch(() => props.id, (newId) => {
currentContact.value = contactStore.contacts.find(x => x.id == newId)
if (currentContact.value) {
editableRemark.value = currentContact.value.remarkName || ''
remarkInput.value = currentContact.value.remarkName || ''
}
}) })
onMounted(() => { onMounted(() => {
currentContact.value = contactStore.contacts.find(x => x.id == props.id); currentContact.value = contactStore.contacts.find(x => x.id == props.id)
if (currentContact.value) {
editableRemark.value = currentContact.value.remarkName || ''
remarkInput.value = currentContact.value.remarkName || ''
}
}) })
</script> </script>
<style scoped> <style scoped>
/* --- 右侧名片区 --- */
.profile-main { .profile-main {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -142,6 +188,7 @@ onMounted(() => {
display: flex; display: flex;
margin-bottom: 15px; margin-bottom: 15px;
font-size: 14px; font-size: 14px;
align-items: center;
} }
.info-row .label { .info-row .label {
@@ -154,6 +201,34 @@ onMounted(() => {
flex: 1; flex: 1;
} }
.remark-input {
flex: 1;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.mini-btn {
margin-left: 4px;
padding: 4px 10px;
background: #007aff;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.mini-btn-cancel {
margin-left: 4px;
padding: 4px 10px;
background: #f0f0f0;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.profile-footer { .profile-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -182,10 +257,19 @@ onMounted(() => {
cursor: pointer; cursor: pointer;
} }
.btn-primary:hover, .btn-ghost:hover { .btn-ghost-danger {
opacity: 0.8; width: 160px;
padding: 10px;
background: #fff;
border: 1px solid #ff4d4f;
color: #ff4d4f;
border-radius: 4px;
cursor: pointer;
} }
.btn-primary:hover, .btn-ghost:hover { opacity: 0.8; }
.btn-ghost-danger:hover { background: #fff1f0; }
.empty-state { .empty-state {
text-align: center; text-align: center;
color: #ccc; color: #ccc;
@@ -196,7 +280,7 @@ onMounted(() => {
margin-bottom: 10px; margin-bottom: 10px;
opacity: 0.2; opacity: 0.2;
} }
/* 4. 定义组件进场和退场的动画 */
.fade-scale-enter-active, .fade-scale-enter-active,
.fade-scale-leave-active { .fade-scale-leave-active {
transition: all 0.3s ease; transition: all 0.3s ease;
@@ -10,9 +10,10 @@
<AddMenu :menu-list="addMenuList" @action-active="actionHandler"/> <AddMenu :menu-list="addMenuList" @action-active="actionHandler"/>
</div> </div>
</div> </div>
<div v-if="msgTitleShow" class="showMsg" @click="requestNotificationPermission"> <div v-if="statusBanner" class="status-bar" :class="{ 'is-notify': statusBanner.type === 'notify' }" @click="statusBanner.type === 'notify' && requestNotificationPermission()">
<i style="color: red;line-height:0;" v-html="feather.icons['alert-circle'].toSvg({width:14})"></i> <i v-html="feather.icons[statusBanner.type === 'notify' ? 'bell' : 'wifi-off'].toSvg({ width: 13, height: 13 })"></i>
<span>新消息无法通知点我授予通知权限</span> <span>{{ statusBanner.text }}</span>
<button v-if="statusBanner.closable" class="bar-close" @click.stop="statusBanner.type === 'net' ? (networkError = null) : null"></button>
</div> </div>
<div class="scroll-area"> <div class="scroll-area">
@@ -26,11 +27,18 @@
<div class="info"> <div class="info">
<div class="name-row"> <div class="name-row">
<span class="name">{{ s.targetName ?? '未知用户' }}</span> <span class="name">{{ s.targetName ?? '未知用户' }}</span>
<span class="time">{{ formatDate(s.dateTime) ?? '1970/1/1 00:00:00' }}</span> <span class="time">{{ formatDate(s.dateTime) || '-' }}</span>
</div> </div>
<div class="last-msg">{{ lastMessageHandler(s.lastMessage) ?? '获取消息内容失败' }}</div> <div class="last-msg">{{ lastMessageHandler(s.lastMessage) ?? '获取消息内容失败' }}</div>
</div> </div>
</div> </div>
<!-- 空状态占位 -->
<div v-if="filteredSessions.length === 0" class="empty-placeholder">
<i v-html="feather.icons[searchQuery ? 'search' : 'message-circle'].toSvg({ width: 40, height: 40 })"></i>
<p class="empty-text">{{ searchQuery ? '没有匹配的会话' : '暂无消息' }}</p>
<p class="empty-sub" v-if="!searchQuery">点击右上角 + 发起聊天</p>
</div>
</div> </div>
</aside> </aside>
@@ -43,7 +51,6 @@
<script setup> <script setup>
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import defaultAvatar from '@/assets/default_avatar.png'
import { formatDate } from '@/utils/formatDate' import { formatDate } from '@/utils/formatDate'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import AddMenu from '@/components/addMenu.vue' import AddMenu from '@/components/addMenu.vue'
@@ -55,20 +62,26 @@ import { useChatStore } from '@/stores/chat'
import { groupService } from '../../services/group' import { groupService } from '../../services/group'
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus' import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
import { useMessage } from '../../components/messages/useAlert' import { useMessage } from '../../components/messages/useAlert'
import { useCacheStore } from '../../stores/cache'
import AsyncImage from '../../components/AsyncImage.vue' import AsyncImage from '../../components/AsyncImage.vue'
import { networkError } from '../../services/api'
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const router = useRouter(); const router = useRouter();
const browserNotification = useBrowserNotification(); const browserNotification = useBrowserNotification();
const message = useMessage() const message = useMessage()
const cacheStore = useCacheStore()
const searchQuery = ref('') const searchQuery = ref('')
const activeId = ref(0) const activeId = ref(0)
const searchUserModal = ref(false); const searchUserModal = ref(false);
const createGroupModal = ref(false); const createGroupModal = ref(false);
const msgTitleShow = ref(false); const msgTitleShow = ref(false);
// 统一状态横幅:通知权限 > 网络错误
const statusBanner = computed(() => {
if (msgTitleShow.value) return { type: 'notify', text: '点击授予通知权限', closable: false }
if (networkError.value) return { type: 'net', text: networkError.value, closable: true }
return null
})
const addMenuList = [ const addMenuList = [
{ {
text: '发起群聊', text: '发起群聊',
@@ -92,21 +105,18 @@ const addMenuList = [
const createGroupSubmitHandler = async (selectedUsers, groupName) => { const createGroupSubmitHandler = async (selectedUsers, groupName) => {
const res = await groupService.createGroup({ const res = await groupService.createGroup({ name: groupName });
name: groupName, if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
avatar: "http://192.168.5.116:7070/uploads/files/IM/2026/03/2/bf1a0f691220.jpg", message.success('群聊创建成功');
userIDs: [...selectedUsers] const groupId = res.data?.id;
}) // 循环邀请群成员
const userIds = [...selectedUsers];
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){ for (const userId of userIds) {
message.success('群聊创建成功') await groupService.inviteUser(groupId, userId);
}else{ }
message.error(res.message) } else {
} message.error(res.message);
} }
const urlHandler = async () => {
} }
const filteredSessions = computed(() => conversationStore.sortedConversations.filter(s => s.targetName.includes(searchQuery.value))) const filteredSessions = computed(() => conversationStore.sortedConversations.filter(s => s.targetName.includes(searchQuery.value)))
@@ -123,20 +133,25 @@ function actionHandler(type){
break; break;
case 'createGroup': case 'createGroup':
createGroupModal.value = true; createGroupModal.value = true;
break;
default: default:
break; break;
} }
} }
function lastMessageHandler(text){ function lastMessageHandler(text){
if (!text) return '';
try{ try{
const data = JSON.parse(text); // 新 API content 可能是字符串或结构化对象 { fallback, body: { text, ... }, ... }
if(data.text){ if (typeof text === 'object') {
return data.text; return text.body?.text || text.fallback || '';
}else{
return text
} }
}catch(e){ const data = JSON.parse(text);
if (typeof data === 'object' && data !== null) {
return data.body?.text || data.fallback || data.text || text;
}
return text;
}catch{
return text; return text;
} }
} }
@@ -183,20 +198,35 @@ onMounted(async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.showMsg { .status-bar {
/* width: 10px; */ height: 24px;
height: 20px;
background: #e3f98d;
font-size: 12px;
display: flex; display: flex;
/* text-align: center; */
flex-wrap: nowrap;
align-content: center;
justify-content: center;
align-items: center; align-items: center;
color: red; justify-content: center;
gap: 6px;
font-size: 12px;
flex-shrink: 0;
color: #856404;
background: #fff9e6;
cursor: default;
}
.status-bar.is-notify {
color: #d46b08;
background: #fff7e6;
cursor: pointer; cursor: pointer;
} }
.status-bar i { line-height: 0; }
.bar-close {
background: none;
border: none;
color: inherit;
opacity: 0.5;
cursor: pointer;
font-size: 14px;
padding: 0 8px;
margin-left: auto;
}
.bar-close:hover { opacity: 1; }
/* 修复:搜索框美化 */ /* 修复:搜索框美化 */
@@ -228,6 +258,20 @@ onMounted(async () => {
.scroll-area { flex: 1; overflow-y: auto; } .scroll-area { flex: 1; overflow-y: auto; }
/* 空状态占位 */
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #bbb;
text-align: center;
}
.empty-placeholder i { color: #ccc; margin-bottom: 12px; line-height: 0; }
.empty-text { font-size: 14px; color: #999; margin: 0; }
.empty-sub { font-size: 12px; color: #bbb; margin: 6px 0 0; }
/* 3. 聊天主面板修复 */ /* 3. 聊天主面板修复 */
.chat-panel { .chat-panel {
flex: 1; flex: 1;
@@ -299,7 +343,7 @@ onMounted(async () => {
} }
/* 头像样式统一 */ /* 头像样式统一 */
:deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; } :deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0; }
.avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; } .avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
/* 未读气泡 */ /* 未读气泡 */
@@ -23,27 +23,27 @@
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']"> <div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
<!-- <img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard" <!-- <img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard"
:src="(m.senderId == myInfo.id ? myInfo?.avatar : m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar)" :src="(m.senderId == myInfo.id ? myInfo?.avatar : m.chatType == CHAT_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar)"
class="avatar-chat" /> --> class="avatar-chat" /> -->
<AsyncImage :raw-url="m.senderId == myInfo.id <AsyncImage :raw-url="m.senderId == myInfo.id
? myInfo?.avatar ? myInfo?.avatar
: m.chatType == MESSAGE_TYPE.GROUP : m.chatType == CHAT_TYPE.GROUP
? m.senderAvatar ? (getSenderAvatar(m.senderId) || m.senderAvatar)
: conversationInfo?.targetAvatar : conversationInfo?.targetAvatar
" @mouseenter="(e) => handleHoverCard(e, m)" class="avatar-chat" @mouseleave="closeHoverCard" /> " @click.stop="(e) => handleHoverCard(e, m)" class="avatar-chat" />
<div class="msg-content"> <div class="msg-content">
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{ <div class="group-sendername" v-if="m.chatType == CHAT_TYPE.GROUP && m.senderId != myInfo.id">{{
m.senderName }}</div> m.senderName }}</div>
<div :class="['bubble', m.type == 'Text' ? 'text-bubble' : '']" <div :class="['bubble', m.msgType == MSG_TYPE.Text ? 'text-bubble' : '']"
@contextmenu.prevent="(e) => handleRightClick(e, m)"> @contextmenu.prevent="(e) => handleRightClick(e, m)">
<div v-if="m.type === 'Text'">{{ m.content }}</div> <div v-if="m.isWithdrawn" class="withdrawn-msg"><i>消息已撤回</i></div>
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div> <div v-else-if="m.msgType == MSG_TYPE.Text">
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)"> {{ typeof m.content === 'object' ? (m.content?.body?.text || m.content?.fallback) : m.content }}
<!-- <img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb" </div>
alt="图片消息" @click="imagePreview(m)"> --> <div v-else-if="m.msgType === MSG_TYPE.Image" class="image-msg-container" :style="getImageStyle(m)">
<AsyncImage class="image-msg-content" :noAvatar="true" <AsyncImage class="image-msg-content" :noAvatar="true"
:rawUrl="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb" alt="图片消息" :rawUrl="m.isImgLoading || m.isLoading ? m.localUrl : (m.url || m.thumb || m.localUrl)" alt="图片消息"
@click="imagePreview($event, m)" /> @click="imagePreview($event, m)" />
<div v-if="m.isImgLoading || m.isError" class="image-overlay"> <div v-if="m.isImgLoading || m.isError" class="image-overlay">
@@ -58,18 +58,29 @@
</div> </div>
</div> </div>
<i v-if="m.isError" class="error-icon" <i v-if="m.isError" class="error-icon clickable" title="点击重发"
@click.stop="handleRetry(m)"
v-html="feather.icons['alert-circle'].toSvg({ width: 24, height: 24 })"></i> v-html="feather.icons['alert-circle'].toSvg({ width: 24, height: 24 })"></i>
</div> </div>
</div> </div>
<VideoMsg v-else-if="m.type === FILE_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.content.thumb" <VideoMsg v-else-if="m.msgType === MSG_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.thumb"
:duration="m.content.duration" :w="m.content.w" :h="m.content.h" :uploading="m.isImgLoading" :duration="m.duration" :w="m.width" :h="m.height" :uploading="m.isImgLoading"
:progress="+m.progress" @play="playHandler(m)" /> :progress="+m.progress" @play="playHandler(m)" />
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice" :url="m.localUrl ?? m.content.url" <VoiceMsg v-else-if="m.msgType === MSG_TYPE.Voice" :url="m.localUrl ?? m.url"
:duration="m.content.duration" :isRead="true" :isSelf="m.senderId == myInfo.id" /> :duration="m.duration" :isRead="true" :isSelf="m.senderId == myInfo.id" />
<button v-else-if="m.msgType === MSG_TYPE.File" class="file-message" type="button"
@click.stop="downloadFileMessage(m)">
<i v-html="feather.icons['file-text'].toSvg({ width: 30, height: 30 })"></i>
<span class="file-meta">
<strong>{{ m.fileName || m.content?.body?.fileName || '文件' }}</strong>
<small>{{ formatFileSize(m.fileSize ?? m.content?.body?.size) }}</small>
</span>
<i v-html="feather.icons['download'].toSvg({ width: 18, height: 18 })"></i>
</button>
<div class="status" v-if="m.senderId == myInfo.id"> <div class="status" v-if="m.senderId == myInfo.id">
<i v-if="m.isError" style="color: red;" <i v-if="m.isError" class="error-icon clickable" title="点击重发"
v-html="feather.icons['alert-circle'].toSvg({ width: 18, height: 18 })"></i> @click.stop="handleRetry(m)"
v-html="feather.icons['alert-circle'].toSvg({ width: 16, height: 16 })"></i>
<i v-if="m.isLoading" class="loaderIcon" <i v-if="m.isLoading" class="loaderIcon"
v-html="feather.icons['loader'].toSvg({ width: 18, height: 18 })"></i> v-html="feather.icons['loader'].toSvg({ width: 18, height: 18 })"></i>
</div> </div>
@@ -81,14 +92,42 @@
</div> </div>
<footer class="chat-footer"> <footer class="chat-footer">
<!-- 引用预览条 -->
<div v-if="quoteMessage" class="quote-bar">
<span class="quote-label">回复 {{ quoteMessage.senderName }}</span>
<span class="quote-text">{{ typeof quoteMessage.content === 'object' ? (quoteMessage.content?.body?.text || quoteMessage.content?.fallback) : String(quoteMessage.content || '').slice(0, 80) }}</span>
<button class="quote-close" @click="cancelQuote"></button>
</div>
<!-- emoji 面板 -->
<div v-if="showEmoji" class="emoji-panel">
<button v-for="e in EMOJIS" :key="e" class="emoji-btn" @click="insertEmoji(e)">{{ e }}</button>
</div>
<!-- 搜索面板 -->
<div v-if="searchOpen" class="search-panel">
<input v-model="searchKeyword" class="search-input" placeholder="搜索聊天记录..." @keyup.enter="doSearch" />
<div class="search-results">
<div v-for="r in searchResults" :key="r.id || r.sequenceId" class="search-item" @click="scrollToMsg(r)">
<span class="search-time">{{ formatDate(r.creationTime || r.timeStamp) }}</span>
<span class="search-text">{{ typeof r.content === 'object' ? (r.content?.body?.text || r.content?.fallback || '') : String(r.content || '').slice(0, 100) }}</span>
</div>
<div v-if="searchResults.length === 0 && searchKeyword" class="search-empty">无匹配消息</div>
</div>
<button class="search-close" @click="toggleSearch">关闭</button>
</div>
<div class="toolbar"> <div class="toolbar">
<button class="tool-btn" @click="toggleEmoji" <button class="tool-btn" @click="showEmoji = !showEmoji"
v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })"> v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })">
</button> </button>
<label class="tool-btn"> <label class="tool-btn">
<i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i> <i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i>
<input type="file" hidden @change="handleFile($event.target.files)" /> <input type="file" hidden @change="handleFile($event.target.files)" />
</label> </label>
<button class="tool-btn" @click="toggleSearch"
v-html="feather.icons['search'].toSvg({ width: 25, height: 25 })">
</button>
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord" <button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord"
v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({ width: 25, height: 25 })"> v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({ width: 25, height: 25 })">
</button> </button>
@@ -99,7 +138,7 @@
</div> </div>
</footer> </footer>
<InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null" <InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null"
:groupData="conversationInfo" /> :groupData="conversationInfo" @close="handleConversationClosed" />
</div> </div>
</section> </section>
</template> </template>
@@ -107,21 +146,20 @@
<script setup> <script setup>
import { ref, nextTick, onMounted, watch, onUnmounted, reactive } from 'vue'; import { ref, nextTick, onMounted, watch, onUnmounted, reactive } from 'vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import defaultAvatar from '@/assets/default_avatar.png';
import { formatDate } from '@/utils/formatDate'; import { formatDate } from '@/utils/formatDate';
import { debounce } from '@/utils/debounce';
import { useChatStore } from '@/stores/chat'; import { useChatStore } from '@/stores/chat';
import { generateSessionId } from '@/utils/sessionIdTools'; import { generateSessionId } from '@/utils/sessionIdTools';
import { useSignalRStore } from '@/stores/signalr';
import { useConversationStore } from '@/stores/conversation'; import { useConversationStore } from '@/stores/conversation';
import feather from 'feather-icons'; import feather from 'feather-icons';
import { MESSAGE_TYPE } from '@/constants/MessageType'; import { CHAT_TYPE, MSG_TYPE } from '@/constants/MessageType';
import HistoryLoading from '@/components/messages/HistoryLoading.vue'; import HistoryLoading from '@/components/messages/HistoryLoading.vue';
import UserHoverCard from '@/components/user/UserHoverCard.vue'; import UserHoverCard from '@/components/user/UserHoverCard.vue';
import ContextMenu from '@/components/ContextMenu.vue'; import ContextMenu from '@/components/ContextMenu.vue';
import { useSendMessageHandler } from './hooks/useSendMessageHandler'; import { useSendMessageHandler } from './hooks/useSendMessageHandler';
import { previewImages } from 'hevue-img-preview/v3' import { previewImages } from 'hevue-img-preview/v3'
import { useMessage } from '@/components/messages/useAlert'; import { useMessage } from '@/components/messages/useAlert';
import { FILE_TYPE, getMessageType } from '@/constants/fileTypeDefine'; import { getMessageType } from '@/constants/fileTypeDefine';
import { generateImageThumbnailBlob, getVideoDuration, getVideoThumbnailBlob, loadImage } from '@/utils/imageTools'; import { generateImageThumbnailBlob, getVideoDuration, getVideoThumbnailBlob, loadImage } from '@/utils/imageTools';
import { ImageInfo, VideoInfo, VoiceInfo } from '@/constants/fileTypeInfo'; import { ImageInfo, VideoInfo, VoiceInfo } from '@/constants/fileTypeInfo';
import VideoMsg from '@/components/messages/VideoMsg.vue'; import VideoMsg from '@/components/messages/VideoMsg.vue';
@@ -129,10 +167,13 @@ import VoiceMsg from '@/components/messages/VoiceMsg.vue';
import WindowControls from '../../../components/WindowControls.vue'; import WindowControls from '../../../components/WindowControls.vue';
import InfoSidebar from '../../../components/messages/InfoSidebar.vue'; import InfoSidebar from '../../../components/messages/InfoSidebar.vue';
import { isElectron } from '../../../utils/electronHelper'; import { isElectron } from '../../../utils/electronHelper';
import { groupService } from '../../../services/group';
import VideoPreview from '../../../components/electron/VideoPreview.vue'; import VideoPreview from '../../../components/electron/VideoPreview.vue';
import { useRightClickHandler } from './hooks/useRightClickHandler'; import { useRightClickHandler } from './hooks/useRightClickHandler';
import AsyncImage from '../../../components/AsyncImage.vue'; import AsyncImage from '../../../components/AsyncImage.vue';
import { messageService } from '@/services/message';
import { userService } from '@/services/userService';
import { uploadService } from '@/services/upload/uploadService';
import { useRouter } from 'vue-router';
const props = defineProps({ const props = defineProps({
@@ -145,13 +186,13 @@ const props = defineProps({
const infoSideBarShow = ref(false); const infoSideBarShow = ref(false);
const chatStore = useChatStore(); const chatStore = useChatStore();
const signalRStore = useSignalRStore();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const message = useMessage(); const message = useMessage();
const { sendMessage, sendFileMessage, sendTextMessage } = useSendMessageHandler(); const router = useRouter();
const { sendFileMessage, sendTextMessage, retryMessage } = useSendMessageHandler();
const input = ref(''); // 输入框内容 const input = ref('');
const historyRef = ref(null); // 绑定 DOM 用于滚动 const historyRef = ref(null);
const loadingRef = ref(null) const loadingRef = ref(null)
const userHoverCardRef = ref(null); const userHoverCardRef = ref(null);
const menuRef = ref(null); const menuRef = ref(null);
@@ -160,18 +201,85 @@ const myInfo = useAuthStore().userInfo;
const conversationInfo = ref(null) const conversationInfo = ref(null)
let groupData = reactive({}); let groupData = reactive({});
// --- 消息加载 & 录音状态 ---
// --- 消息数据 ---
const messages = ref([]);
const isLoading = ref(false); const isLoading = ref(false);
const isFinished = ref(false); const isFinished = ref(false);
const hasError = ref(false); const hasError = ref(false);
let observer = null; let observer = null;
const isRecord = ref(false); const isRecord = ref(false);
let mediaRecorder = null; let mediaRecorder = null;
let audioChunks = []; // 用于存储录音的数据片段 let audioChunks = [];
const videoUrl = ref(null); const videoUrl = ref(null);
const videoOpen = ref(false) const videoOpen = ref(false);
const resolvedObjectUrls = new Set();
const resolvingFileIds = new Set();
// --- emoji / quote / search ---
const showEmoji = ref(false);
const quoteMessage = ref(null);
const searchOpen = ref(false);
const searchKeyword = ref('');
const searchResults = ref([]);
const searchLoading = ref(false);
// common emoji list
const EMOJIS = ['😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎','😍','😘','🥰','😗','😙','😚','🙂','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😯','😪','😫','😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','☹️','🙁','😖','😞','😟','😤','😢','😭','😦','😧','😨','😩','🤯','😬','😰','😱','🥵','🥶','😳','🤪','😵','😡','😠','🤬','👍','👎','👏','🙌','🤝','💪','❤️','🧡','💛','💚','💙','💜','🖤','🤍','🎉','🎊','🔥','⭐','✅','❌']
const insertEmoji = (emoji) => {
input.value += emoji
showEmoji.value = false
}
// --- quote ---
const startQuote = (msg) => {
quoteMessage.value = msg
}
const cancelQuote = () => { quoteMessage.value = null }
// --- message search ---
const toggleSearch = () => {
searchOpen.value = !searchOpen.value
if (!searchOpen.value) {
searchKeyword.value = ''
searchResults.value = []
}
}
const doSearch = async () => {
if (!searchKeyword.value.trim()) { searchResults.value = []; return }
searchLoading.value = true
const kw = searchKeyword.value.toLowerCase()
searchResults.value = chatStore.messages.filter(m => {
const txt = typeof m.content === 'object' ? (m.content?.body?.text || m.content?.fallback || '') : (m.content || '')
return txt.toLowerCase().includes(kw)
})
searchLoading.value = false
}
const scrollToMsg = (msg) => {
const el = document.getElementById('msg-' + (msg.id || msg.sequenceId))
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// --- message actions ---
const handleWithdraw = async (msg) => {
const msgId = msg.id || msg.clientMsgId
if (!msgId) { message.error('无法撤回此消息'); return }
try {
const res = await messageService.withdraw(msgId)
if (res.code === 0) {
msg.isWithdrawn = true
message.success('已撤回')
} else {
message.error(res.message || '撤回失败')
}
} catch { message.error('撤回失败') }
}
const handleDeleteMsg = (msg) => {
chatStore.deleteMessage(msg)
}
const handleForward = () => {
message.info('请选择转发联系人(功能开发中)')
}
const infoShowHandler = async () => { const infoShowHandler = async () => {
if (infoSideBarShow.value) { if (infoSideBarShow.value) {
@@ -182,14 +290,20 @@ const infoShowHandler = async () => {
infoSideBarShow.value = true infoSideBarShow.value = true
} }
const handleConversationClosed = async () => {
infoSideBarShow.value = false;
await router.push('/messages/index');
}
const getImageStyle = (content) => {
const maxWidth = 200; // 最大宽度
const maxHeight = 200; // 最大高度
const minSize = 60; // 最小尺寸,防止变成一个点
let w = content.W || maxWidth; const getImageStyle = (m) => {
let h = content.H || maxHeight; const maxWidth = 200;
const maxHeight = 200;
const minSize = 60;
// 新 API: 媒体尺寸在消息顶层字段 width/height
let w = m.width || m.content?.body?.width || maxWidth;
let h = m.height || m.content?.body?.height || maxHeight;
const ratio = w / h; const ratio = w / h;
@@ -212,7 +326,7 @@ const getImageStyle = (content) => {
const imagePreview = (e, m) => { const imagePreview = (e, m) => {
e.stopPropagation(); e.stopPropagation();
const imageList = chatStore.messages const imageList = chatStore.messages
.filter(x => x.type == 'Image') .filter(x => x.msgType == MSG_TYPE.Image)
; ;
const index = imageList.indexOf(m); const index = imageList.indexOf(m);
if (isElectron()) { if (isElectron()) {
@@ -223,7 +337,7 @@ const imagePreview = (e, m) => {
window.api.window.newWindow('imgpre', safeData); window.api.window.newWindow('imgpre', safeData);
} else { } else {
previewImages({ previewImages({
imgList: imageList.map(m => m.content.url), imgList: imageList.map(m => m.url || (m.content?.body?.url) || m.localUrl),
nowImgIndex: index nowImgIndex: index
}); });
} }
@@ -271,7 +385,7 @@ const stopRecord = async () => {
} }
const playHandler = (m) => { const playHandler = (m) => {
videoUrl.value = m.content.url videoUrl.value = m.url || (m.content?.body?.url) || m.localUrl
if (isElectron()) { if (isElectron()) {
window.api.window.newWindow('videopre', videoUrl.value) window.api.window.newWindow('videopre', videoUrl.value)
return return
@@ -283,6 +397,59 @@ const videoClose = () => {
videoOpen.value = false videoOpen.value = false
} }
const resolvePrivateMedia = async (m) => {
if (![MSG_TYPE.Image, MSG_TYPE.Video, MSG_TYPE.Voice].includes(m.msgType)) return;
const fileId = m.fileId || m.content?.body?.fileId;
const hasCompleteLocalMedia = m.msgType !== MSG_TYPE.Video && m.localUrl;
if (!fileId || m.url || hasCompleteLocalMedia || resolvingFileIds.has(fileId)) return;
resolvingFileIds.add(fileId);
try {
const blob = await uploadService.downloadFile(fileId);
const objectUrl = URL.createObjectURL(blob);
resolvedObjectUrls.add(objectUrl);
m.url = objectUrl;
} catch (error) {
console.error('媒体文件读取失败:', error);
} finally {
resolvingFileIds.delete(fileId);
}
};
const formatFileSize = (size) => {
const bytes = Number(size || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
};
const downloadFileMessage = async (m) => {
const fileId = m.fileId || m.content?.body?.fileId;
if (!fileId) {
message.error('文件标识缺失,无法下载');
return;
}
try {
const blob = await uploadService.downloadFile(fileId);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = m.fileName || m.content?.body?.fileName || 'download';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
} catch {
message.error('文件下载失败或无访问权限');
}
};
// 群聊中根据 senderId 查找成员头像
const getSenderAvatar = (senderId) => {
const member = groupData?.members?.find(gm => gm.userId == senderId);
return member?.avatar || null;
};
const loadHistoryMsg = async () => { const loadHistoryMsg = async () => {
// 1. 如果正在加载,或者已经彻底没数据了,才拦截 // 1. 如果正在加载,或者已经彻底没数据了,才拦截
if (isLoading.value || isFinished.value) return; if (isLoading.value || isFinished.value) return;
@@ -312,30 +479,63 @@ const loadHistoryMsg = async () => {
} }
}; };
const handleHoverCard = (e, m) => { const handleHoverCard = async (e, m) => {
const userInfo = { let name = m.senderName || '';
name: m.senderName, let avatar = m.senderAvatar || null;
avatar: m.senderAvatar, let userName = '';
id: m.senderId if (m.senderId == myInfo.id) {
name = myInfo.nickName || myInfo.userName || name;
avatar = avatar || myInfo.avatar;
userName = myInfo.userName || '';
} else {
// 先从本地信息补全,查不到再调接口
const member = groupData?.members?.find(gm => gm.userId == m.senderId);
if (member) {
name = name || member.groupNickName || member.userId;
avatar = avatar || member.avatar;
} else if (conversationInfo.value?.targetId == m.senderId) {
name = name || conversationInfo.value.targetName || '';
avatar = avatar || conversationInfo.value.targetAvatar;
} }
if (!name || name == m.senderId) {
name = name || m.senderId;
}
// 查用户名
try {
const { data } = await userService.findUser(m.senderId);
if (data) {
userName = data.userName || '';
name = name || data.nickName || data.userName || name;
avatar = avatar || data.avatar;
}
} catch { /* 查不到就忽略 */ }
}
const userInfo = { name, avatar, id: m.senderId, userName }
userHoverCardRef.value.show(e.target, userInfo); userHoverCardRef.value.show(e.target, userInfo);
} }
const closeHoverCard = () => {
userHoverCardRef.value.hide();
}
const handleRightClick = (e, m) => { const handleRightClick = (e, m) => {
e.stopPropagation(); e.stopPropagation();
menuRef.value.show(e, useRightClickHandler(e, m)); const menu = useRightClickHandler(m, {
onQuote: startQuote,
onWithdraw: handleWithdraw,
onDelete: handleDeleteMsg,
onForward: handleForward,
})
menuRef.value.show(e, menu);
} }
const debouncedMarkRead = debounce((id) => messageService.markRead(id), 500);
watch( watch(
() => chatStore.messages, () => chatStore.messages,
async (newVal) => { async () => {
await Promise.all(chatStore.messages.map(resolvePrivateMedia));
scrollToBottom(); scrollToBottom();
conversationStore.conversations.find(x => x.id == conversationInfo.value.id).unreadCount = 0; const conv = conversationStore.conversations.find(x => x.id == conversationInfo.value.id);
signalRStore.clearUnreadCount(conversationInfo.value.id); if (conv) conv.unreadCount = 0;
if (conversationInfo.value?.id) debouncedMarkRead(conversationInfo.value.id);
}, },
{ deep: true } { deep: true }
); );
@@ -351,10 +551,16 @@ const scrollToBottom = async () => {
// 发送文本 // 发送文本
async function sendText() { async function sendText() {
if (!input.value.trim()) return; if (!input.value.trim()) return;
// 根据 C# MessageBaseDto 构造的示例对象 const qid = quoteMessage.value?.id || null
const content = input.value; const content = input.value;
input.value = ''; input.value = '';
await sendTextMessage(content, conversationInfo); cancelQuote()
await sendTextMessage(content, conversationInfo, qid);
}
// 重发失败消息
const handleRetry = (m) => {
retryMessage(m)
} }
// 通话模拟 // 通话模拟
@@ -369,23 +575,30 @@ async function handleFile(files) {
let localUrl = null; let localUrl = null;
let img = null; let img = null;
switch (getMessageType(file.type)) { switch (getMessageType(file.type)) {
case FILE_TYPE.Image: case MSG_TYPE.Image:
localUrl = URL.createObjectURL(file); localUrl = URL.createObjectURL(file);
img = await loadImage(localUrl); img = await loadImage(localUrl);
info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200)); info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200));
break; break;
case FILE_TYPE.Video: { case MSG_TYPE.Video: {
try {
const imgBlob = await getVideoThumbnailBlob(file); const imgBlob = await getVideoThumbnailBlob(file);
localUrl = URL.createObjectURL(imgBlob); localUrl = URL.createObjectURL(imgBlob);
img = await loadImage(localUrl); img = await loadImage(localUrl);
info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file)); info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
} catch (e) {
console.error('视频解析失败:', e);
info = new VideoInfo(file.type, '[视频]', 0, 0, null, 0);
}
break; break;
} }
case FILE_TYPE.Voice: { case MSG_TYPE.Voice: {
localUrl = URL.createObjectURL(file); localUrl = URL.createObjectURL(file);
info = new VoiceInfo(file.type, '[语音消息]', 0);
try {
info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file)); info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file));
} catch { /* 无法获取时长,用 0 */ }
break; break;
} }
@@ -394,10 +607,6 @@ async function handleFile(files) {
await sendFileMessage(file, conversationInfo, info, localUrl); await sendFileMessage(file, conversationInfo, info, localUrl);
} }
function toggleEmoji() {
console.log('打开表情面板');
}
async function loadConversation(conversationId) { async function loadConversation(conversationId) {
/* /*
const res = await messageService.getConversationById(conversationId); const res = await messageService.getConversationById(conversationId);
@@ -406,14 +615,14 @@ async function loadConversation(conversationId) {
if (conversationStore.conversations.length == 0) { if (conversationStore.conversations.length == 0) {
await conversationStore.loadUserConversations(); await conversationStore.loadUserConversations();
} }
conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId)); conversationInfo.value = conversationStore.conversations.find(x => x.id == conversationId);
} }
const initChat = async (newId) => { const initChat = async (newId) => {
await loadConversation(newId); await loadConversation(newId);
if (conversationInfo.value) { if (conversationInfo.value) {
const sessionid = generateSessionId( const sessionid = generateSessionId(
conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP) conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == CHAT_TYPE.GROUP)
await chatStore.swtichSession(sessionid, newId); await chatStore.swtichSession(sessionid, newId);
isFinished.value = false; isFinished.value = false;
scrollToBottom(); scrollToBottom();
@@ -431,14 +640,14 @@ watch(
} }
// 尝试找到当前会话 // 尝试找到当前会话
const session = conversationStore.conversations.find(x => x.id == Number(props.id)); const session = conversationStore.conversations.find(x => x.id == props.id);
// 返回 [ID, 状态],用了 ?. 即使 session 是 undefined 也只会返回 undefined,不会报错 // 返回 [ID, 状态],用了 ?. 即使 session 是 undefined 也只会返回 undefined,不会报错
return [props.id, session?.isInitialized]; return [props.id, session?.isInitialized];
}, },
// 2. 回调逻辑 // 2. 回调逻辑
async ([newId, isInited], [oldId, oldInited]) => { async ([newId, isInited]) => {
// 基础防守:如果 ID 无效,直接跳过 // 基础防守:如果 ID 无效,直接跳过
if (!newId) return; if (!newId) return;
@@ -460,16 +669,16 @@ watch(
//const currentMax = chatStore.maxSequenceId; //const currentMax = chatStore.maxSequenceId;
// 2. 去服务器拉取增量数据 // 2. 去服务器拉取增量数据
const msgList = await chatStore.fetchNewMsgFromServier(newId); const msgList = await chatStore.fetchNewMsgFromServier(newId, chatStore.maxSequenceId);
const session = conversationStore.conversations.find(x => x.id == Number(newId)); const session = conversationStore.conversations.find(x => x.id == newId);
if (msgList && msgList.length > 0) { if (msgList && msgList.length > 0) {
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId)); const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
const locaMaxSequenceId = chatStore.maxSequenceId; const locaMaxSequenceId = chatStore.maxSequenceId;
if (locaMaxSequenceId < (minSequenceId - 1)) { if (locaMaxSequenceId < (minSequenceId - 1)) {
chatStore.messages = [];; chatStore.messages = [];;
} }
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true); await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == CHAT_TYPE.GROUP), true);
} }
// 3. 如果有新消息,存入 Store // 3. 如果有新消息,存入 Store
@@ -527,6 +736,8 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
if (observer) observer.disconnect(); if (observer) observer.disconnect();
resolvedObjectUrls.forEach((url) => URL.revokeObjectURL(url));
resolvedObjectUrls.clear();
}); });
</script> </script>
@@ -773,6 +984,26 @@ onUnmounted(() => {
background: #fff; background: #fff;
} }
.file-message {
width: 280px;
min-height: 74px;
padding: 12px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: #fff;
color: #333;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
}
.file-message:hover { background: #f8f8f8; }
.file-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.file-meta strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-meta small { color: #999; }
.mine .text-bubble { .mine .text-bubble {
background: #95ec69; background: #95ec69;
} }
@@ -781,12 +1012,7 @@ onUnmounted(() => {
/* background: #95ec69; */ /* background: #95ec69; */
} }
:deep(.avatar-chat) { :deep(.avatar-chat) { width: 38px; height: 38px; border-radius: 4px; flex-shrink: 0; }
width: 38px;
height: 38px;
border-radius: 4px;
flex-shrink: 0;
}
.msg-time { .msg-time {
font-size: 11px; font-size: 11px;
@@ -827,4 +1053,82 @@ textarea {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
/* --- emoji 面板 --- */
.emoji-panel {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 8px;
background: #fff;
border-top: 1px solid #e0e0e0;
max-height: 200px;
overflow-y: auto;
}
.emoji-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 18px;
cursor: pointer;
border-radius: 4px;
}
.emoji-btn:hover { background: #e8e8e8; }
/* --- 引用预览条 --- */
.quote-bar {
display: flex;
align-items: center;
padding: 6px 12px;
background: #f0f7ff;
border-top: 1px solid #d0dff5;
font-size: 13px;
gap: 8px;
}
.quote-label { color: #007aff; font-weight: 500; white-space: nowrap; }
.quote-text { flex: 1; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.quote-close { background: none; border: none; color: #999; cursor: pointer; font-size: 16px; }
/* --- 搜索面板 --- */
.search-panel {
padding: 8px 12px;
background: #fff;
border-top: 1px solid #e0e0e0;
max-height: 180px;
display: flex;
flex-direction: column;
}
.search-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.search-results {
flex: 1;
overflow-y: auto;
margin: 6px 0;
}
.search-item {
padding: 4px 0;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
display: flex;
gap: 8px;
font-size: 12px;
}
.search-item:hover { background: #f0f7ff; }
.search-time { color: #999; white-space: nowrap; }
.search-text { color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-empty { text-align: center; color: #ccc; padding: 10px; font-size: 13px; }
.search-close { margin-top: 4px; padding: 4px; background: #f0f0f0; border: none; cursor: pointer; border-radius: 4px; font-size: 12px; }
/* --- 撤回消息 --- */
.withdrawn-msg { color: #999; font-style: italic; padding: 4px 6px; }
.error-icon.clickable { cursor: pointer; opacity: 0.8; transition: opacity 0.15s; }
.error-icon.clickable:hover { opacity: 1; }
</style> </style>
@@ -1,41 +1,28 @@
import { FILE_TYPE } from '../../../../constants/fileTypeDefine' import { MSG_TYPE } from '@/constants/MessageType'
export function useRightClickHandler(e, m) { const buildMenu = (message, opts = {}) => {
const textRightItem = [ const { onQuote, onForward, onDelete, onWithdraw, onCopy } = opts
{ const items = []
label: '复制',
action: async () => {
await navigator.clipboard.writeText(e.target.innerText)
}
},
{
label: '引用',
action: () => console.log('进入私聊')
},
{
label: '转发',
action: () => {}
},
{
label: '删除',
type: 'danger',
action: () => alert('删除成功')
}
]
const imgRightItem = [ const isText = message.msgType === MSG_TYPE.Text
{ const isImage = message.msgType === MSG_TYPE.Image
label: '复制',
action: () => { if (isText) {
console.log(e.target) 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) {
switch (m.type) { items.push({ label: '引用', action: () => onQuote?.(message) })
case FILE_TYPE.TEXT:
return textRightItem
case FILE_TYPE.Image:
case FILE_TYPE.Video:
return imgRightItem;
} }
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)
} }
@@ -1,89 +1,168 @@
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useAuthStore } from "@/stores/auth";
import { generateSessionId } from "@/utils/sessionIdTools"; 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 { messageService } from "@/services/message";
import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus"; import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus";
import { uploadFile } from "@/services/upload/uploader"; import { uploadFile } from "@/services/upload/uploader";
import { UPLOAD_STATUS } from "@/constants/uploadStatus"; import { UPLOAD_STATUS } from "@/constants/uploadStatus";
import { getMessageType } from "@/constants/fileTypeDefine"; import { getMessageType } from "@/constants/fileTypeDefine";
import { uploadService } from "@/services/upload/uploadService"; import { uploadService } from "@/services/upload/uploadService";
import { getFileHash } from "@/utils/uploadTools";
export function useSendMessageHandler() { export function useSendMessageHandler() {
// 发送者恒等于当前登录用户,避免会话数据缺 userId 导致消息归属错误
const myId = useAuthStore().userInfo?.id;
const sendMessage = async (msg) => { const sendMessage = async (msg) => {
const chatStore = useChatStore(); const chatStore = useChatStore();
//设置消息为加载状态
const msgServer = { ...msg }
msg.isLoading = true; msg.isLoading = true;
//将临时消息推送到消息列表(存库,方便后续重试) const isGroupChat = msg.chatType == CHAT_TYPE.GROUP;
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true); await chatStore.pushAndSortMessagesAsync(
//从列表取出消息 [msg],
let updateMsg = msg; generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
);
try { try {
const res = await messageService.sendMessage(msgServer); const res = await messageService.sendMessage(msg);
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) { if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
updateMsg.isError = true; msg.isError = true;
} else { } else {
//发送成功将后端生成的sequenceId更新 // 用服务端数据覆盖(保留本地 clientMsgId 等字段用于去重/重发)
updateMsg = res.data; const serverData = res.data;
Object.assign(msg, serverData);
msg.isError = false;
} }
} catch { } catch {
updateMsg.isError = true; msg.isError = true;
} finally { } finally {
updateMsg.isLoading = false;
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
msg.isLoading = false; 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 = { const msg = {
type: 'Text', // 消息类型,例如 'Text', 'Image', 'File' clientMsgId: self.crypto.randomUUID(),
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP' chatType: conversationInfo.value.chatType,
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int) targetId: conversationInfo.value.targetId,
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int) msgType: MSG_TYPE.Text,
content: text, senderId: myId,
timeStamp: new Date(), // 对应 DateTime sequenceId: Date.now(),
msgId: self.crypto.randomUUID() text, // 顶层 text 供 /message/sendMessageSendRequest 校验 text 非空)
// 统一用 content 结构,本地消息与服务端返回格式一致
content: { fallback: text, body: { text }, ext: {}, quote: null },
}; };
//更新当前会话最新消息 if (quoteMessageId) msg.quoteMessageId = quoteMessageId;
conversationInfo.value.lastMessage = msg.content; conversationInfo.value.lastMessage = text;
await sendMessage(msg); await sendMessage(msg);
} }
// 重发失败消息
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 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
)
}
}
const sendFileMessage = async (file, conversationInfo, info, localUrl) => { const sendFileMessage = async (file, conversationInfo, info, localUrl) => {
const chatStore = useChatStore(); const chatStore = useChatStore();
const isGroupChat = conversationInfo.value.chatType == CHAT_TYPE.GROUP;
const msgType = getMessageType(file.type);
const msgId = self.crypto.randomUUID();
const msg = { const msg = {
type: getMessageType(file.type), // 消息类型,例如 'Text', 'Image', 'File' clientMsgId: msgId,
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP' chatType: conversationInfo.value.chatType,
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int) targetId: conversationInfo.value.targetId,
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int) msgType: msgType,
content: '', senderId: myId,
timeStamp: new Date(), // 对应 DateTime sequenceId: Date.now(),
msgId: self.crypto.randomUUID(),
localUrl: localUrl, localUrl: localUrl,
progress: 0 progress: 0,
isLoading: false,
isImgLoading: true,
}; };
//更新当前会话最新消息
conversationInfo.value.lastMessage = info.text; conversationInfo.value.lastMessage = info.text || '[文件]';
msg.isImgLoading = true; msg.isImgLoading = true;
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true); await chatStore.pushAndSortMessagesAsync(
[msg],
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
);
if (info.thumb) { // 缩略图直传
const hash = await getFileHash(info.thumb); if (info.thumb instanceof Blob) {
try { try {
const { data } = await uploadService.uploadSmallFile(info.thumb, hash); const thumbFile = info.thumb instanceof File
info.thumb = data.objectName; ? 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) { } catch (e) {
console.error(e) console.error('缩略图上传失败:', e);
msg.isError = true; info.thumb = '';
msg.isLoading = false; }
return;
} }
} const conversationId = chatStore.activeConversationId;
await uploadFile(file, { try {
const uploadedFile = await uploadFile(file, {
conversationId,
chatType: conversationInfo.value.chatType,
targetId: conversationInfo.value.targetId,
onProgress: async (e) => { onProgress: async (e) => {
if (!e.status) return; if (!e.status) return;
switch (e.status) { switch (e.status) {
@@ -91,19 +170,44 @@ export function useSendMessageHandler() {
case UPLOAD_STATUS.UPLOADING: case UPLOAD_STATUS.UPLOADING:
msg.progress = e.progress; msg.progress = e.progress;
break; break;
case UPLOAD_STATUS.UPLOADED:
msg.isImgLoading = false;
break;
case UPLOAD_STATUS.COMPLETE: case UPLOAD_STATUS.COMPLETE:
msg.progress = 100; msg.progress = 100;
msg.isImgLoading = false; msg.isImgLoading = false;
info.fileId = e.taskId;
msg.content = JSON.stringify(info);
await sendMessage(msg);
break; break;
default: default:
break; 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 }; return { sendMessage, sendFileMessage, sendTextMessage, retryMessage };
} }
@@ -55,7 +55,7 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref, computed } from 'vue' import { ref, computed } from 'vue'
const twoFactor = ref(false) const twoFactor = ref(false)
@@ -11,7 +11,7 @@
<span class="description">选择您喜欢的主题颜色</span> <span class="description">选择您喜欢的主题颜色</span>
</div> </div>
<div class="select-wrapper"> <div class="select-wrapper">
<select v-model="settings.theme"> <select :value="settingsStore.generalOptions.theme" @change="setTheme($event.target.value)">
<option value="light">浅色模式</option> <option value="light">浅色模式</option>
<option value="dark">深色模式</option> <option value="dark">深色模式</option>
<option value="system">跟随系统</option> <option value="system">跟随系统</option>
@@ -25,7 +25,7 @@
<span class="description">设置应用显示的界面语言</span> <span class="description">设置应用显示的界面语言</span>
</div> </div>
<div class="select-wrapper"> <div class="select-wrapper">
<select v-model="settings.language"> <select :value="settingsStore.generalOptions.language" @change="settingsStore.setGeneralOptions({ language: $event.target.value })">
<option value="zh-CN">简体中文</option> <option value="zh-CN">简体中文</option>
<option value="en-US">English</option> <option value="en-US">English</option>
</select> </select>
@@ -42,7 +42,7 @@
<span class="description">在电脑启动时自动运行应用</span> <span class="description">在电脑启动时自动运行应用</span>
</div> </div>
<label class="switch"> <label class="switch">
<input type="checkbox" v-model="settings.autoStart"> <input type="checkbox" :checked="settingsStore.generalOptions.autoStart" @change="toggleAutoStart">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
@@ -54,10 +54,10 @@
</div> </div>
<div class="radio-group"> <div class="radio-group">
<label class="radio-label"> <label class="radio-label">
<input type="radio" value="tray" v-model="settings.closeBehavior"> 最小化到托盘 <input type="radio" value="tray" :checked="settingsStore.generalOptions.closeBehavior === 'tray'" @change="settingsStore.setGeneralOptions({ closeBehavior: 'tray' })"> 最小化到托盘
</label> </label>
<label class="radio-label"> <label class="radio-label">
<input type="radio" value="quit" v-model="settings.closeBehavior"> 直接退出应用 <input type="radio" value="quit" :checked="settingsStore.generalOptions.closeBehavior === 'quit'" @change="settingsStore.setGeneralOptions({ closeBehavior: 'quit' })"> 直接退出应用
</label> </label>
</div> </div>
</div> </div>
@@ -89,35 +89,106 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useSettingsStore } from '../../stores/settings'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMessage } from '../../components/messages/useAlert'
import { dbPromise } from '../../utils/db/baseDb'
const cacheSize = ref(124.5) const cacheSize = ref(0)
const isClearing = ref(false) const isClearing = ref(false)
const authStore = useAuthStore() const authStore = useAuthStore()
const settingsStore = useSettingsStore()
const router = useRouter() const router = useRouter()
const message = useMessage()
const settings = reactive({ // -- 主题 --
theme: 'system', const applyTheme = (theme) => {
language: 'zh-CN', const root = document.documentElement
autoStart: true, const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
closeBehavior: 'tray' root.style.setProperty('--bg', isDark ? '#1e1e1e' : '#fff')
}) root.style.setProperty('--text', isDark ? '#ddd' : '#333')
// body 层
document.body.style.background = isDark ? '#1a1a1a' : ''
document.body.style.color = isDark ? '#ccc' : ''
}
const setTheme = (val) => {
settingsStore.setGeneralOptions({ theme: val })
applyTheme(val)
}
// -- 开机自启动(Electron --
const toggleAutoStart = () => {
const next = !settingsStore.generalOptions.autoStart
settingsStore.setGeneralOptions({ autoStart: next })
// Electron 环境通过 IPC 写入系统启动项
if (window.electron?.ipcRenderer) {
try { window.electron.ipcRenderer.send('setting-autoStart', next) } catch { /* Electron IPC is optional here. */ }
} else if (window.api) {
// 如果 preload 暴露了,走这里
try { window.api.setAutoStart?.(next) } catch { /* Alternate preload API is optional here. */ }
}
}
// -- 缓存 --
const calcCacheSize = async () => {
try {
const db = await dbPromise
const stores = ['messages', 'conversations', 'contacts', 'groupRequests']
let total = 0
for (const storeName of stores) {
if (db.objectStoreNames.contains(storeName)) {
const items = await db.getAll(storeName)
total += new Blob([JSON.stringify(items)]).size
}
}
// 加上磁盘缓存大小
if (window.api?.cache?.diskSize) {
const res = await window.api.cache.diskSize()
if (res.success) total += res.size
}
cacheSize.value = +(total / (1024 * 1024)).toFixed(2)
} catch { cacheSize.value = 0 }
}
const clearCache = async () => {
isClearing.value = true
try {
const db = await dbPromise
const stores = ['messages', 'conversations', 'contacts', 'groupRequests']
for (const storeName of stores) {
try { await db.clear(storeName) } catch { /* Continue clearing the remaining cache stores. */ }
}
// 清除 localStorage 会话缓存(置顶/免打扰标记)
const keys = Object.keys(localStorage)
for (const k of keys) {
if (k.startsWith('conv_')) localStorage.removeItem(k)
}
// 清除磁盘文件缓存
if (window.api?.cache?.clearDisk) {
await window.api.cache.clearDisk()
}
cacheSize.value = 0
message.success('缓存清理成功')
} catch {
message.error('缓存清理失败')
} finally {
isClearing.value = false
}
}
const logout = () => { const logout = () => {
authStore.logout() authStore.logout()
router.push('/auth/login') router.push('/auth/login')
} }
const clearCache = () => { onMounted(() => {
isClearing.value = true applyTheme(settingsStore.generalOptions.theme)
setTimeout(() => { calcCacheSize()
cacheSize.ref = 0 })
isClearing.value = false
alert('缓存清理成功')
}, 1500)
}
</script> </script>
<style scoped> <style scoped>
@@ -171,7 +242,6 @@ const clearCache = () => {
color: #999; color: #999;
} }
/* 下拉选择框样式 */
.select-wrapper select { .select-wrapper select {
padding: 6px 10px; padding: 6px 10px;
border: 1px solid #dcdfe6; border: 1px solid #dcdfe6;
@@ -182,7 +252,6 @@ const clearCache = () => {
cursor: pointer; cursor: pointer;
} }
/* 单选框组样式 */
.radio-group { .radio-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -198,7 +267,6 @@ const clearCache = () => {
gap: 6px; gap: 6px;
} }
/* 按钮样式 */
.ghost-btn { .ghost-btn {
background: #fff; background: #fff;
border: 1px solid #dcdfe6; border: 1px solid #dcdfe6;
@@ -224,7 +292,6 @@ const clearCache = () => {
color: #007bff; color: #007bff;
} }
/* 复用 Switch 开关样式 */
.switch { .switch {
position: relative; position: relative;
width: 36px; width: 36px;
@@ -11,7 +11,7 @@
<span class="description">当有新版本可用时提醒我</span> <span class="description">当有新版本可用时提醒我</span>
</div> </div>
<label class="switch"> <label class="switch">
<input type="checkbox" v-model="settings.systemUpdate"> <input type="checkbox" :checked="settingsStore.notificationOptions.systemUpdate" @change="toggle('systemUpdate')">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
@@ -19,10 +19,10 @@
<div class="setting-item"> <div class="setting-item">
<div class="info"> <div class="info">
<span class="label">新消息提醒</span> <span class="label">新消息提醒</span>
<span class="description">收到好友或群组新消息时播放提示音</span> <span class="description">收到好友或群组新消息时播放提示音并显示通知</span>
</div> </div>
<label class="switch"> <label class="switch">
<input type="checkbox" v-model="settings.newMsg"> <input type="checkbox" :checked="settingsStore.notificationOptions.newMsg" @change="toggle('newMsg')">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
@@ -37,7 +37,7 @@
<span class="description">在屏幕右下角显示通知气泡</span> <span class="description">在屏幕右下角显示通知气泡</span>
</div> </div>
<label class="switch"> <label class="switch">
<input type="checkbox" v-model="settings.desktopPopup"> <input type="checkbox" :checked="settingsStore.notificationOptions.desktopPopup" @change="toggle('desktopPopup')">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
@@ -48,7 +48,7 @@
<span class="description">每周接收一次系统周报</span> <span class="description">每周接收一次系统周报</span>
</div> </div>
<label class="switch"> <label class="switch">
<input type="checkbox" v-model="settings.emailDigest"> <input type="checkbox" :checked="settingsStore.notificationOptions.emailDigest" @change="toggle('emailDigest')">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
@@ -63,30 +63,33 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue' import { ref } from 'vue'
import { useSettingsStore } from '../../stores/settings'
import { useMessage } from '../../components/messages/useAlert'
const settingsStore = useSettingsStore()
const message = useMessage()
const isSaving = ref(false) const isSaving = ref(false)
const settings = reactive({ // 即时切换,但只保存开关状态
systemUpdate: true, const toggle = (key) => {
newMsg: true, settingsStore.setNotificationOptions({ [key]: !settingsStore.notificationOptions[key] })
desktopPopup: false, // 新消息关闭时重置浏览器通知权限感知(下次再开时重新请求)
emailDigest: false }
})
const saveSettings = async () => { const saveSettings = async () => {
isSaving.value = true isSaving.value = true
// 模拟请求 // settingsStore 已经即时写入 localStorage,这里只是给用户反馈
await new Promise(r => setTimeout(r, 800)) await new Promise(r => setTimeout(r, 600))
isSaving.value = false isSaving.value = false
alert('通知设置已更新') message.success('通知设置已保存')
} }
</script> </script>
<style scoped> <style scoped>
.settings-content { .settings-content {
width: 100%; width: 100%;
max-width: 520px; /* 稍微宽一点,方便文字展示 */ max-width: 520px;
margin: 0 auto; margin: 0 auto;
color: #333; color: #333;
} }
@@ -133,7 +136,6 @@ const saveSettings = async () => {
color: #999; color: #999;
} }
/* 纯 CSS 实现的开关组件 */
.switch { .switch {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -169,14 +171,13 @@ const saveSettings = async () => {
} }
input:checked + .slider { input:checked + .slider {
background-color: #007bff; /* 匹配你截图中的主蓝色 */ background-color: #007bff;
} }
input:checked + .slider:before { input:checked + .slider:before {
transform: translateX(18px); transform: translateX(18px);
} }
/* 按钮样式保持一致 */
.action-bar { .action-bar {
margin-top: 20px; margin-top: 20px;
} }
@@ -21,7 +21,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, computed } from 'vue'
import feather from 'feather-icons' import feather from 'feather-icons'
import SettingContent from './SettingContent.vue' import SettingContent from './SettingContent.vue'
@@ -36,14 +36,6 @@ const menuItems = [
const currentMenuName = computed(() => menuItems.find(i => i.id === activeTab.value)?.name) const currentMenuName = computed(() => menuItems.find(i => i.id === activeTab.value)?.name)
const notificationSettings = reactive({
'声音提醒': true,
'桌面弹窗': true,
'仅在免打扰外提醒': false
})
const save = () => alert('设置已生效')
const reset = () => location.reload()
</script> </script>
<style scoped> <style scoped>
@@ -51,7 +51,6 @@ import { userService } from '../../services/userService'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus' import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
import { useMessage } from '../../components/messages/useAlert' import { useMessage } from '../../components/messages/useAlert'
import { getFileHash } from '../../utils/uploadTools'
const authStore = useAuthStore() const authStore = useAuthStore()
const message = useMessage() const message = useMessage()
@@ -64,10 +63,10 @@ const triggerUpload = () => fileRef.value.click()
const onFileChange = async (e) => { const onFileChange = async (e) => {
const file = e.target.files[0] const file = e.target.files[0]
const hash = await getFileHash(file) const res = await uploadService.uploadSmallFile(file, true)
const res = await uploadService.uploadSmallFile(file, hash)
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){ if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
message.error(res.message) message.error(res.message)
return
} }
userInfo.avatar = res.data.url userInfo.avatar = res.data.url
message.success('头像上传成功') message.success('头像上传成功')
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getFileHash: vi.fn(),
sliceFile: vi.fn(),
initUploadTask: vi.fn(),
getProgress: vi.fn(),
getUploadUrl: vi.fn(),
getTaskStatus: vi.fn(),
completeTask: vi.fn(),
uploadPart: vi.fn(),
uploadPartToUrl: vi.fn()
}))
vi.mock('@/utils/uploadTools', () => ({
getFileHash: mocks.getFileHash,
sliceFile: mocks.sliceFile
}))
vi.mock('@/services/upload/uploadService', () => ({
uploadService: {
initUploadTask: mocks.initUploadTask,
getProgress: mocks.getProgress,
getUploadUrl: mocks.getUploadUrl,
getTaskStatus: mocks.getTaskStatus,
completeTask: mocks.completeTask,
uploadPart: mocks.uploadPart,
uploadPartToUrl: mocks.uploadPartToUrl
}
}))
import { uploadFile } from '@/services/upload/uploader'
const file = {
name: 'demo.bin',
size: 10,
type: 'application/octet-stream'
}
describe('uploadFile', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getFileHash.mockResolvedValue('0123456789abcdef0123456789abcdef')
mocks.sliceFile.mockReturnValue([{ size: 5 }, { size: 5 }])
})
it('returns the final file immediately on an instant upload hit', async () => {
const finalFile = { id: 'file-1', url: 'https://files.test/file-1' }
mocks.initUploadTask.mockResolvedValue({
data: { taskId: 'file-1', instant: true, uploadMode: 'Instant', file: finalFile }
})
await expect(uploadFile(file, { chatType: 'PRIVATE', targetId: 'user-2' }))
.resolves.toEqual(finalFile)
expect(mocks.uploadPart).not.toHaveBeenCalled()
expect(mocks.completeTask).not.toHaveBeenCalled()
})
it('uploads every local part, completes, and waits for the final file', async () => {
const finalFile = { id: 'file-2', url: null }
mocks.initUploadTask.mockResolvedValue({
data: {
taskId: 'task-2',
uploadSessionId: 'session-2',
uploadMode: 'LocalMultipart',
totalPartCount: 2,
partSizeBytes: 5,
instant: false
}
})
mocks.uploadPart.mockImplementation((_session, partNumber) =>
Promise.resolve({ data: { eTag: `etag-${partNumber}` } })
)
mocks.completeTask.mockResolvedValue({ data: { state: 'Merging' } })
mocks.getTaskStatus.mockResolvedValue({ data: { state: 'Completed', file: finalFile } })
await expect(uploadFile(file, { chatType: 'GROUP', targetId: 'group-1' }))
.resolves.toEqual(finalFile)
expect(mocks.uploadPart).toHaveBeenCalledTimes(2)
expect(mocks.completeTask).toHaveBeenCalledOnce()
expect(mocks.getTaskStatus).toHaveBeenCalledWith('task-2')
})
it('does not call complete when a part keeps failing', async () => {
mocks.initUploadTask.mockResolvedValue({
data: {
taskId: 'task-3',
uploadSessionId: 'session-3',
uploadMode: 'LocalMultipart',
totalPartCount: 2,
partSizeBytes: 5,
instant: false
}
})
mocks.uploadPart.mockRejectedValue(new Error('part failed'))
await expect(uploadFile(file)).rejects.toThrow('part failed')
expect(mocks.completeTask).not.toHaveBeenCalled()
})
})
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src/renderer/src', import.meta.url))
}
},
test: {
environment: 'node',
clearMocks: true,
include: ['tests/**/*.test.js']
}
})