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。
+35 -12
View File
@@ -1,20 +1,43 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import { loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
main: {},
preload: {},
renderer: {
server: {
host: true
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
// 从 VITE_API_BASE_URL 推导网关源(去掉末尾 /api)
const apiBase = env.VITE_API_BASE_URL || 'http://localhost:8009/api'
const signalRBase = env.VITE_SIGNALR_BASE_URL || 'http://localhost:8009/chat'
const gatewayOrigin = new URL(apiBase).origin
const signalROrigin = new URL(signalRBase).origin
const signalRWebSocketOrigin = signalROrigin.replace(/^http/, 'ws')
const cspPlugin = {
name: 'environment-csp',
transformIndexHtml: (html) => html
.replaceAll('__API_ORIGIN__', gatewayOrigin)
.replaceAll('__SIGNALR_ORIGIN__', signalROrigin)
.replaceAll('__SIGNALR_WS_ORIGIN__', signalRWebSocketOrigin)
}
return {
main: {},
preload: {},
renderer: {
server: {
host: true,
// 开发环境代理,规避浏览器 CORS(Electron 内不受影响)
proxy: {
'/api': { target: gatewayOrigin, changeOrigin: true },
'/chat': { target: gatewayOrigin, changeOrigin: true, ws: true }
}
},
resolve: {
alias: {
'@': resolve('src/renderer/src')
}
},
plugins: [vue(), vueDevTools()]
resolve: {
alias: {
'@': resolve('src/renderer/src')
}
},
plugins: [vue(), vueDevTools(), cspPlugin]
}
}
})
+286 -7
View File
@@ -40,6 +40,7 @@
"prettier": "^3.7.4",
"vite": "^7.2.6",
"vite-plugin-vue-devtools": "^8.0.7",
"vitest": "^5.0.0",
"vue": "^3.5.25",
"vue-eslint-parser": "^10.2.0"
}
@@ -2474,6 +2475,17 @@
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz",
@@ -2484,6 +2496,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@@ -2595,6 +2614,64 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz",
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vue/babel-helper-vue-transform-on": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
@@ -3235,6 +3312,16 @@
"node": ">=0.8"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -3699,6 +3786,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -4771,6 +4868,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5236,6 +5340,16 @@
"node": ">=12.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -6999,6 +7113,20 @@
"node": ">= 0.4"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
@@ -7261,9 +7389,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.7",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7845,6 +7973,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -8015,6 +8150,13 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/stat-mode": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz",
@@ -8025,6 +8167,13 @@
"node": ">= 6"
}
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -8282,15 +8431,35 @@
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -9268,6 +9437,99 @@
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/vitest": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz",
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/vue": {
"version": "3.5.28",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz",
@@ -9389,6 +9651,23 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
+2
View File
@@ -11,6 +11,7 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "electron-vite build",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
@@ -49,6 +50,7 @@
"prettier": "^3.7.4",
"vite": "^7.2.6",
"vite-plugin-vue-devtools": "^8.0.7",
"vitest": "^5.0.0",
"vue": "^3.5.25",
"vue-eslint-parser": "^10.2.0"
}
+51 -5
View File
@@ -1,24 +1,28 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import path from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerWindowHandler } from './ipcHandlers/window'
import { createTry } from './trayHandler'
import { registerCacheHandler } from './ipcHandlers/cache'
import { addProtocolHandler } from '../cache/protocolReg'
import { CACHE_ROOT } from '../cache/cacheDir'
import fs from 'fs-extra'
let mainWindow = null
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
frame:false,
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
icon: join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
icon: path.join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false
}
})
@@ -40,7 +44,7 @@ function createWindow() {
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
}
@@ -63,6 +67,48 @@ app.whenReady().then(() => {
// IPC test
ipcMain.on('ping', () => console.log('pong'))
// 开机自启
ipcMain.on('setting-autoStart', (_event, enable) => {
app.setLoginItemSettings({ openAtLogin: !!enable })
})
// 清理磁盘文件缓存
ipcMain.handle('cache-clear-disk', async () => {
try {
await fs.emptyDir(CACHE_ROOT)
return { success: true }
} catch (e) {
return { success: false, error: e.message }
}
})
// 获取磁盘缓存大小
ipcMain.handle('cache-disk-size', async () => {
try {
let total = 0
const walk = async (dir) => {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const e of entries) {
const p = path.join(dir, e.name)
if (e.isDirectory()) { await walk(p) }
else { try { total += (await fs.stat(p)).size } catch { /* Ignore files removed during traversal. */ } }
}
}
if (await fs.pathExists(CACHE_ROOT)) await walk(CACHE_ROOT)
return { success: true, size: total }
} catch {
return { success: false, size: 0 }
}
})
// 新消息任务栏/托盘闪动
ipcMain.on('window-flash', () => {
if (mainWindow && !mainWindow.isFocused()) {
mainWindow.flashFrame(true)
// 也可以设置托盘图标高亮
}
})
registerWindowHandler()
registerCacheHandler()
-1
View File
@@ -1,6 +1,5 @@
import { app, Tray, Menu, nativeImage } from 'electron'
import path from 'path'
import { useRouter } from 'vue-router';
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 }),
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId),
setMainSize: (width, height, resizable = true) =>
ipcRenderer.send('window-action', 'changeSize', { width, height, resizable })
ipcRenderer.send('window-action', 'changeSize', { width, height, resizable }),
flash: () => ipcRenderer.send('window-flash')
},
cache: {
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type)
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type),
clearDisk: () => ipcRenderer.invoke('cache-clear-disk'),
diskSize: () => ipcRenderer.invoke('cache-disk-size')
}
}
+3 -3
View File
@@ -5,13 +5,13 @@
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' http://192.168.5.100:8009 ql-im:;
content="default-src 'self' ql-im:;
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self' http://localhost:5202 ws://localhost:5202 http://192.168.5.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:;
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>
<body>
-1
View File
@@ -8,7 +8,6 @@
<script setup>
import Alert from '@/components/messages/Alert.vue';
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
//import { useSignalRStore } from './stores/signalr';
import { useRouter } from 'vue-router';
@@ -1,46 +1,35 @@
<template>
<div class="img-container">
<img :src="finalUrl" alt="" v-bind="$attrs" @error="imgLoadErrHandler">
</div>
<img :src="src" :class="$attrs.class" v-bind="filteredAttrs" @error="onErr" />
</template>
<script setup>
import { computed, ref, useAttrs, watch } from 'vue';
import default_avatar from '@/assets/default_avatar.png';
import loading_img from '@/assets/loading_img.png';
import { onMounted, ref, watch } from 'vue';
import { useCacheStore } from '../stores/cache';
import { FILE_TYPE } from '../constants/fileTypeDefine';
import default_avatar from '@/assets/default_avatar.png'
import loading_img from '@/assets/loading_img.png'
const cacheStore = useCacheStore()
const attrs = useAttrs();
const filteredAttrs = computed(() => {
const rest = { ...attrs };
delete rest.class;
return rest;
});
const props = defineProps({
rawUrl: {
type: String,
required: true
},
noAvatar: {
type: Boolean,
default: false
rawUrl: { type: [String, null], default: null },
noAvatar: { type: Boolean, default: false }
});
const fallbackImg = props.noAvatar ? loading_img : default_avatar;
const error = ref(false);
const src = computed(() => {
if (error.value) return props.noAvatar ? loading_img : default_avatar;
if (props.rawUrl && props.rawUrl !== '') {
return props.rawUrl;
}
})
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 }
)
return fallbackImg;
});
const onErr = () => { error.value = true; };
watch(() => props.rawUrl, () => { error.value = false; });
</script>
@@ -31,7 +31,7 @@ export default {
</script>
<script setup>
import { defineProps, useAttrs, onMounted } from 'vue';
import { defineProps, useAttrs } from 'vue';
const props = defineProps({
// 按钮样式变体:primary, secondary, danger, text
@@ -20,7 +20,7 @@
</template>
<script setup>
import { ref, defineProps, onMounted, defineEmits } from 'vue';
import { ref, defineProps, defineEmits } from 'vue';
const props = defineProps({
menuList: {
@@ -3,14 +3,19 @@
@click="routeUserInfo(c.id)">
<AsyncImage :raw-url="c.avatar" class="avatar-std" />
<div class="info">
<div class="name">{{ c.remarkName }}</div>
<div class="name">{{ c.remarkName || c.nickName }}</div>
</div>
</div>
<div v-if="!props.contacts || props.contacts.length === 0" class="empty-placeholder">
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
<p class="empty-text">暂无好友</p>
</div>
</template>
<script setup>
import { defineProps, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import feather from 'feather-icons';
import AsyncImage from '../AsyncImage.vue';
const router = useRouter()
@@ -20,8 +25,8 @@ const activeContactId = computed(() => route.params.id)
const props = defineProps({
contacts: {
type: String,
required: true
type: Array,
default: () => []
}
})
@@ -33,6 +38,16 @@ const routeUserInfo = (id) => {
</script>
<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 {
display: flex;
padding: 10px 12px;
@@ -68,12 +83,7 @@ a:focus {
background: #c6c6c6;
}
:deep(.avatar-std) {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
:deep(.avatar-std) { width: 36px; height: 36px; border-radius: 4px; flex-shrink: 0; }
.icon-box {
width: 36px;
@@ -20,7 +20,7 @@ onMounted(async () => {
imageList.value = data.imageList;
index.value = data.index;
previewImages({
imgList: imageList.value.map(m => m.content.url),
imgList: imageList.value.map(m => m.url || (m.content?.body?.url) || m.localUrl),
nowImgIndex: index,
clickMaskCLose: false,
disabledImgRightClick:true,
@@ -1,52 +1,46 @@
<script setup>
import { ref, onMounted, defineEmits } from 'vue';
import { ref, computed, watch } from 'vue';
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';
const message = useMessage();
const isLoaded = ref(false);
const isError = ref(false)
const props = defineProps({
modelValue: Boolean,
type: {
/**@type {"CreateGroup" | "InviteUser"} */
type: String,
default: 'CreateGroup',
validator: (value) => {
return ['CreateGroup', 'InviteUser'].includes(value)
}
},
title: {
type: String,
default: '创建群聊'
}
type: { type: String, default: 'CreateGroup' },
title: { type: String, default: '创建群聊' },
/** 已在群的 userId 数组 */
excludeIds: { type: Array, default: () => [] }
});
const emits = defineEmits(['submit'])
const friends = ref([])
const emits = defineEmits(['submit']);
const friends = 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 () =>{
friends.value = (await friendService.getFriendList()).data;
})
watch(() => props.modelValue, async (v) => {
if (!v) return;
selected.value = new Set();
friends.value = [];
try {
const res = await friendService.getFriendList();
friends.value = (res.data || []).map(f => ({
...f,
friendId: f.targetId,
avatar: f.avatar,
nickName: f.remarkName || f.nickName,
}));
} catch { /* ignore */ }
});
</script>
<template>
@@ -60,18 +54,19 @@ onMounted(async () =>{
<main>
<input v-if="props.type == 'CreateGroup'" v-model="groupName" placeholder="群组名称..." class="mini-input" />
<div class="list">
<div v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item">
<AsyncImage :raw-url="f.userInfo.avatar" class="avatar" />
<span class="name">{{ f.remarkName }}</span>
<div v-for="f in available" :key="f.friendId" class="item" @click="toggle(f.friendId)">
<AsyncImage :raw-url="f.avatar" class="avatar" />
<span class="name">{{ f.nickName }}</span>
<input type="checkbox" :checked="selected.has(f.friendId)" />
</div>
<div v-if="available.length === 0 && friends.length > 0" class="empty-hint">暂无可邀请的好友</div>
<div v-if="friends.length === 0" class="empty-hint">加载中...</div>
</div>
</main>
<footer>
<button @click="submit" :disabled="(!groupName&& props.type == 'CreateGroup') || !selected.size" class="btn">
<button @click="submit" :disabled="(!groupName && props.type == 'CreateGroup') || !selected.size" class="btn">
{{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
</button>
</footer>
@@ -81,44 +76,19 @@ onMounted(async () =>{
</template>
<style scoped>
.overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 999;
}
.mini-modal {
background: white; width: 300px; border-radius: 12px; overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
header {
padding: 12px 16px; display: flex; justify-content: space-between;
background: #f9f9f9; font-weight: bold; font-size: 14px;
}
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
.mini-modal { background: white; width: 300px; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
header { padding: 12px 16px; display: flex; justify-content: space-between; background: #f9f9f9; font-weight: bold; font-size: 14px; }
header button { background: none; border: none; cursor: pointer; color: #999; }
main { padding: 12px; }
.mini-input {
width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee;
border-radius: 4px; box-sizing: border-box; outline: none;
}
.mini-input { width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee; border-radius: 4px; box-sizing: border-box; outline: none; }
.list { max-height: 200px; overflow-y: auto; }
.item {
display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px;
}
.item { display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px; }
.item:hover { background: #f5f5f5; }
:deep(.avatar) { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; }
:deep(.avatar) { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; flex-shrink: 0; }
.name { flex: 1; font-size: 14px; }
.empty-hint { text-align: center; padding: 20px; color: #999; font-size: 13px; }
footer { padding: 12px; }
.btn {
width: 100%; padding: 10px; background: #07c160; color: white;
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
}
.btn { width: 100%; padding: 10px; background: #07c160; color: white; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; }
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
</style>
@@ -4,29 +4,44 @@
v-for="group in groups"
:key="group.id"
class="group-item"
:class="{ active: activeId === group.id }"
@click="activeId = group.id; $emit('select', group)"
:class="{ active: activeGroupId === group.id }"
@click="routeGroupInfo(group)"
>
<img :src="group.avatar" class="group-avatar" />
<span class="group-name">{{ group.name }}</span>
</div>
<div v-if="!groups || groups.length === 0" class="empty-placeholder">
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
<p class="empty-text">暂无群聊</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import feather from 'feather-icons';
defineProps({
groups: {
type: Array,
default: () => []
// 数据结构仅需: { id, name, avatar }
// 数据结构: { id, name, avatar }
}
});
const emit = defineEmits(['select']);
const activeId = ref(null);
const router = useRouter();
const route = useRoute();
const activeGroupId = computed(() => route.params.id);
const routeGroupInfo = (group) => {
emit('select', group);
router.push(`/contacts/group/${group.id}`);
};
</script>
<style scoped>
@@ -68,6 +83,16 @@ const activeId = ref(null);
background-color: #f0f0f0;
}
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 20px;
color: #bbb;
}
.empty-placeholder i { color: #ccc; margin-bottom: 10px; line-height: 0; }
.empty-text { font-size: 13px; color: #999; margin: 0; }
.group-name {
flex: 1;
font-size: 14px;
@@ -1,8 +1,7 @@
<template>
<transition name="slide">
<aside class="group-info-sidebar">
<aside class="group-info-sidebar">
<div class="sidebar-scroll-content">
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card header-section">
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card header-section">
<div class="avatar-wrapper">
<input type="file" style="display: none;" ref="input" @change="fileUploadHandler">
<img :src="groupData.targetAvatar" class="group-main-avatar" @click="uploadGroupAvatar"/>
@@ -13,20 +12,24 @@
<p class="group-id">群ID: {{ groupData.targetId }}</p>
</section>
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
<div class="section-header">
<h3 class="section-label">群公告</h3>
<button v-if="isAdmin" class="text-link">编辑</button>
<button v-if="isAdmin" class="text-link" @click="editingAnnouncement = !editingAnnouncement">{{ editingAnnouncement ? '完成' : '编辑' }}</button>
</div>
<div class="announcement-box">
{{ groupInfo ? groupInfo.announcement : '暂无群公告,点击编辑添加。' }}
<div v-if="editingAnnouncement" class="announcement-edit">
<textarea v-model="announcementText" class="announce-input" rows="3" placeholder="输入群公告..."></textarea>
<button class="save-announce-btn" @click="saveAnnouncement">保存公告</button>
</div>
<div v-else class="announcement-box">
{{ groupInfo?.announcement || '暂无群公告,点击编辑添加。' }}
</div>
</section>
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
<div class="section-header">
<h3 class="section-label">群成员 <span class="count-tag">{{ 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 class="member-grid">
@@ -38,15 +41,16 @@
</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"
class="member-item"
@click="handleMemberClick(member)"
>
<div class="member-avatar-box">
<async-image :raw-url="member.avatar" class="member-img"/>
<span v-if="member.role === GROUP_MEMBER_ROLE.ADMIN || member.role === GROUP_MEMBER_ROLE.MASTER" class="role-badge"></span>
</div>
<span class="member-nick">{{ member.nickname }}</span>
<span class="member-nick">{{ member.nickname || member.groupNickName }}</span>
</div>
</div>
</section>
@@ -54,43 +58,45 @@
<section class="info-card settings-list">
<div class="setting-item">
<span>置顶聊天</span>
<input type="checkbox" class="ios-switch" />
<input type="checkbox" class="ios-switch" :checked="isPinned" @change="togglePin" />
</div>
<div class="setting-item">
<span>消息免打扰</span>
<input type="checkbox" class="ios-switch" />
<input type="checkbox" class="ios-switch" :checked="isMuted" @change="toggleMute" />
</div>
<div class="setting-item arrow">
<div class="setting-item arrow" @click="$emit('searchInChat')">
<span>查找聊天记录</span>
</div>
</section>
<div class="danger-zone">
<button class="danger-btn">删除并退出</button>
<div v-if="chatType == CHAT_TYPE.GROUP" class="danger-zone">
<button class="danger-btn" @click="handleExitGroup">{{ isMaster ? '解散群组' : '删除并退出' }}</button>
</div>
</div>
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友" @submit="inviteUserHandler"/>
</aside>
</transition>
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友"
:excludeIds="groupMemberIds" @submit="inviteUserHandler"/>
</aside>
</template>
<script setup>
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 { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
import { uploadService } from '../../services/upload/uploadService';
import { groupService } from '../../services/group';
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
import { useMessage } from './useAlert';
import { getFileHash } from '../../utils/uploadTools';
import CreateGroup from '../groups/CreateGroup.vue';
import AsyncImage from '../AsyncImage.vue';
import { useAuthStore } from '../../stores/auth';
import { useConversationStore } from '../../stores/conversation';
import { useGroupStore } from '../../stores/group';
const props = defineProps({
chatType: {
type: String,
default: MESSAGE_TYPE.GROUP
default: CHAT_TYPE.GROUP
},
groupData: {
type: Object,
@@ -103,24 +109,105 @@ const props = defineProps({
id: i,
nickname: `成员 ${i + 1}`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
role: i === 0 ? 'admin' : 'member'
role: i === 0 ? 'Master' : 'Normal'
}))
})
},
currentUserId: [String, Number]
});
const input = useTemplateRef('input')
const message = useMessage();
const conversationStore = useConversationStore();
const groupStore = useGroupStore();
const groupInfo = ref({
members: []
})
const groupInviteModal = ref(false)
const groupMemberIds = computed(() => (groupInfo.value?.members || []).map(m => m.userId))
const editingAnnouncement = ref(false)
const announcementText = ref('')
const showAllMembers = ref(false)
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 = () => {
input.value.click()
@@ -128,9 +215,9 @@ const uploadGroupAvatar = () => {
const fileUploadHandler = async (e) => {
const file = e.target.files[0];
const hash = await getFileHash(file)
const { data } = await uploadService.uploadSmallFile(file, hash);
const res = await groupService.updateGroupInfo(props.groupData.targetId, {
const { data } = await uploadService.uploadSmallFile(file, true);
const res = await groupService.updateGroupInfo({
groupId: props.groupData.targetId,
avatar: data.url
})
@@ -140,31 +227,43 @@ const fileUploadHandler = async (e) => {
message.error(res.message)
}
}
const inviteHandler = () => {
const inviteHandler = async () => {
// 打开弹窗前确保成员数据已加载
try {
const mRes = await groupService.getGroupMember(props.groupData.targetId)
if (mRes.code === SYSTEM_BASE_STATUS.SUCCESS && mRes.data) {
groupInfo.value.members = mRes.data
}
} catch (error) {
console.error('加载群成员失败:', error)
}
groupInviteModal.value = true
}
const inviteUserHandler = async (selectedUsers) => {
const res = await groupService.inviteUser(props.groupData.targetId, [...selectedUsers])
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
message.success('成功')
const userIds = [...selectedUsers];
let allSuccess = true;
for (const userId of userIds) {
const res = await groupService.inviteUser(props.groupData.targetId, userId);
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) allSuccess = false;
}
if (allSuccess) {
message.success('邀请成功')
} else {
message.error('部分邀请发送失败')
}
}
// 判断当前用户是否为管理员
const isAdmin = computed(() => {
// 逻辑:在 members 数组中找到当前用户并检查 role
return true; // 演示用,默认 true
});
watch(
() => props.groupData.id,
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.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 }
)
@@ -444,6 +543,11 @@ onMounted(async () => {
.ios-switch:checked::before { transform: translateX(18px); }
/* 公告编辑 */
.announcement-edit { display: flex; flex-direction: column; gap: 8px; }
.announce-input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; resize: vertical; box-sizing: border-box; }
.save-announce-btn { align-self: flex-end; padding: 6px 16px; background: #007aff; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
/* 动画 */
.slide-enter-active, .slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.slide-enter-from, .slide-leave-to { transform: translateX(100%); opacity: 0.5; }
@@ -17,9 +17,9 @@ const props = defineProps({
}
});
let player = new Player({
new Player({
id: 'Video',
url: props.m.content.url,
url: props.m.url || (props.m.content?.body?.url) || '',
controlPlugins: [
volume,
playbackRate
@@ -2,8 +2,9 @@
import { ref, reactive } from 'vue';
import { friendService } from '@/services/friend';
import { useMessage } from '../messages/useAlert';
import AsyncImage from '../AsyncImage.vue';
const props = defineProps({ modelValue: Boolean });
defineProps({ modelValue: Boolean });
const emit = defineEmits(['update:modelValue', 'success']);
const message = useMessage();
@@ -43,7 +44,7 @@ const onSearch = async () => {
const submitAdd = async () => {
submitting.value = true;
const res = await friendService.requestFriend({
toUserId: userResult.value.id,
targetId: userResult.value.id,
remarkName: form.remark,
description: form.description
});
@@ -74,10 +75,10 @@ const submitAdd = async () => {
</div>
<div v-if="userResult" class="result-card">
<img :src="userResult.avatar" class="mini-avatar" />
<AsyncImage :raw-url="userResult.avatar" class="mini-avatar" />
<div class="info">
<div class="name">{{ userResult.nickName }}</div>
<div class="id">ID: {{ userResult.username }}</div>
<div class="id">ID: {{ userResult.userName }}</div>
</div>
<button class="next-btn" @click="step = 2">添加</button>
</div>
@@ -146,7 +147,7 @@ main { padding: 12px; }
margin-top: 12px; display: flex; align-items: center;
padding: 10px; background: #f9f9f9; border-radius: 8px;
}
.mini-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; }
:deep(.mini-avatar) { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; flex-shrink: 0; }
.info { flex: 1; }
.name { font-size: 14px; font-weight: bold; color: #333; }
.id { font-size: 11px; color: #999; }
@@ -3,25 +3,24 @@
<transition name="fade">
<div
v-if="isVisible"
ref="cardRef"
class="im-hover-card"
:style="cardStyle"
@mouseenter="clearTimer"
@mouseleave="hide"
>
<div class="card-inner">
<div class="user-profile">
<div class="info-text">
<h4 class="nickname">{{ currentUser.name }}</h4>
<p class="detail-item">
<span class="label">微信号</span>
<span class="value">{{ currentUser.id }}</span>
<span class="label">用户名</span>
<span class="value">{{ currentUser.userName || currentUser.name || currentUser.id }}</span>
</p>
<p class="detail-item">
<span class="label"> </span>
<span class="label">地区</span>
<span class="value">{{ currentUser.region || '未知' }}</span>
</p>
</div>
<img :src="currentUser.avatar" class="avatar-square" />
<AsyncImage :raw-url="currentUser.avatar" class="avatar-square" />
</div>
<div class="user-bio">
@@ -39,7 +38,8 @@
</template>
<script setup>
import { ref, reactive } from 'vue';
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import AsyncImage from '../AsyncImage.vue';
const isVisible = ref(false);
const currentUser = ref({});
@@ -48,32 +48,32 @@ const cardStyle = reactive({
top: '0px',
left: '0px'
});
const cardRef = ref(null);
let timer = null;
const hide = () => {
isVisible.value = false;
};
const onDocumentClick = (e) => {
if (!isVisible.value) return;
// 点击卡片内部不关闭
if (cardRef.value && cardRef.value.contains(e.target)) return;
hide();
};
onMounted(() => document.addEventListener('click', onDocumentClick, true));
onUnmounted(() => document.removeEventListener('click', onDocumentClick, true));
const show = (el, data) => {
clearTimer();
currentUser.value = data;
const rect = el.getBoundingClientRect();
// IM 习惯:通常在头像右侧或下方弹出
// 这里设置为在头像中心水平对齐,下方弹出
cardStyle.top = `${rect.bottom + 8}px`;
cardStyle.left = `${rect.left}px`;
isVisible.value = true;
};
const hide = () => {
timer = setTimeout(() => {
isVisible.value = false;
}, 300);
};
const clearTimer = () => {
if (timer) clearTimeout(timer);
};
const onAdd = () => {
console.log('申请添加好友:', currentUser.value.id);
// 这里写你的逻辑
@@ -133,12 +133,7 @@ defineExpose({ show, hide });
color: #555;
}
.avatar-square {
width: 60px;
height: 60px;
border-radius: 4px;
object-fit: cover;
}
:deep(.avatar-square) { width: 60px; height: 60px; border-radius: 4px; flex-shrink: 0; }
/* 签名区 */
.user-bio {
@@ -5,20 +5,23 @@ export const GROUP_MEMBER_ROLE = Object.freeze({
})
/**
* 群请求状态枚举 (对应后端 String 输出)
* 群入群请求状态 (对应后端 string)
* State: "Pending" 待管理员同意, "Declined" 已拒绝, "Passed" 已通过
*/
export const GROUP_REQUEST_STATUS = Object.freeze({
/** 待管理员处理 */
PENDING: 'Pending',
/** 管理员已拒绝 */
DECLINED: 'Declined',
/** 管理员已同意 */
PASSED: 'Passed',
/** 待对方同意 */
TARGET_PENDING: 'TargetPending',
/** 对方拒绝 */
TARGET_DECLINED: 'TargetDeclined',
TARGET_PASSED: 'TargetPassed'
})
/**
* 群邀请状态 (对应后端 string)
* State: "Pending" 待被邀请人同意, "Passed" 已同意, "Reject" 拒绝
*/
export const GROUP_INVITATION_STATUS = Object.freeze({
PENDING: 'Pending',
ACCEPTED: 'Passed',
REJECTED: 'Reject',
})
export const GROUP_REQUEST_ACTION = Object.freeze({
@@ -35,14 +38,7 @@ export const getGroupRequestStatusTxt = (status) => {
return '管理员已拒绝';
case GROUP_REQUEST_STATUS.PASSED:
return '管理员已同意';
case GROUP_REQUEST_STATUS.TARGET_PENDING:
return '待对方同意';
case GROUP_REQUEST_STATUS.TARGET_DECLINED:
return '对方拒绝';
case GROUP_REQUEST_STATUS.TARGET_PASSED:
return '对方同意';
default:
return '未知状态';
}
}
@@ -1,3 +1,23 @@
/** 会话类型 (对应后端 ChatType string) */
export const CHAT_TYPE = Object.freeze({
PRIVATE: 'PRIVATE',
GROUP: 'GROUP'
})
/** 消息类型 (对应后端 MsgType string) */
export const MSG_TYPE = Object.freeze({
Text: 'Text',
Image: 'Image',
Voice: 'Voice',
Video: 'Video',
File: 'File',
VoiceCall: 'VoiceChat',
VideoCall: 'VideoChat'
})
/**
* @deprecated 请使用 CHAT_TYPE
*/
export const MESSAGE_TYPE = Object.freeze({
PRIVATE: 'PRIVATE',
GROUP: 'GROUP'
@@ -1,37 +1,16 @@
import { MSG_TYPE } from './MessageType'
export const getMessageType = (fileType) => {
if (!fileType) return FILE_TYPE.File; // 兜底处理
if (!fileType) return MSG_TYPE.File;
// 处理图片
if (fileType.startsWith('image/')) {
return FILE_TYPE.Image;
}
if (fileType.startsWith('image/')) return MSG_TYPE.Image;
if (fileType.startsWith('audio/')) return MSG_TYPE.Voice;
if (fileType.startsWith('video/')) return MSG_TYPE.Video;
// 处理音频(录音消息)
if (fileType.startsWith('audio/')) {
return FILE_TYPE.Voice;
}
// 处理视频
if (fileType.startsWith('video/')) {
return FILE_TYPE.Video;
}
// 常见文档类型的特殊处理(可选)
const documentTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
];
if (documentTypes.includes(fileType)) {
return FILE_TYPE.File;
}
// 其他所有情况统一归类为文件
return FILE_TYPE.File;
return MSG_TYPE.File;
};
/** @deprecated 请使用 MSG_TYPE。保留仅为 AsyncImage/cache 等通用缓存层兼容。 */
export const FILE_TYPE = Object.freeze({
Image: 'Image',
Video: 'Video',
@@ -1,17 +1,19 @@
export const FRIEND_ACTIONS = Object.freeze({
/**接受 */
Accept: 'Accept',
/**同意 */
Accept: 'Accpet',
/**拒绝 */
Reject: 'Reject'
Reject: 'Reject',
/**拉黑 */
Block: 'Block'
});
export const FRIEND_REQUEST_STATUS = Object.freeze({
/**待处理 */
/**待通过 */
Pending: 'Pending',
/**通过 */
Passed: 'Passed',
/**已拒绝 */
Declined: 'Declined',
/**已同意 */
Passed: 'Passed',
/**已拉黑 */
Blocked: 'Blocked'
})
@@ -1,31 +1,50 @@
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 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
if (msg.chatType === MESSAGE_TYPE.GROUP) {
return x.chatType === MESSAGE_TYPE.GROUP &&
x.targetId === msg.receiverId;
let conversation = conversationStore.conversations.find(x => {
if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
(x.targetId === msg.senderId || x.targetId === msg.targetId);
}
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; // 容错处理:如果没找到会话,不执行后续逻辑
conversation.lastMessage = msg.content;
if (conversation.targetId == msg.receiverId) {
conversation.unreadCount = 0;
} else {
conversation.unreadCount += 1;
// 会话不存在 → 从服务器拉取
if (!conversation) {
await conversationStore.fetchConversationsFromServier();
conversation = conversationStore.conversations.find(x => {
if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
(x.targetId === msg.senderId || x.targetId === msg.targetId);
}
if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
x.targetId === msg.targetId;
}
return false;
});
}
conversation.dateTime = new Date().toISOString();
if (!conversation) return;
const contentText = typeof msg.content === 'object'
? (msg.content?.body?.text || msg.content?.fallback || '')
: msg.content;
conversation.lastMessage = contentText;
// 不是我发的消息 → 未读+1
const isFromMe = (conversation.userId && conversation.userId === msg.senderId);
if (!isFromMe) {
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
}
conversation.dateTime = msg.pushTimestamp
? new Date(msg.pushTimestamp).toISOString()
: (msg.creationTime || new Date().toISOString());
}
@@ -54,6 +54,12 @@ const routes = [
component: () => import('@/views/contact/UserInfoContent.vue'),
props: true
},
{
path: '/contacts/group/:id',
name: 'groupInfo',
component: () => import('@/views/contact/GroupInfoContent.vue'),
props: true
},
{
path: '/contacts/requests',
name: 'friendRequests',
+101 -51
View File
@@ -1,4 +1,5 @@
import axios from 'axios'
import { ref } from 'vue'
import { useMessage } from '@/components/messages/useAlert'
import router from '@/router'
import { useAuthStore } from '@/stores/auth'
@@ -10,13 +11,29 @@ let waitqueue = []
let isRefreshing = false
const authURL = ['/auth/login', '/auth/register', '/auth/refresh']
// 全局网络状态:null=正常,string=错误消息
export const networkError = ref(null)
let recoveryTimer = null
const setNetworkError = (msg) => {
networkError.value = msg
// 10 秒后如果没恢复,自动清除(下次请求成功也会清除)
clearTimeout(recoveryTimer)
recoveryTimer = setTimeout(() => { networkError.value = null }, 10000)
}
const clearNetworkError = () => {
networkError.value = null
clearTimeout(recoveryTimer)
}
const pushLoginElectron = () => {
window.api.window.close()
window.api.window.newWindow('/auth/login', null, 420, 540)
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8009/api'),
timeout: 10000,
headers: {}
})
@@ -35,65 +52,98 @@ api.interceptors.request.use(
}
)
const redirectToLogin = () => {
const authStore = useAuthStore()
authStore.logout()
message.error('登录已失效,请重新登录。')
if (window.api?.window) pushLoginElectron()
else router.push('/auth/login')
}
const rejectWaitingRequests = (error) => {
waitqueue.forEach(({ reject }) => reject(error))
waitqueue = []
}
const refreshAndRetry = async (config, originalError) => {
const authStore = useAuthStore()
if (!config || authURL.some((x) => config.url?.includes(x)) || config._retry) {
redirectToLogin()
return Promise.reject(originalError)
}
config._retry = true
if (isRefreshing) {
return new Promise((resolve, reject) => {
waitqueue.push({
resolve: (token) => {
config.headers.Authorization = `Bearer ${token}`
resolve(api(config))
},
reject,
})
})
}
const refreshToken = authStore.refreshToken
if (!refreshToken) {
redirectToLogin()
return Promise.reject(originalError)
}
isRefreshing = true
try {
const res = await authService.refresh(refreshToken)
if (res.code !== 0 || !res.data?.token) throw new Error(res.message || '刷新登录状态失败')
authStore.setLoginInfo(res.data)
waitqueue.forEach(({ resolve }) => resolve(authStore.token))
waitqueue = []
config.headers.Authorization = `Bearer ${authStore.token}`
return api(config)
} catch (error) {
rejectWaitingRequests(error)
redirectToLogin()
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
api.interceptors.response.use(
(response) => {
// 任何成功的响应都清除网络错误状态
clearNetworkError()
// 滚动发布期间兼容旧后端的 HTTP 200 + code=1006。
if (response.data?.code === 1006) {
return refreshAndRetry(response.config, response.data)
}
return response.data
},
async (err) => {
const authStore = useAuthStore()
const { config, response } = err
if (response) {
switch (response.status) {
case 401:
if (authURL.some((x) => config.url.includes(x))) {
authStore.logout()
message.error('未登录,请登录后操作。')
router.push('/auth/login')
break
}
if (config._retry) {
break
}
config._retry = true
// 已经在刷新 → 排队
if (isRefreshing) {
return new Promise((resolve) => {
waitqueue.push((token) => {
config.headers.Authorization = `Bearer ${token}`
resolve(api(config))
})
})
}
isRefreshing = true
const refreshToken = authStore.refreshToken
if (refreshToken != null && refreshToken != '') {
const res = await authService.refresh(refreshToken)
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
waitqueue.forEach((cb) => cb(authStore.token))
waitqueue = []
config.headers.Authorization = `Bearer ${authStore.token}`
return api(config)
}
authStore.logout()
message.error('未登录,请登录后操作。')
router.push('/auth/login')
break
case 400:
if (response.data && response.data.code == 1003) {
message.error(response.data.message)
break
}
default:
message.error('请求错误,请检查网络。')
break
}
return Promise.reject(err)
} else {
message.error('请求错误,请检查网络。')
// 无响应 → 网络不通
if (!response) {
setNetworkError('网络连接异常')
return Promise.reject(err)
}
switch (response.status) {
case 401:
return refreshAndRetry(config, err)
case 400:
if (response.data && response.data.code == 1003) {
message.error(response.data.message)
}
break
default:
// 其他 HTTP 错误用 toast(用户主动操作触发,需要即时反馈)
message.error(response.data?.message || `请求错误 (${response.status})`)
break
}
return Promise.reject(err)
}
)
@@ -3,21 +3,22 @@ import { request } from "./api";
export const authService = {
/**
* 用户登录接口
* @param {*} data
* @returns
* @param {{ userName, password }} data
* @returns Result<LoginResponse>
*/
login: (data) => request.post('/auth/login', data),
/**
* 用户注册
* @param {*} data
* @returns
* @param {{ userName, password, nickName }} data
* @returns Result<UserResponse>
*/
register: (data) => request.post('/auth/register', data),
/**
* 刷新用户凭证
* @param {*} data
* @returns
* @param {string} refreshToken
* @returns Result<LoginResponse>
*/
refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken })
}
@@ -1,44 +1,68 @@
import { request } from "./api";
import { FRIEND_ACTIONS } from "@/constants/friendAction";
export const friendService = {
/**
* 获取好友列表
* @returns Result<List<FriendResponse>>
*/
getFriendList: () => request.get('/friend/list'),
/**
* 获取好友列表
* @param {*} page 当前页
* @param {*} limit 页大小
* @returns
*/
getFriendList: (page = 1, limit = 100) => request.get(`/friend/list?page=${page}&limit=${limit}`),
/**
* 搜索好友
* @param {*} username
* @returns
*/
/**
* 删除好友
* @param {string} friendId
* @returns Result<boolean>
*/
deleteFriend: (friendId) => request.post(`/friend/delete?friendId=${friendId}`),
findUser: (username) => request.get(`/user/findbyusername?username=${username}`),
/**
* 申请添加好友
* @param {*} params
* @returns
*/
requestFriend: (params) => request.post('/friend/request', params),
/**
* 获取好友请求列表
* @param {*} page
* @param {*} limit
* @returns
*/
getFriendRequests: (page = 1, limit = 100) => request.get(`/friend/requests?page=${page}&limit=${limit}`),
/**
* 拉黑好友
* @param {string} friendId
* @returns Result<boolean>
*/
blockFriend: (friendId) => request.post(`/friend/block?friendId=${friendId}`),
/**
* 处理好友请求
* @param {*} friendRequestId
* @param {typeof FRIEND_ACTIONS[keyof typeof FRIEND_ACTIONS]} action
* @returns
*/
handleFriendRequest: (friendRequestId, action, remarkname) => request.post(`/Friend/HandleRequest?id=${friendRequestId}`, {
remarkName: remarkname,
action: action
})
/**
* 检查好友关系
* @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,
}),
/**
* 获取好友请求列表
* @returns Result<List<FriendRequestResponse>>
*/
getFriendRequests: () => request.get('/friendRequest/list'),
/**
* 处理好友请求
* @param {string} requestId 请求ID
* @param {number} action 0=同意, 1=拒绝, 2=拉黑
* @param {string} remarkName 备注名(同意时必填)
* @returns Result<boolean>
*/
handleFriendRequest: (requestId, action, remarkName) => request.post('/friendRequest/handle', {
requestId,
action,
remarkName,
}),
}
+105 -52
View File
@@ -1,62 +1,115 @@
import { request } from "./api"
import { request } from "./api";
export const groupService = {
/**
* 创建群聊
* @param {*} data
* @returns
*/
createGroup: (data) => request.post('/Group/CreateGroup', data),
/**
* 查询群组成员
* @param {*} groupId
* @returns
*/
getGroupMember: (groupId) => request.get(`/Group/GetGroupMembers?groupId=${groupId}`),
/**
* 更新群组信息
* @param {*} groupId
* @param {*} params
* @returns
*/
updateGroupInfo: (groupId, params) => request.post(`/Group/UpdateGroup?groupId=${groupId}`, params),
/**
* 查询群组信息
* @param {*} groupId
* @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 Result<List<GroupResponse>>
*/
getAllGroups: () => request.get('/group/getAll'),
/**
* 获取群聊通知
* @returns
* 群详情
* @param {string} groupId
* @returns Result<GroupResponse>
*/
getGroupNotification: () => request.get('/Group/GetGroupNotification'),
getGroupInfo: (groupId) => request.get(`/group/getOne?groupId=${groupId}`),
/**
* 处理入群邀请
* @param {*} inviteId
* @param {*} action
* @returns
* 创建群聊
* @param {{ name }} data
* @returns Result<GroupResponse>
*/
handleGroupInvite: (inviteId, action) =>
request.post('/Group/HandleGroupInvite', { inviteId: inviteId, action: action }),
createGroup: (data) => request.post('/group/create', { name: data.name }),
/**
* 处理入群请求
* @param {*} requestId
* @param {*} action
* @returns
* 更新群组信息
* @param {{ groupId, groupName?, avatar?, description? }} params
* @returns Result<GroupResponse>
*/
handleGroupRequest: (requestId, action) =>
request.post('/Group/HandleGroupRequest', { requestId: requestId, action: action })
updateGroupInfo: (params) => request.post('/group/update', {
groupId: params.groupId,
groupName: params.groupName,
avatar: params.avatar,
description: params.description,
}),
/**
* 查询群成员列表
* @param {string} groupId
* @returns Result<List<GroupMemberResponse>>
*/
getGroupMember: (groupId) => request.get(`/groupMember/list?groupId=${groupId}`),
/**
* 检查群成员
* @param {string} userId
* @param {string} groupId
* @returns Result<boolean>
*/
checkMember: (userId, groupId) => request.get(`/groupMember/checkMember?userId=${userId}&groupId=${groupId}`),
/**
* 移除群成员
* @param {string} memberId
* @returns Result<boolean>
*/
deleteMember: (memberId) => request.post(`/groupMember/delete?memberId=${memberId}`),
/** 当前成员主动退群。 */
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'),
}
@@ -1,42 +1,50 @@
import { request } from "./api";
export const messageService = {
/**
* 获取当前用户会话
* @returns
*/
getConversations: () => request.get('/conversation/list'),
/**
* 获取当前用户会话列表
* @returns Result<List<ConversationResponse>>
*/
getConversations: () => request.get('/conversation/list'),
/**
* 清空所有会话
* @returns
*/
clearConversation: () => request.post(''),
/**
* 获取单个会话信息
* @param {*} conversationId
* @returns
*/
getConversationById: (conversationId) => request.get(`/conversation/get?conversationId=${conversationId}`),
/**
* 获取历史消息列表
* @param {*} conversationId 指定会话
* @param {*} msgId
* @param {*} pageSize
* @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 单次查询消息数
* @returns
*/
getMessages: (conversationId, cursor, direction, limit) => request.get(
`/message/getmessageList?conversationId=${conversationId}${cursor ? '&cursor=' + cursor : ''}&direction=${direction}&limit=${limit}`
/**
* 获取单个会话
* @param {string} id 会话ID
* @returns Result<ConversationResponse>
*/
getConversationById: (id) => request.get(`/conversation/get?id=${id}`),
/**
* 获取消息列表
* @param {string} conversationId 会话ID
* @param {number|null} cursor 游标 (sequenceId),查最新消息传 null
* @param {number} direction 0=查历史消息, 1=查锚点后的消息
* @param {number} limit 单次查询消息数
* @returns Result<{ messages: MessageResponse[], hasmore: boolean }>
*/
getMessages: (conversationId, cursor, direction, 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 axios from 'axios';
export const uploadService = {
/**
* 创建文件上传任务
* @param {*} fileName 文件
* @param {*} fileSize 文件大小
* @param {*} contentType 文件类型
* @param {*} fileHash 文件哈希
* @returns
* 小文件直传(头像/封面等),后端自动秒传
* @param {File} file 文件对象
* @param {boolean} isPublic true 落公开桶返回直链
* @returns Result<FileResponse>
*/
createUploadTask: (fileName, fileSize, contentType, fileHash) => request.post('/Upload/CreateTask', {
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) => {
uploadSmallFile: (file, isPublic = true) => {
const formData = new FormData()
formData.append('file', file)
return request.post(
uploadUrl,
formData,
{
baseURL: '',
headers, // 不要包含 Content-Type
onUploadProgress: e => {
if (onProgress && e.total) {
onProgress(e.loaded / e.total)
}
}
}
)
formData.append('isPublic', isPublic)
return request.post('/file/simple-upload', formData)
},
uploadSmallFile: (file, hash) => {
/**
* 初始化分片上传任务
* @param {{ conversationId, chatType, targetId, fileName, fileSize, contentType, checkSum }} params
* @returns Result<{ taskId, uploadSessionId, storageLocation }>
*/
initUploadTask: (params) => request.post('/fileTask/init', {
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(`/Upload/upload/${hash}`, formData);
}
return request.post('/fileTask/local/parts/upload', formData, {
onUploadProgress: (e) => {
if (onProgress && e.total) {
onProgress(e.loaded / e.total)
}
},
})
},
/**
* 分片上传到预签名 URL (storageLocation=1 时)
* @param {{url:string, method:string, headers:Object}} presigned 预签名请求描述
* @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 { uploadService } from "./uploadService";
import { getFileHash, sliceFile } from "@/utils/uploadTools";
import { request } from "../api";
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, {
onProgress,
onPartComplete
onPartComplete,
conversationId,
chatType,
targetId,
} = {}) => {
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 = {
status: UPLOAD_STATUS.COMPLETE,
progress: 100,
taskId: taskId,
url: url
}
onProgress?.(uploadStatus)
return;
taskId,
fileId: initRes.data.file.id,
url: initRes.data.file.url || null,
file: initRes.data.file,
};
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 () => {
const partNum = index + 1;
const { skip, method, url, headers, partNumber } = (await uploadService.createPartTask(taskId, partNum)).data;
if (!skip) {
const { data } = await uploadService.uploadPart(url, headers, chunks[index], p => {
const partNumber = index + 1;
if (uploadMode === 'Presigned') {
// 预签名 URL 直传(非本地存储)
const urlRes = await uploadService.getUploadUrl(uploadSessionId, partNumber);
const result = await uploadService.uploadPartToUrl(urlRes.data, chunk, (p) => {
chunkProgress[index] = p;
// 第三步:计算总进度
// 把账本上所有的百分比加起来
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)
reportProgress(chunkProgress, chunks.length, taskId, onProgress);
});
onPartComplete?.(partNum)
return data;
parts.push({ partNumber, eTag: result?.eTag || '', size: chunk.size });
} 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);
const evtSource = new EventSource(`${request.instance.defaults.baseURL}/upload/events/${taskId}`);
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
const uploadStatus = {
status: data.progress == 100 ? UPLOAD_STATUS.COMPLETE : UPLOAD_STATUS.MERGING,
taskId: taskId,
progress: data.progress,
url: data.url
// Step 4: 等待异步合并落库,最终结果以 status 为准。
const MAX_POLL_ATTEMPTS = 120;
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
const statusRes = await uploadService.getTaskStatus(taskId);
const task = statusRes.data;
if (task.state === 'Failed') {
throw new Error(task.failureReason || '文件合并失败');
}
onProgress?.(uploadStatus)
if (data.status === "Completed") {
evtSource.close();
if (task.state === 'Completed' && task.file) {
const uploadStatus = {
status: UPLOAD_STATUS.COMPLETE,
progress: 100,
taskId,
fileId: task.file.id,
url: task.file.url || null,
file: task.file,
};
onProgress?.(uploadStatus);
return task.file;
}
};
await sleep(1000);
}
throw new Error('等待文件合并超时');
};
evtSource.onerror = (err) => {
console.error("SSE 连接异常", err);
};
function reportProgress(chunkProgress, total, taskId, onProgress) {
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 results = [];
const executing = [];
for (const task of tasks) {
const retryTask = async (task) => {
const retryTask = async (t) => {
let attempt = 0;
while (attempt <= maxRetries) {
try {
return await task();
return await t();
} catch (e) {
attempt++;
if (attempt > maxRetries) {
throw e;
}
if (attempt > maxRetries) throw e;
}
}
}
};
const p = Promise.resolve().then(() => retryTask(task));
results.push(p);
if (limit <= tasks.length) {
const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
if (executing.length >= limit) await Promise.race(executing);
}
}
return Promise.allSettled(results);
}
return Promise.all(results);
};
@@ -1,6 +1,37 @@
import { request } from "./api";
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 { 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', () => {
const token = ref(localStorage.getItem('user_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);
@@ -15,15 +25,27 @@ export const useAuthStore = defineStore('auth', () => {
try {
const base64Url = t.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// 处理 Unicode 字符解码
return JSON.parse(decodeURIComponent(atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join('')));
} catch (e) {
} catch {
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 是否过期
const isTokenExpired = computed(() => {
if (!token.value) return true;
@@ -36,20 +58,25 @@ export const useAuthStore = defineStore('auth', () => {
/**
* 登录成功保存状态
* @param {String} newToken 用户凭证
* @param {*} user 用户信息
* LoginResponse 扁平化:
* { userId, token, refreshToken, expired, userName, nickName, avatar, creationTime }
* @param {Object} loginData res.data (LoginResponse)
*/
function setLoginInfo(newToken, newRefreshToken, user) {
console.log(`设置凭证:\ntoken${newToken}\nrefreshToken:${newRefreshToken}`)
token.value = newToken;
refreshToken.value = newRefreshToken
function setLoginInfo(loginData) {
if (!loginData || typeof loginData !== 'object') return;
const { token: t, refreshToken: rt, userId, userName, nickName, avatar, creationTime } = loginData;
token.value = t;
refreshToken.value = rt;
const user = { id: userId, userName, nickName, avatar, creationTime };
userInfo.value = user;
localStorage.setItem('user_token', newToken);
localStorage.setItem('refresh_token', newRefreshToken)
localStorage.setItem('user_info', JSON.stringify(user))
localStorage.setItem('user_token', t);
localStorage.setItem('refresh_token', rt);
localStorage.setItem('user_info', JSON.stringify(user));
};
function setUserInfo(user){
// 防御:避免把 user_info 写成字符串 "undefined"
if (!user || typeof user !== 'object') return;
userInfo.value = user;
localStorage.setItem('user_info',JSON.stringify(user));
}
+57 -25
View File
@@ -9,25 +9,63 @@ export const useChatStore = defineStore('chat', {
maxSequenceId: null,
isEnded: false,
messages: [],
pageSize: 20
pageSize: 20,
// 本地删除的消息 ID 集合(refresh 后从服务器还会拉回来,需要过滤)
deletedMsgKeys: new Set(JSON.parse(localStorage.getItem('deleted_msg_keys') || '[]'))
}),
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) {
const filtered = this._filterDeleted(newMsgs).map((m) => this._normalizeMessage(m))
if (shouldSaveToDb) {
for (const m of newMsgs) {
if (m.type != 'Text' && !m.isLoading && !m.isError && !m.isImgLoading) {
m.content = JSON.parse(m.content);
}
await messagesDb.save({ ...m, sessionId });
for (const m of filtered) {
await messagesDb.save({
...m,
msgId: m.msgId || m.clientMsgId || m.id,
sessionId
});
}
}
if (sessionId == this.activeSessionId) {
const combined = [...this.messages, ...newMsgs];
// 1. 根据 msgId 或唯一 key 去重
const combined = [...this.messages, ...filtered];
// 根据 msgId / clientMsgId / id 去重,降级 sequenceId
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升序排序 (旧的在前,新的在后)
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
@@ -48,8 +86,8 @@ export const useChatStore = defineStore('chat', {
this.activeConversationId = conversationId;
this.messages = [];
this.isEnded = false;
//先从浏览器缓存加载一部分消息列表
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize);
const localHistory = this._filterDeleted(await messagesDb.getLatestMessages(sessionId, this.pageSize))
.map((m) => this._normalizeMessage(m));
if (localHistory.length > 0) {
this.messages = localHistory;
this.maxSequenceId = this.messages.reduce((max, m) =>
@@ -64,12 +102,10 @@ export const useChatStore = defineStore('chat', {
* @returns
*/
async fetchNewMsgFromServier(conversationId, sequenceId) {
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data;
if (newMsg.length > 0) {
return newMsg;
} else {
return [];
}
const res = await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize);
const newMsgs = res.data?.messages || [];
// 注意:不在这里设置 isEnded,避免 direction=1(向前) 的 hasmore=false 错误地阻断历史加载
return newMsgs;
},
/**
* 从服务器加载历史消息
@@ -78,14 +114,10 @@ export const useChatStore = defineStore('chat', {
* @returns
*/
async fetchHistoryFromServer(conversationId, sequenceId) {
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data;
if (res.length > 0) {
const sessionId = this.activeSessionId;
return res;
} else {
return [];
}
const res = await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize);
const msgs = res.data?.messages || [];
this.isEnded = !(res.data?.hasmore);
return msgs;
},
/**
* 加载更多历史消息
@@ -1,9 +1,6 @@
import { defineStore } from "pinia";
import { messageService } from "@/services/message";
import { conversationDb } from "@/utils/db/conversationDB";
import { useMessage } from "@/components/messages/useAlert";
const message = useMessage();
export const useConversationStore = defineStore('conversation', {
state: () => ({
@@ -11,14 +8,21 @@ export const useConversationStore = defineStore('conversation', {
}),
// stores/conversation.js
getters: {
// 始终根据时间倒序排列
// 置顶的排在前面,其余按时间倒序
sortedConversations: (state) => {
return [...state.conversations].sort((a, b) =>
new Date(b.dateTime) - new Date(a.dateTime)
);
return [...state.conversations].sort((a, b) => {
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: {
async removeConversation(id) {
this.conversations = this.conversations.filter((item) => item.id !== id);
await conversationDb.delete(id);
},
async addConversation(conversation) {
await conversationDb.save(conversation);
this.conversations.unshift(conversation)
@@ -32,15 +36,14 @@ export const useConversationStore = defineStore('conversation', {
const covnersationsCache = await conversationDb.getAll();
if (covnersationsCache && covnersationsCache.length > 0) {
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) {
message.error('读取本地会话缓存失败...');
console.log('读取本地会话缓存失败:', e);
}
}
//await this.fetchConversationsFromServier()
await this.fetchConversationsFromServier()
},
/**
* 从服务器加载新消息
@@ -48,24 +51,16 @@ export const useConversationStore = defineStore('conversation', {
* @returns
*/
async fetchConversationsFromServier() {
const newConversations = (await messageService.getConversations()).data;
if (newConversations.length > 0) {
// 1. 将当前的本地数据转为 Map,方便通过 ID 快速查找 (O(1) 复杂度)
try {
const res = await messageService.getConversations();
const newConversations = res.data || [];
const localMap = new Map(this.conversations.map(item => [item.id, item]));
newConversations.forEach(item => {
const existingItem = localMap.get(item.id);
if (existingItem) {
// --- 局部更新 ---
// 使用 Object.assign 将新数据合并到旧对象上,保持响应式引用
Object.assign(existingItem, item);
} else {
// --- 插入新会话 ---
this.conversations.unshift(item);
}
// 同步到本地数据库
conversationDb.save(item);
});
this.conversations = newConversations.map((item) =>
Object.assign(localMap.get(item.id) || {}, item)
);
await conversationDb.replaceAll(this.conversations);
} catch (e) {
console.error('获取会话列表失败:', e);
}
}
}
@@ -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 { ref } from 'vue'
import { groupService } from '../services/group'
import { useMessage } from '../components/messages/useAlert'
import { SYSTEM_BASE_STATUS } from '../constants/systemBaseStatus'
@@ -14,6 +13,7 @@ export const useGroupRequestStore = defineStore('groupRequest', {
this.groupRequest = await groupRequestDb.getAll()
const message = useMessage()
// 新 API: 群通知页只展示入群申请列表(邀请通知走私聊消息)
const res = await groupService.getGroupNotification()
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
@@ -1,20 +1,45 @@
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', {
state: () => ({
notificationOptions: reactive(JSON.parse(localStorage.getItem('notification_options')) || {}),
generalOptions: reactive(JSON.parse(localStorage.getItem('generaN_options')) || {})
notificationOptions: safeParse(localStorage.getItem(NOTIFICATION_KEY), DEFAULT_NOTIFICATION),
generalOptions: safeParse(localStorage.getItem(GENERAL_KEY), DEFAULT_GENERAL)
}),
actions: {
setNotificationOptions(options) {
this.notificationOptions = options;
localStorage.setItem('notification_options', options)
this.notificationOptions = { ...this.notificationOptions, ...options }
localStorage.setItem(NOTIFICATION_KEY, JSON.stringify(this.notificationOptions))
},
setGeneralOptions(options){
this.generalOptions = options;
localStorage.setItem('generaN_options', options)
setGeneralOptions(options) {
this.generalOptions = { ...this.generalOptions, ...options }
localStorage.setItem(GENERAL_KEY, JSON.stringify(this.generalOptions))
}
}
})
@@ -1,13 +1,10 @@
import { defineStore } from "pinia";
import * as signalR from '@microsoft/signalr';
import { useMessage } from "@/components/messages/useAlert";
import { useAuthStore } from "./auth";
import { useChatStore } from "./chat";
import { authService } from "@/services/auth";
import { generateSessionId } from "@/utils/sessionIdTools";
import { messageHandler } from "@/handler/messageHandler";
import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler";
import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler";
import { networkError } from "@/services/api";
export const useSignalRStore = defineStore('signalr', {
state: () => ({
@@ -16,9 +13,8 @@ export const useSignalRStore = defineStore('signalr', {
}),
actions: {
async initSignalR() {
const message = useMessage()
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()
.withUrl(url,
{
@@ -26,7 +22,7 @@ export const useSignalRStore = defineStore('signalr', {
accessTokenFactory: async () => {
if (authStore.isTokenExpired) {
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;
}
@@ -37,65 +33,31 @@ export const useSignalRStore = defineStore('signalr', {
try {
await this.connection.start();
this.isConnected = true;
networkError.value = null;
signalRConnectionEventHandler();
console.log('SignalR建立通信成功!')
} catch (e) {
message.error('与服务器建立通信失败,请检查网络连接...');
} catch {
networkError.value = '消息服务连接失败,实时消息不可用';
}
},
registerHandlers() {
this.connection.on('ReceiveMessage', (msg) => {
this.connection.on('ReceiveNewMessage', (msg) => {
console.log(msg)
SignalRMessageHandler(msg)
});
this.connection.onclose(() => {
this.isConnected = false;
networkError.value = '消息服务已断开,正在重连...';
});
this.connection.onreconnected(() => {
this.isConnected = true;
networkError.value = null;
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) => {
// 考虑到时区偏差,手动构造符合 C# 要求的本地 ISO 字符串
const offset = -date.getTimezoneOffset();
const diff = offset >= 0 ? '+' : '-';
// 手动构造不带时区后缀的本地 ISO 字符串
const pad = (num) => String(num).padStart(2, '0');
return date.getFullYear() +
@@ -6,8 +6,8 @@ const CONVERSARION_STORE_NAME = 'conversations'
const CONTACT_STORE_NAME = 'contacts'
const GROUP_REQUEST_STORE_NAME = 'groupRequests'
export const dbPromise = openDB(DBNAME, 7, {
upgrade(db) {
export const dbPromise = openDB(DBNAME, 8, {
upgrade(db, oldVersion) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' })
store.createIndex('by-sessionId', 'sessionId')
@@ -26,9 +26,13 @@ export const dbPromise = openDB(DBNAME, 7, {
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)) {
const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'requestId' })
store.createIndex('by-id', 'requestId')
const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'id' })
store.createIndex('by-id', 'id')
store.createIndex('by-userid', 'userId')
store.createIndex('by-groupid', 'groupId')
}
@@ -12,6 +12,16 @@ export const conversationDb = {
async getAll() {
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() {
(await dbPromise).clear(STORE_NAME);
}
@@ -7,6 +7,6 @@ export const groupRequestDb = {
(await dbPromise).put(STORE_NAME, request)
},
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) {
if (!dateStr) return ''
const date = new Date(dateStr);
if (isNaN(date.getTime())) return ''
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 hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
@@ -78,7 +78,7 @@ export function getVideoThumbnailBlob(file, seekTime = 1) {
// 如果导出的 blob 大小极小(可能是空的),可以在这里进一步检查
resolve(blob);
}, 'image/jpeg', 0.8);
} catch (e) {
} catch {
resolveBlackThumbnail();
}
};
@@ -1,28 +1,65 @@
import { useBrowserNotification } from '@/services/useBrowserNotification'
import { useChatStore } from '@/stores/chat'
import { messageHandler } from '@/handler/messageHandler'
import { generateSessionId } from '../sessionIdTools'
import { useConversationStore } from '@/stores/conversation'
import { MESSAGE_TYPE } from '@/constants/MessageType'
import { NOTIFICATION_TYPE } from '../../constants/notificationType'
import { CHAT_TYPE } from '@/constants/MessageType'
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 browserNotification = useBrowserNotification()
const settingsStore = useSettingsStore()
const { newMsg, desktopPopup } = settingsStore.notificationOptions
const sessionId = generateSessionId(
msg.senderId,
msg.receiverId,
msg.chatType == MESSAGE_TYPE.GROUP
msg.targetId,
msg.chatType == CHAT_TYPE.GROUP
)
messageHandler(msg)
messageHandler(msg) // asyncawait 可选——不阻塞 UI
chatStore.pushAndSortMessagesAsync([msg], sessionId)
const conversation = useConversationStore().conversations.find((x) => x.targetId == msg.senderId)
browserNotification.send(`${conversation.targetName}发来一条消息`, {
body: msg.content,
icon: conversation.targetAvatar
})
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}发来一条消息`, {
body: contentText,
icon: conversation.targetAvatar
})
}
}
@@ -2,7 +2,7 @@ import { useConversationStore } from "@/stores/conversation"
export const signalRConnectionEventHandler = () => {
const conversationStore = useConversationStore();
conversationStore.fetchConversationsFromServier().then(res => {
conversationStore.fetchConversationsFromServier().then(() => {
conversationStore.conversations.forEach(element => {
element.isInitialized = false;
});
@@ -222,12 +222,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;
@@ -9,7 +9,6 @@
import { ref } from 'vue';
import { useCacheStore } from '../stores/cache';
import { FILE_TYPE } from '../constants/fileTypeDefine';
import AsyncImage from '../components/AsyncImage.vue';
import Dropdown from '../components/Dropdown.vue';
@@ -29,7 +29,7 @@
<label class="checkbox-label">
<input type="checkbox"> 自动登录
</label>
<router-link to="/forget" class="link">找回密码</router-link>
<a href="#" class="link" @click.prevent="message.info('找回密码功能开发中')">找回密码</a>
</div>
<button type="submit" class="login-btn" :disabled="loading">
@@ -97,7 +97,7 @@ const handleLogin = async () => {
const res = await authService.login(form);
if (res.code === 0) { // Assuming 0 is success
message.success('登录成功')
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo);
authStore.setLoginInfo(res.data);
signalRStore.initSignalR();
router.push('/messages')
} else {
@@ -141,10 +141,9 @@ const handleRegister = async () => {
try {
loading.value = true;
// 调用注册接口,过滤掉本地状态如 confirmPassword 和 agree
// API.md Register 参数: userName, password, nickName (不含 email)
const res = await authService.register({
username: form.username,
email: form.email,
userName: form.username,
password: form.password,
nickName: form.nickName
});
@@ -48,37 +48,23 @@ import { ref, computed, onMounted } from 'vue'
import GroupChatModal from '@/components/groups/GroupChatModal.vue'
import feather from 'feather-icons';
import { useContactStore } from '@/stores/contact';
import { useRouter } from 'vue-router';
import { useGroupStore } from '@/stores/group';
import contactShow from '@/components/contacts/contactShow.vue';
import groupsShow from '@/components/groups/groupsShow.vue';
const searchQuery = ref('')
const contactStore = useContactStore();
const groupStore = useGroupStore();
const groupModal = ref(false);
const contactTab = ref(0);
const myGroups = ref([
{
id: 1,
name: "产品设计交流群",
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 myGroups = computed(() => {
const searchKey = searchQuery.value.toString().trim();
if (!searchKey) return groupStore.groups;
return groupStore.groups.filter(g => (g.name || '').includes(searchKey));
});
const filteredContacts = computed(() => {
@@ -93,9 +79,10 @@ const filteredContacts = computed(() => {
if (!c) return false
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并打开会话)
const emit = defineEmits(['start-chat'])
// const showGroupList = () => {
// }
// 群聊弹窗选择回调
const handleChatSelect = (group) => {
groupModal.value = false
emit('start-chat', group)
}
onMounted(async () => {
await contactStore.loadContactList();
await groupStore.loadGroups();
})
</script>
@@ -9,19 +9,19 @@
<div class="request-group">
<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="title-row">
<span class="name">{{ item.nickName }}</span>
<span class="date">{{ formatDate(item.created) }}</span>
<span class="name">{{ item.ownerId != authStore.userInfo.id ? (item.ownerNickName || item.ownerId) : (item.targetNickName || item.targetId) }}</span>
<span class="date">{{ formatDate(item.creationTime) }}</span>
</div>
<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 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-accept" @click="handleOpenDialog(item)">接受</button>
</template>
@@ -29,12 +29,12 @@
待对方同意
</span>
<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 v-else-if="item.state === FRIEND_REQUEST_STATUS.Passed" class="status-label">
已添加
</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 v-else class="status-label">
@@ -42,6 +42,11 @@
</span>
</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 v-if="showDialog" class="modal-mask">
@@ -68,7 +73,9 @@ import { formatDate } from '@/utils/formatDate';
import { useAuthStore } from '@/stores/auth';
import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import feather from 'feather-icons';
import WindowControls from '../../components/WindowControls.vue';
import AsyncImage from '../../components/AsyncImage.vue';
const message = useMessage();
const authStore = useAuthStore();
@@ -90,7 +97,8 @@ const activeItem = ref(null);
const handleOpenDialog = (item) => {
activeItem.value = item;
remarkName.value = item.nickName; // 默认备注为昵称
// 对方是请求发起人(ownerId),默认备注用对方的昵称
remarkName.value = item.ownerNickName || item.ownerId || '';
showDialog.value = true;
};
@@ -168,6 +176,16 @@ onMounted(async () => {
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. 列表项:去掉外框和投影,靠间距呼吸 */
.minimal-item {
display: flex;
@@ -178,11 +196,10 @@ onMounted(async () => {
}
/* 4. 头像:小而圆润 */
.avatar {
:deep(.avatar) {
width: 44px;
height: 44px;
border-radius: 50%; /* 纯圆更简洁 */
background-color: #f5f5f7;
border-radius: 50%;
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="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">
<img :src="avatarHandle(item)" :class="[
'avatar',
item.type === GROUP_REQUEST_STATUS.IS_GROUP ||
item.type === GROUP_REQUEST_STATUS.IS_USER
? 'is-group'
: 'is-user'
]" />
<img :src="item.groupAvatar" class="avatar is-group" />
</div>
<div class="info">
<div class="title-row">
<span class="name">{{ item.name }}</span>
<span :class="[
'type-tag',
item.type === GROUP_REQUEST_TYPE.INVITE ||
item.type === GROUP_REQUEST_TYPE.IS_USER
? 'tag-orange'
: 'tag-green'
]">
{{ getTypeText(item.type) }}
<span class="name">{{ item.userNickName }}</span>
<span class="type-tag tag-orange">
申请入群
</span>
<span class="date">14:20</span>
<span class="date">{{ formatDate(item.created) }}</span>
</div>
<div class="group-info">
<span class="label">目标群聊</span>
<span class="group-name">{{ item.groupName }}</span>
</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>
</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">
<button class="btn-text btn-reject">忽略</button>
<Dropdown :disable="handleDropDownDisable" class="handleDropDown" v-model="handleValue" :options="[
{ label: '同意', value: GROUP_REQUEST_ACTION.ACCEPT },
{ label: '拒绝', value: GROUP_REQUEST_ACTION.REJECT }
]" placeholder="处理" @change="groupRequestHandler(item, handleValue)" />
<div v-if="item.state === GROUP_REQUEST_STATUS.PENDING" class="actions">
<button class="btn-text btn-reject" @click="groupRequestHandler(item, GROUP_REQUEST_ACTION.REJECT)">拒绝</button>
<button class="btn-text btn-accept" @click="groupRequestHandler(item, GROUP_REQUEST_ACTION.ACCEPT)">同意</button>
</div>
<div class="actions" v-else>
<div class="status-tag" :class="getGroupRequestStatusClass(item.status)">
<span>{{ getGroupRequestStatusTxt(item.status) }}</span>
<div class="status-tag" :class="getGroupRequestStatusClass(item.state)">
<span>{{ getGroupRequestStatusTxt(item.state) }}</span>
</div>
</div>
</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 class="footer-hint">仅保留最近 30 天的通知记录</div>
<div v-if="groupRequest.length > 0" class="footer-hint">仅保留最近 30 天的通知记录</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, computed, ref, watch } from 'vue';
import { onMounted, computed } from 'vue';
import WindowControls from '../../components/WindowControls.vue';
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 Dropdown from '../../components/Dropdown.vue';
import { groupService } from '../../services/group';
import { useMessage } from '../../components/messages/useAlert';
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
import { formatDate } from '@/utils/formatDate';
import feather from 'feather-icons';
const groupRequestStore = useGroupRequestStore()
const myInfo = useAuthStore().userInfo
const handleValue = ref(null)
const handleDropDownDisable = ref(false)
const message = useMessage()
const groupRequest = computed(() => {
if (!groupRequestStore.groupRequest) {
return [];
}
return groupRequestStore.groupRequest.map((item) => {
return { ...item, type: getRequestType(item) }
})
if (!groupRequestStore.groupRequest) return [];
return groupRequestStore.groupRequest;
})
const getGroupRequestStatusClass = (status) => {
const getGroupRequestStatusClass = (state) => {
const classMap = {
[GROUP_REQUEST_STATUS.PENDING]: 'status-pending',
[GROUP_REQUEST_STATUS.TARGET_PENDING]: 'status-pending',
[GROUP_REQUEST_STATUS.PASSED]: 'status-passed',
[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) => {
if (!request || !request.type) return
let requestAction = GROUP_REQUEST_STATUS.PASSED
let result = null
switch (request.type) {
case GROUP_REQUEST_TYPE.INVITED:
requestAction = action == GROUP_REQUEST_ACTION.ACCEPT ? GROUP_REQUEST_STATUS.TARGET_PASSED : GROUP_REQUEST_STATUS.TARGET_DECLINED;
result = await groupService.handleGroupInvite(request.requestId ,requestAction)
break
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 (!request || !request.id) return;
const result = await groupService.handleGroupRequest(request.id, action);
if (result.code == SYSTEM_BASE_STATUS.SUCCESS) {
message.success('操作成功');
// 重新加载列表
await groupRequestStore.loadGroupRequest();
} else {
message.error(result.message);
}
if(result.code == SYSTEM_BASE_STATUS.SUCCESS){
message.success('操作成功')
}else{
message.error(result.message)
}
}
onMounted(async () => {
await groupRequestStore.loadGroupRequest()
console.log(groupRequestStore.groupRequest)
await groupRequestStore.loadGroupRequest();
})
</script>
<style scoped>
/* 容器基础环境 */
.minimal-page {
width: 100%;
height: 100vh;
background-color: #f5f5f7;
/* Apple 官网经典的背景灰 */
height: 100%;
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.request-container {
width: 100%;
height: 100%;
background-color: #f5f5f5;
display: flex;
justify-content: center;
padding-top: 40px;
overflow-y: auto;
position: relative;
}
.content-limit {
width: 100%;
max-width: 600px;
padding: 0 20px;
max-width: 640px;
padding: 60px 24px;
}
.section-title {
font-size: 12px;
font-size: 13px;
font-weight: 600;
color: #86868b;
letter-spacing: 0.05em;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 24px;
padding-left: 10px;
margin-bottom: 32px;
padding-left: 4px;
}
/* 列表项设计 */
.minimal-item {
display: flex;
background: #ffffff;
padding: 20px;
border-radius: 18px;
/* 较圆润的倒角 */
margin-bottom: 12px;
transition: transform 0.2s ease;
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);
}
/* 头像差异化处理 */
.avatar-wrapper {
margin-right: 16px;
}
@@ -248,18 +159,10 @@ onMounted(async () => {
background: #f2f2f7;
}
.is-user {
border-radius: 50%;
}
/* 用户是圆的 */
.is-group {
border-radius: 12px;
}
/* 群组是方圆的 */
/* 信息排版 */
.info {
flex: 1;
min-width: 0;
@@ -278,7 +181,6 @@ onMounted(async () => {
margin-right: 8px;
}
/* 状态标签 */
.type-tag {
font-size: 10px;
font-weight: 600;
@@ -291,18 +193,12 @@ onMounted(async () => {
color: #ff9500;
}
.tag-green {
background: #e8f7ed;
color: #34c759;
}
.date {
font-size: 11px;
color: #c1c1c6;
margin-left: auto;
}
/* 关键信息:群聊名称展示 */
.group-info {
margin-bottom: 6px;
}
@@ -316,7 +212,6 @@ onMounted(async () => {
font-size: 13px;
font-weight: 500;
color: #007aff;
/* 链接蓝,暗示可点击 */
}
.sub-text {
@@ -324,18 +219,15 @@ onMounted(async () => {
color: #86868b;
line-height: 1.5;
margin: 0;
/* 文字截断 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 按钮样式:去掉了生硬的边框,采用色块感 */
.actions {
display: flex;
flex-direction: column;
/* 垂直排列,更具操作仪式感 */
justify-content: center;
gap: 8px;
margin-left: 20px;
@@ -378,7 +270,16 @@ onMounted(async () => {
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 {
display: inline-flex;
align-items: center;
@@ -390,32 +291,21 @@ onMounted(async () => {
width: fit-content;
}
/* 待处理:蓝色或橙色 */
.status-pending {
background-color: #fff7e6;
color: #fa8c16;
border: 1px solid #ffd591;
}
/* 已通过:绿色 */
.status-passed {
background-color: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
/* 已拒绝 / 失败:灰色或红色 */
.status-declined {
background-color: #fff1f0;
color: #f5222d;
border: 1px solid #ffa39e;
}
/* 默认 / 禁用状态 */
.action-disabled {
opacity: 0.8;
cursor: not-allowed;
}
.handleDropDown {}
</style>
@@ -7,21 +7,26 @@
<header class="profile-header">
<div class="text-info">
<h2 class="display-name">
{{ currentContact.remarkName }}
<span :class="['gender-tag', 'm']">
{{ '♂' }}
</span>
{{ currentContact.remarkName || currentContact.nickName }}
<span :class="['gender-tag', 'm']">{{ '♂' }}</span>
</h2>
<p class="sub-text">账号{{ currentContact.userInfo.username }}</p>
<p class="sub-text">地区{{ '未' }}</p>
<p class="sub-text">账号{{ currentContact.targetId }}</p>
<p class="sub-text">备注{{ editableRemark || '未设置' }}</p>
<p class="sub-text">地区{{ currentContact.region || '未知' }}</p>
</div>
<AsyncImage :raw-url="currentContact.userInfo.avatar" class="big-avatar" />
<AsyncImage :raw-url="currentContact.avatar" class="big-avatar" />
</header>
<div class="profile-body">
<div class="info-row">
<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 class="info-row">
<span class="label">个性签名</span>
@@ -35,55 +40,96 @@
<footer class="profile-footer">
<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>
</div>
</main>
</div>
</template>
<script setup>
import { defineProps, onMounted, ref } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contact';
import { onBeforeRouteUpdate, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import { useConversationStore } from '@/stores/conversation';
import { friendService } from '@/services/friend';
import { useMessage } from '@/components/messages/useAlert';
import WindowControls from '../../components/WindowControls.vue';
import AsyncImage from '../../components/AsyncImage.vue';
const contactStore = useContactStore();
const router = useRouter();
const conversationStore = useConversationStore();
const message = useMessage();
const props = defineProps({
id: {
type: String,
required: true
}
id: { type: String, required: true }
})
const currentContact = ref(null)
const showRemarkEdit = ref(false)
const remarkInput = ref('')
const editableRemark = ref('')
function handleGoToChat() {
const saveRemark = () => {
if (currentContact.value) {
const cid = conversationStore.conversations.find(x => x.targetId == currentContact.value.userInfo.id).id;
router.push(`/messages/chat/${cid}`);
currentContact.value.remarkName = remarkInput.value
editableRemark.value = remarkInput.value
showRemarkEdit.value = false
message.success('备注已更新')
}
}
onBeforeRouteUpdate((to, from) => {
currentContact.value = contactStore.contacts.find(x => x.id == to.params.id);
const handleGoToChat = () => {
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(() => {
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>
<style scoped>
/* --- 右侧名片区 --- */
.profile-main {
flex: 1;
display: flex;
@@ -142,6 +188,7 @@ onMounted(() => {
display: flex;
margin-bottom: 15px;
font-size: 14px;
align-items: center;
}
.info-row .label {
@@ -154,6 +201,34 @@ onMounted(() => {
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 {
display: flex;
flex-direction: column;
@@ -182,10 +257,19 @@ onMounted(() => {
cursor: pointer;
}
.btn-primary:hover, .btn-ghost:hover {
opacity: 0.8;
.btn-ghost-danger {
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 {
text-align: center;
color: #ccc;
@@ -196,7 +280,7 @@ onMounted(() => {
margin-bottom: 10px;
opacity: 0.2;
}
/* 4. 定义组件进场和退场的动画 */
.fade-scale-enter-active,
.fade-scale-leave-active {
transition: all 0.3s ease;
@@ -10,12 +10,13 @@
<AddMenu :menu-list="addMenuList" @action-active="actionHandler"/>
</div>
</div>
<div v-if="msgTitleShow" class="showMsg" @click="requestNotificationPermission">
<i style="color: red;line-height:0;" v-html="feather.icons['alert-circle'].toSvg({width:14})"></i>
<span>新消息无法通知点我授予通知权限</span>
</div>
<div v-if="statusBanner" class="status-bar" :class="{ 'is-notify': statusBanner.type === 'notify' }" @click="statusBanner.type === 'notify' && requestNotificationPermission()">
<i v-html="feather.icons[statusBanner.type === 'notify' ? 'bell' : 'wifi-off'].toSvg({ width: 13, height: 13 })"></i>
<span>{{ statusBanner.text }}</span>
<button v-if="statusBanner.closable" class="bar-close" @click.stop="statusBanner.type === 'net' ? (networkError = null) : null"></button>
</div>
<div class="scroll-area">
<div class="scroll-area">
<div v-for="s in filteredSessions" :key="s.id"
class="list-item" :class="{active: activeId == s.id}" @click="selectSession(s)">
<div class="avatar-container">
@@ -26,11 +27,18 @@
<div class="info">
<div class="name-row">
<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 class="last-msg">{{ lastMessageHandler(s.lastMessage) ?? '获取消息内容失败' }}</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>
</aside>
@@ -43,7 +51,6 @@
<script setup>
import { useRouter } from 'vue-router'
import { ref, computed, onMounted, watch } from 'vue'
import defaultAvatar from '@/assets/default_avatar.png'
import { formatDate } from '@/utils/formatDate'
import { useConversationStore } from '@/stores/conversation'
import AddMenu from '@/components/addMenu.vue'
@@ -55,20 +62,26 @@ import { useChatStore } from '@/stores/chat'
import { groupService } from '../../services/group'
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
import { useMessage } from '../../components/messages/useAlert'
import { useCacheStore } from '../../stores/cache'
import AsyncImage from '../../components/AsyncImage.vue'
import { networkError } from '../../services/api'
const conversationStore = useConversationStore();
const router = useRouter();
const browserNotification = useBrowserNotification();
const message = useMessage()
const cacheStore = useCacheStore()
const searchQuery = ref('')
const activeId = ref(0)
const searchUserModal = ref(false);
const createGroupModal = 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 = [
{
text: '发起群聊',
@@ -92,21 +105,18 @@ const addMenuList = [
const createGroupSubmitHandler = async (selectedUsers, groupName) => {
const res = await groupService.createGroup({
name: groupName,
avatar: "http://192.168.5.116:7070/uploads/files/IM/2026/03/2/bf1a0f691220.jpg",
userIDs: [...selectedUsers]
})
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
message.success('群聊创建成功')
}else{
message.error(res.message)
}
}
const urlHandler = async () => {
const res = await groupService.createGroup({ name: groupName });
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
message.success('群聊创建成功');
const groupId = res.data?.id;
// 循环邀请群成员
const userIds = [...selectedUsers];
for (const userId of userIds) {
await groupService.inviteUser(groupId, userId);
}
} else {
message.error(res.message);
}
}
const filteredSessions = computed(() => conversationStore.sortedConversations.filter(s => s.targetName.includes(searchQuery.value)))
@@ -123,20 +133,25 @@ function actionHandler(type){
break;
case 'createGroup':
createGroupModal.value = true;
break;
default:
break;
}
}
function lastMessageHandler(text){
if (!text) return '';
try{
const data = JSON.parse(text);
if(data.text){
return data.text;
}else{
return text
// 新 API content 可能是字符串或结构化对象 { fallback, body: { text, ... }, ... }
if (typeof text === 'object') {
return text.body?.text || text.fallback || '';
}
}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;
}
}
@@ -183,20 +198,35 @@ onMounted(async () => {
display: flex;
flex-direction: column;
}
.showMsg {
/* width: 10px; */
height: 20px;
background: #e3f98d;
font-size: 12px;
display: flex;
/* text-align: center; */
flex-wrap: nowrap;
align-content: center;
justify-content: center;
align-items: center;
color: red;
cursor: pointer;
.status-bar {
height: 24px;
display: flex;
align-items: center;
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;
}
.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; }
/* 空状态占位 */
.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. 聊天主面板修复 */
.chat-panel {
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; }
/* 未读气泡 */
@@ -23,27 +23,27 @@
<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"
: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" /> -->
<AsyncImage :raw-url="m.senderId == myInfo.id
? myInfo?.avatar
: m.chatType == MESSAGE_TYPE.GROUP
? m.senderAvatar
: m.chatType == CHAT_TYPE.GROUP
? (getSenderAvatar(m.senderId) || m.senderAvatar)
: 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="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>
<div :class="['bubble', m.type == 'Text' ? 'text-bubble' : '']"
<div :class="['bubble', m.msgType == MSG_TYPE.Text ? 'text-bubble' : '']"
@contextmenu.prevent="(e) => handleRightClick(e, m)">
<div v-if="m.type === 'Text'">{{ m.content }}</div>
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
<!-- <img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
alt="图片消息" @click="imagePreview(m)"> -->
<div v-if="m.isWithdrawn" class="withdrawn-msg"><i>消息已撤回</i></div>
<div v-else-if="m.msgType == MSG_TYPE.Text">
{{ typeof m.content === 'object' ? (m.content?.body?.text || m.content?.fallback) : m.content }}
</div>
<div v-else-if="m.msgType === MSG_TYPE.Image" class="image-msg-container" :style="getImageStyle(m)">
<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)" />
<div v-if="m.isImgLoading || m.isError" class="image-overlay">
@@ -58,18 +58,29 @@
</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>
</div>
</div>
<VideoMsg v-else-if="m.type === FILE_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.content.thumb"
:duration="m.content.duration" :w="m.content.w" :h="m.content.h" :uploading="m.isImgLoading"
<VideoMsg v-else-if="m.msgType === MSG_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.thumb"
:duration="m.duration" :w="m.width" :h="m.height" :uploading="m.isImgLoading"
:progress="+m.progress" @play="playHandler(m)" />
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice" :url="m.localUrl ?? m.content.url"
:duration="m.content.duration" :isRead="true" :isSelf="m.senderId == myInfo.id" />
<VoiceMsg v-else-if="m.msgType === MSG_TYPE.Voice" :url="m.localUrl ?? m.url"
: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">
<i v-if="m.isError" style="color: red;"
v-html="feather.icons['alert-circle'].toSvg({ width: 18, height: 18 })"></i>
<i v-if="m.isError" class="error-icon clickable" title="点击重发"
@click.stop="handleRetry(m)"
v-html="feather.icons['alert-circle'].toSvg({ width: 16, height: 16 })"></i>
<i v-if="m.isLoading" class="loaderIcon"
v-html="feather.icons['loader'].toSvg({ width: 18, height: 18 })"></i>
</div>
@@ -81,14 +92,42 @@
</div>
<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">
<button class="tool-btn" @click="toggleEmoji"
<button class="tool-btn" @click="showEmoji = !showEmoji"
v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })">
</button>
<label class="tool-btn">
<i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i>
<input type="file" hidden @change="handleFile($event.target.files)" />
</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"
v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({ width: 25, height: 25 })">
</button>
@@ -99,7 +138,7 @@
</div>
</footer>
<InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null"
:groupData="conversationInfo" />
:groupData="conversationInfo" @close="handleConversationClosed" />
</div>
</section>
</template>
@@ -107,21 +146,20 @@
<script setup>
import { ref, nextTick, onMounted, watch, onUnmounted, reactive } from 'vue';
import { useAuthStore } from '@/stores/auth';
import defaultAvatar from '@/assets/default_avatar.png';
import { formatDate } from '@/utils/formatDate';
import { debounce } from '@/utils/debounce';
import { useChatStore } from '@/stores/chat';
import { generateSessionId } from '@/utils/sessionIdTools';
import { useSignalRStore } from '@/stores/signalr';
import { useConversationStore } from '@/stores/conversation';
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 UserHoverCard from '@/components/user/UserHoverCard.vue';
import ContextMenu from '@/components/ContextMenu.vue';
import { useSendMessageHandler } from './hooks/useSendMessageHandler';
import { previewImages } from 'hevue-img-preview/v3'
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 { ImageInfo, VideoInfo, VoiceInfo } from '@/constants/fileTypeInfo';
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 InfoSidebar from '../../../components/messages/InfoSidebar.vue';
import { isElectron } from '../../../utils/electronHelper';
import { groupService } from '../../../services/group';
import VideoPreview from '../../../components/electron/VideoPreview.vue';
import { useRightClickHandler } from './hooks/useRightClickHandler';
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({
@@ -145,13 +186,13 @@ const props = defineProps({
const infoSideBarShow = ref(false);
const chatStore = useChatStore();
const signalRStore = useSignalRStore();
const conversationStore = useConversationStore();
const message = useMessage();
const { sendMessage, sendFileMessage, sendTextMessage } = useSendMessageHandler();
const router = useRouter();
const { sendFileMessage, sendTextMessage, retryMessage } = useSendMessageHandler();
const input = ref(''); // 输入框内容
const historyRef = ref(null); // 绑定 DOM 用于滚动
const input = ref('');
const historyRef = ref(null);
const loadingRef = ref(null)
const userHoverCardRef = ref(null);
const menuRef = ref(null);
@@ -160,18 +201,85 @@ const myInfo = useAuthStore().userInfo;
const conversationInfo = ref(null)
let groupData = reactive({});
// --- 消息数据 ---
const messages = ref([]);
// --- 消息加载 & 录音状态 ---
const isLoading = ref(false);
const isFinished = ref(false);
const hasError = ref(false);
let observer = null;
const isRecord = ref(false);
let mediaRecorder = null;
let audioChunks = []; // 用于存储录音的数据片段
let audioChunks = [];
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 () => {
if (infoSideBarShow.value) {
@@ -182,14 +290,20 @@ const infoShowHandler = async () => {
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;
let h = content.H || maxHeight;
const getImageStyle = (m) => {
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;
@@ -212,7 +326,7 @@ const getImageStyle = (content) => {
const imagePreview = (e, m) => {
e.stopPropagation();
const imageList = chatStore.messages
.filter(x => x.type == 'Image')
.filter(x => x.msgType == MSG_TYPE.Image)
;
const index = imageList.indexOf(m);
if (isElectron()) {
@@ -223,7 +337,7 @@ const imagePreview = (e, m) => {
window.api.window.newWindow('imgpre', safeData);
} else {
previewImages({
imgList: imageList.map(m => m.content.url),
imgList: imageList.map(m => m.url || (m.content?.body?.url) || m.localUrl),
nowImgIndex: index
});
}
@@ -271,7 +385,7 @@ const stopRecord = async () => {
}
const playHandler = (m) => {
videoUrl.value = m.content.url
videoUrl.value = m.url || (m.content?.body?.url) || m.localUrl
if (isElectron()) {
window.api.window.newWindow('videopre', videoUrl.value)
return
@@ -283,6 +397,59 @@ const videoClose = () => {
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 () => {
// 1. 如果正在加载,或者已经彻底没数据了,才拦截
if (isLoading.value || isFinished.value) return;
@@ -312,30 +479,63 @@ const loadHistoryMsg = async () => {
}
};
const handleHoverCard = (e, m) => {
const userInfo = {
name: m.senderName,
avatar: m.senderAvatar,
id: m.senderId
const handleHoverCard = async (e, m) => {
let name = m.senderName || '';
let avatar = m.senderAvatar || null;
let userName = '';
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);
}
const closeHoverCard = () => {
userHoverCardRef.value.hide();
}
const handleRightClick = (e, m) => {
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(
() => chatStore.messages,
async (newVal) => {
async () => {
await Promise.all(chatStore.messages.map(resolvePrivateMedia));
scrollToBottom();
conversationStore.conversations.find(x => x.id == conversationInfo.value.id).unreadCount = 0;
signalRStore.clearUnreadCount(conversationInfo.value.id);
const conv = conversationStore.conversations.find(x => x.id == conversationInfo.value.id);
if (conv) conv.unreadCount = 0;
if (conversationInfo.value?.id) debouncedMarkRead(conversationInfo.value.id);
},
{ deep: true }
);
@@ -351,10 +551,16 @@ const scrollToBottom = async () => {
// 发送文本
async function sendText() {
if (!input.value.trim()) return;
// 根据 C# MessageBaseDto 构造的示例对象
const qid = quoteMessage.value?.id || null
const content = 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 img = null;
switch (getMessageType(file.type)) {
case FILE_TYPE.Image:
case MSG_TYPE.Image:
localUrl = URL.createObjectURL(file);
img = await loadImage(localUrl);
info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200));
break;
case FILE_TYPE.Video: {
const imgBlob = await getVideoThumbnailBlob(file);
localUrl = URL.createObjectURL(imgBlob);
img = await loadImage(localUrl);
info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
case MSG_TYPE.Video: {
try {
const imgBlob = await getVideoThumbnailBlob(file);
localUrl = URL.createObjectURL(imgBlob);
img = await loadImage(localUrl);
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;
}
case FILE_TYPE.Voice: {
case MSG_TYPE.Voice: {
localUrl = URL.createObjectURL(file);
info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file));
info = new VoiceInfo(file.type, '[语音消息]', 0);
try {
info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file));
} catch { /* 无法获取时长,用 0 */ }
break;
}
@@ -394,10 +607,6 @@ async function handleFile(files) {
await sendFileMessage(file, conversationInfo, info, localUrl);
}
function toggleEmoji() {
console.log('打开表情面板');
}
async function loadConversation(conversationId) {
/*
const res = await messageService.getConversationById(conversationId);
@@ -406,14 +615,14 @@ async function loadConversation(conversationId) {
if (conversationStore.conversations.length == 0) {
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) => {
await loadConversation(newId);
if (conversationInfo.value) {
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);
isFinished.value = false;
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,不会报错
return [props.id, session?.isInitialized];
},
// 2. 回调逻辑
async ([newId, isInited], [oldId, oldInited]) => {
async ([newId, isInited]) => {
// 基础防守:如果 ID 无效,直接跳过
if (!newId) return;
@@ -460,16 +669,16 @@ watch(
//const currentMax = chatStore.maxSequenceId;
// 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) {
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
const locaMaxSequenceId = chatStore.maxSequenceId;
if (locaMaxSequenceId < (minSequenceId - 1)) {
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
@@ -527,6 +736,8 @@ onMounted(async () => {
onUnmounted(() => {
if (observer) observer.disconnect();
resolvedObjectUrls.forEach((url) => URL.revokeObjectURL(url));
resolvedObjectUrls.clear();
});
</script>
@@ -773,6 +984,26 @@ onUnmounted(() => {
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 {
background: #95ec69;
}
@@ -781,12 +1012,7 @@ onUnmounted(() => {
/* background: #95ec69; */
}
:deep(.avatar-chat) {
width: 38px;
height: 38px;
border-radius: 4px;
flex-shrink: 0;
}
:deep(.avatar-chat) { width: 38px; height: 38px; border-radius: 4px; flex-shrink: 0; }
.msg-time {
font-size: 11px;
@@ -827,4 +1053,82 @@ textarea {
display: flex;
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>
@@ -1,41 +1,28 @@
import { FILE_TYPE } from '../../../../constants/fileTypeDefine'
import { MSG_TYPE } from '@/constants/MessageType'
export function useRightClickHandler(e, m) {
const textRightItem = [
{
label: '复制',
action: async () => {
await navigator.clipboard.writeText(e.target.innerText)
}
},
{
label: '引用',
action: () => console.log('进入私聊')
},
{
label: '转发',
action: () => {}
},
{
label: '删除',
type: 'danger',
action: () => alert('删除成功')
}
]
const buildMenu = (message, opts = {}) => {
const { onQuote, onForward, onDelete, onWithdraw, onCopy } = opts
const items = []
const imgRightItem = [
{
label: '复制',
action: () => {
console.log(e.target)
}
}
]
switch (m.type) {
case FILE_TYPE.TEXT:
return textRightItem
case FILE_TYPE.Image:
case FILE_TYPE.Video:
return imgRightItem;
const isText = message.msgType === MSG_TYPE.Text
const isImage = message.msgType === MSG_TYPE.Image
if (isText) {
items.push({ label: '复制', action: () => navigator.clipboard.writeText(message.content?.body?.text || message.content?.fallback || message.content || '') })
}
if (isImage) {
items.push({ label: '复制图片', action: () => onCopy?.(message) })
}
if (isText) {
items.push({ label: '引用', action: () => onQuote?.(message) })
}
items.push({ label: '转发', action: () => onForward?.(message) })
items.push({ label: '撤回', action: () => onWithdraw?.(message) })
items.push({ label: '删除', type: 'danger', action: () => onDelete?.(message) })
return items
}
export const useRightClickHandler = (message, opts) => {
return buildMenu(message, opts)
}
@@ -1,109 +1,213 @@
import { useChatStore } from "@/stores/chat";
import { useAuthStore } from "@/stores/auth";
import { generateSessionId } from "@/utils/sessionIdTools";
import { MESSAGE_TYPE } from "@/constants/MessageType";
import { CHAT_TYPE, MSG_TYPE } from "@/constants/MessageType";
import { messageService } from "@/services/message";
import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus";
import { uploadFile } from "@/services/upload/uploader";
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
import { getMessageType } from "@/constants/fileTypeDefine";
import { uploadService } from "@/services/upload/uploadService";
import { getFileHash } from "@/utils/uploadTools";
export function useSendMessageHandler() {
// 发送者恒等于当前登录用户,避免会话数据缺 userId 导致消息归属错误
const myId = useAuthStore().userInfo?.id;
const sendMessage = async (msg) => {
const chatStore = useChatStore();
//设置消息为加载状态
const msgServer = { ...msg }
msg.isLoading = true;
//将临时消息推送到消息列表(存库,方便后续重试)
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
//从列表取出消息
let updateMsg = msg;
const isGroupChat = msg.chatType == CHAT_TYPE.GROUP;
await chatStore.pushAndSortMessagesAsync(
[msg],
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
);
try {
const res = await messageService.sendMessage(msgServer);
const res = await messageService.sendMessage(msg);
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
updateMsg.isError = true;
msg.isError = true;
} else {
//发送成功将后端生成的sequenceId更新
updateMsg = res.data;
// 用服务端数据覆盖(保留本地 clientMsgId 等字段用于去重/重发)
const serverData = res.data;
Object.assign(msg, serverData);
msg.isError = false;
}
} catch {
updateMsg.isError = true;
msg.isError = true;
} finally {
updateMsg.isLoading = false;
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
msg.isLoading = false;
await chatStore.pushAndSortMessagesAsync(
[msg],
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
);
}
}
const sendTextMessage = async (text, conversationInfo) => {
const sendTextMessage = async (text, conversationInfo, quoteMessageId = null) => {
const msg = {
type: 'Text', // 消息类型,例如 'Text', 'Image', 'File'
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
content: text,
timeStamp: new Date(), // 对应 DateTime
msgId: self.crypto.randomUUID()
clientMsgId: self.crypto.randomUUID(),
chatType: conversationInfo.value.chatType,
targetId: conversationInfo.value.targetId,
msgType: MSG_TYPE.Text,
senderId: myId,
sequenceId: Date.now(),
text, // 顶层 text 供 /message/sendMessageSendRequest 校验 text 非空)
// 统一用 content 结构,本地消息与服务端返回格式一致
content: { fallback: text, body: { text }, ext: {}, quote: null },
};
//更新当前会话最新消息
conversationInfo.value.lastMessage = msg.content;
if (quoteMessageId) msg.quoteMessageId = quoteMessageId;
conversationInfo.value.lastMessage = text;
await sendMessage(msg);
}
const sendFileMessage = async (file, conversationInfo, info, localUrl) => {
const chatStore = useChatStore();
const msg = {
type: getMessageType(file.type), // 消息类型,例如 'Text', 'Image', 'File'
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
content: '',
timeStamp: new Date(), // 对应 DateTime
msgId: self.crypto.randomUUID(),
localUrl: localUrl,
progress: 0
};
//更新当前会话最新消息
conversationInfo.value.lastMessage = info.text;
msg.isImgLoading = true;
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
if (info.thumb) {
const hash = await getFileHash(info.thumb);
try {
const { data } = await uploadService.uploadSmallFile(info.thumb, hash);
info.thumb = data.objectName;
} catch (e) {
console.error(e)
msg.isError = true;
msg.isLoading = false;
return;
}
// 重发失败消息
const retryMessage = async (msg) => {
const chatStore = useChatStore()
msg.isError = false
msg.isLoading = true
const isGroupChat = msg.chatType == CHAT_TYPE.GROUP
// 兼容旧缓存中的纯文本 content,并保留新版结构化消息字段。
const text = typeof msg.content === 'object'
? (msg.content?.body?.text || msg.content?.fallback || '')
: (msg.content || '')
const apiMsg = {
clientMsgId: msg.clientMsgId || msg.msgId,
targetId: msg.targetId,
chatType: msg.chatType,
msgType: msg.msgType,
text,
url: msg.url,
thumb: msg.thumb,
width: msg.width,
height: msg.height,
duration: msg.duration,
fileId: msg.fileId,
fileName: msg.fileName,
fileSize: msg.fileSize,
fileFormat: msg.fileFormat,
}
await uploadFile(file, {
onProgress: async (e) => {
if (!e.status) return;
switch (e.status) {
case UPLOAD_STATUS.MERGING:
case UPLOAD_STATUS.UPLOADING:
msg.progress = e.progress;
break;
case UPLOAD_STATUS.COMPLETE:
msg.progress = 100;
msg.isImgLoading = false;
info.fileId = e.taskId;
msg.content = JSON.stringify(info);
await sendMessage(msg);
break;
default:
break;
}
await chatStore.pushAndSortMessagesAsync(
[msg],
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
)
try {
const res = await messageService.sendMessage(apiMsg)
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
msg.isError = true
} else {
Object.assign(msg, res.data)
msg.isError = false
}
});
} catch {
msg.isError = true
} finally {
msg.isLoading = false
await chatStore.pushAndSortMessagesAsync(
[msg],
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
)
}
}
return { sendMessage, sendFileMessage, sendTextMessage };
const sendFileMessage = async (file, conversationInfo, info, localUrl) => {
const chatStore = useChatStore();
const isGroupChat = conversationInfo.value.chatType == CHAT_TYPE.GROUP;
const msgType = getMessageType(file.type);
const msgId = self.crypto.randomUUID();
const msg = {
clientMsgId: msgId,
chatType: conversationInfo.value.chatType,
targetId: conversationInfo.value.targetId,
msgType: msgType,
senderId: myId,
sequenceId: Date.now(),
localUrl: localUrl,
progress: 0,
isLoading: false,
isImgLoading: true,
};
conversationInfo.value.lastMessage = info.text || '[文件]';
msg.isImgLoading = true;
await chatStore.pushAndSortMessagesAsync(
[msg],
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
);
// 缩略图直传
if (info.thumb instanceof Blob) {
try {
const thumbFile = info.thumb instanceof File
? info.thumb
: new File([info.thumb], `thumb_${msgId}.jpg`, { type: info.thumb.type || 'image/jpeg' });
const thumbRes = await uploadService.uploadSmallFile(thumbFile, true);
info.thumb = thumbRes.data?.url || thumbRes.data?.id || '';
} catch (e) {
console.error('缩略图上传失败:', e);
info.thumb = '';
}
}
const conversationId = chatStore.activeConversationId;
try {
const uploadedFile = await uploadFile(file, {
conversationId,
chatType: conversationInfo.value.chatType,
targetId: conversationInfo.value.targetId,
onProgress: async (e) => {
if (!e.status) return;
switch (e.status) {
case UPLOAD_STATUS.MERGING:
case UPLOAD_STATUS.UPLOADING:
msg.progress = e.progress;
break;
case UPLOAD_STATUS.UPLOADED:
msg.isImgLoading = false;
break;
case UPLOAD_STATUS.COMPLETE:
msg.progress = 100;
msg.isImgLoading = false;
break;
default:
break;
}
}
});
msg.progress = 100;
msg.isImgLoading = false;
msg.fileId = uploadedFile.id;
msg.url = uploadedFile.url || '';
msg.thumb = info.thumb || '';
if (info.w) msg.width = info.w;
if (info.h) msg.height = info.h;
if (info.duration) msg.duration = Math.round(info.duration);
if (msgType === MSG_TYPE.File) {
msg.fileName = file.name;
msg.fileSize = file.size;
msg.fileFormat = file.type || file.name.split('.').pop() || 'application/octet-stream';
}
await sendMessage(msg);
} catch (e) {
console.error('文件上传失败:', e);
msg.isError = true;
msg.isLoading = false;
msg.isImgLoading = false;
await chatStore.pushAndSortMessagesAsync(
[msg],
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
true
);
}
}
return { sendMessage, sendFileMessage, sendTextMessage, retryMessage };
}
@@ -55,7 +55,7 @@
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import { ref, computed } from 'vue'
const twoFactor = ref(false)
@@ -11,7 +11,7 @@
<span class="description">选择您喜欢的主题颜色</span>
</div>
<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="dark">深色模式</option>
<option value="system">跟随系统</option>
@@ -25,7 +25,7 @@
<span class="description">设置应用显示的界面语言</span>
</div>
<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="en-US">English</option>
</select>
@@ -42,7 +42,7 @@
<span class="description">在电脑启动时自动运行应用</span>
</div>
<label class="switch">
<input type="checkbox" v-model="settings.autoStart">
<input type="checkbox" :checked="settingsStore.generalOptions.autoStart" @change="toggleAutoStart">
<span class="slider"></span>
</label>
</div>
@@ -54,10 +54,10 @@
</div>
<div class="radio-group">
<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 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>
</div>
</div>
@@ -89,35 +89,106 @@
</template>
<script setup>
import { reactive, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { useSettingsStore } from '../../stores/settings'
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 authStore = useAuthStore()
const settingsStore = useSettingsStore()
const router = useRouter()
const message = useMessage()
const settings = reactive({
theme: 'system',
language: 'zh-CN',
autoStart: true,
closeBehavior: 'tray'
})
// -- 主题 --
const applyTheme = (theme) => {
const root = document.documentElement
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
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 = () => {
authStore.logout()
router.push('/auth/login')
}
const clearCache = () => {
isClearing.value = true
setTimeout(() => {
cacheSize.ref = 0
isClearing.value = false
alert('缓存清理成功')
}, 1500)
}
onMounted(() => {
applyTheme(settingsStore.generalOptions.theme)
calcCacheSize()
})
</script>
<style scoped>
@@ -171,7 +242,6 @@ const clearCache = () => {
color: #999;
}
/* 下拉选择框样式 */
.select-wrapper select {
padding: 6px 10px;
border: 1px solid #dcdfe6;
@@ -182,7 +252,6 @@ const clearCache = () => {
cursor: pointer;
}
/* 单选框组样式 */
.radio-group {
display: flex;
flex-direction: column;
@@ -198,7 +267,6 @@ const clearCache = () => {
gap: 6px;
}
/* 按钮样式 */
.ghost-btn {
background: #fff;
border: 1px solid #dcdfe6;
@@ -224,7 +292,6 @@ const clearCache = () => {
color: #007bff;
}
/* 复用 Switch 开关样式 */
.switch {
position: relative;
width: 36px;
@@ -11,7 +11,7 @@
<span class="description">当有新版本可用时提醒我</span>
</div>
<label class="switch">
<input type="checkbox" v-model="settings.systemUpdate">
<input type="checkbox" :checked="settingsStore.notificationOptions.systemUpdate" @change="toggle('systemUpdate')">
<span class="slider"></span>
</label>
</div>
@@ -19,10 +19,10 @@
<div class="setting-item">
<div class="info">
<span class="label">新消息提醒</span>
<span class="description">收到好友或群组新消息时播放提示音</span>
<span class="description">收到好友或群组新消息时播放提示音并显示通知</span>
</div>
<label class="switch">
<input type="checkbox" v-model="settings.newMsg">
<input type="checkbox" :checked="settingsStore.notificationOptions.newMsg" @change="toggle('newMsg')">
<span class="slider"></span>
</label>
</div>
@@ -37,7 +37,7 @@
<span class="description">在屏幕右下角显示通知气泡</span>
</div>
<label class="switch">
<input type="checkbox" v-model="settings.desktopPopup">
<input type="checkbox" :checked="settingsStore.notificationOptions.desktopPopup" @change="toggle('desktopPopup')">
<span class="slider"></span>
</label>
</div>
@@ -48,7 +48,7 @@
<span class="description">每周接收一次系统周报</span>
</div>
<label class="switch">
<input type="checkbox" v-model="settings.emailDigest">
<input type="checkbox" :checked="settingsStore.notificationOptions.emailDigest" @change="toggle('emailDigest')">
<span class="slider"></span>
</label>
</div>
@@ -63,30 +63,33 @@
</template>
<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 settings = reactive({
systemUpdate: true,
newMsg: true,
desktopPopup: false,
emailDigest: false
})
// 即时切换,但只保存开关状态
const toggle = (key) => {
settingsStore.setNotificationOptions({ [key]: !settingsStore.notificationOptions[key] })
// 新消息关闭时重置浏览器通知权限感知(下次再开时重新请求)
}
const saveSettings = async () => {
isSaving.value = true
// 模拟请求
await new Promise(r => setTimeout(r, 800))
// settingsStore 已经即时写入 localStorage,这里只是给用户反馈
await new Promise(r => setTimeout(r, 600))
isSaving.value = false
alert('通知设置已更新')
message.success('通知设置已保存')
}
</script>
<style scoped>
.settings-content {
width: 100%;
max-width: 520px; /* 稍微宽一点,方便文字展示 */
max-width: 520px;
margin: 0 auto;
color: #333;
}
@@ -133,7 +136,6 @@ const saveSettings = async () => {
color: #999;
}
/* 纯 CSS 实现的开关组件 */
.switch {
position: relative;
display: inline-block;
@@ -169,14 +171,13 @@ const saveSettings = async () => {
}
input:checked + .slider {
background-color: #007bff; /* 匹配你截图中的主蓝色 */
background-color: #007bff;
}
input:checked + .slider:before {
transform: translateX(18px);
}
/* 按钮样式保持一致 */
.action-bar {
margin-top: 20px;
}
@@ -21,7 +21,7 @@
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ref, computed } from 'vue'
import feather from 'feather-icons'
import SettingContent from './SettingContent.vue'
@@ -36,14 +36,6 @@ const menuItems = [
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>
<style scoped>
@@ -51,7 +51,6 @@ import { userService } from '../../services/userService'
import { useAuthStore } from '../../stores/auth'
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
import { useMessage } from '../../components/messages/useAlert'
import { getFileHash } from '../../utils/uploadTools'
const authStore = useAuthStore()
const message = useMessage()
@@ -64,10 +63,10 @@ const triggerUpload = () => fileRef.value.click()
const onFileChange = async (e) => {
const file = e.target.files[0]
const hash = await getFileHash(file)
const res = await uploadService.uploadSmallFile(file, hash)
const res = await uploadService.uploadSmallFile(file, true)
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
message.error(res.message)
return
}
userInfo.avatar = res.data.url
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']
}
})