fix: align frontend with backend APIs
This commit is contained in:
@@ -0,0 +1,573 @@
|
|||||||
|
# IM_NEW 接口文档
|
||||||
|
|
||||||
|
> 源码基线:`IM_NEW/codex/api-alignment-fixes`(起点 `32177a7293ec9eb7bd731467953da3c51e561beb`)
|
||||||
|
> 文档以控制器、DTO、枚举、序列化配置和 Nginx 网关配置为准;“当前实现注意事项”用于标明源码中的实际限制,不代表理想设计。
|
||||||
|
|
||||||
|
## 1. 通用约定
|
||||||
|
|
||||||
|
### 1.1 网关与路径大小写
|
||||||
|
|
||||||
|
- 网关默认端口:`8009`。
|
||||||
|
- HTTP API 前缀:`/api`。
|
||||||
|
- SignalR Hub:`/chat`。
|
||||||
|
- Nginx `location` 匹配区分大小写,因此应使用本文给出的全小写控制器前缀,例如 `/api/user/me`。`/api/User/Me` 在当前网关会返回 404。
|
||||||
|
|
||||||
|
### 1.2 认证
|
||||||
|
|
||||||
|
除登录、注册以及下文特别标注的接口外,请携带:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <JWT>
|
||||||
|
```
|
||||||
|
|
||||||
|
SignalR 客户端通过 `/chat?access_token=<JWT>` 完成握手;使用官方 SignalR 客户端时由 `accessTokenFactory` 自动添加。
|
||||||
|
|
||||||
|
Token 缺失、无效或过期时返回 **HTTP 401**,业务响应为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 1006, "message": "认证失败", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
已认证但权限不足时返回 **HTTP 403**,业务码为 `1005`。新版前端在滚动发布期间仍兼容旧服务的 HTTP 200 + `code=1006`。
|
||||||
|
|
||||||
|
### 1.3 统一响应
|
||||||
|
|
||||||
|
除文件内容下载外,接口通常返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "成功",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- JSON 属性使用 camelCase。
|
||||||
|
- `code === 0` 表示业务成功。
|
||||||
|
- 参数校验失败也返回 HTTP 200,业务码为 `1003`。
|
||||||
|
- 枚举通过 `JsonStringEnumConverter` 序列化为字符串,并接受字符串枚举请求值。
|
||||||
|
- 未处理异常统一返回 HTTP 500 + `code=1000`,响应头 `X-Correlation-ID` 可用于关联服务端日志。
|
||||||
|
|
||||||
|
## 2. 认证与用户
|
||||||
|
|
||||||
|
### 2.1 POST `/api/auth/login`
|
||||||
|
|
||||||
|
无需认证。请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "userName": "user1", "password": "******" }
|
||||||
|
```
|
||||||
|
|
||||||
|
校验:`userName` 5–20 字符;`password` 非空且不超过 50 字符。
|
||||||
|
|
||||||
|
成功返回 `Result<LoginResponse>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "成功",
|
||||||
|
"data": {
|
||||||
|
"userId": "guid",
|
||||||
|
"token": "jwt",
|
||||||
|
"refreshToken": "string",
|
||||||
|
"expired": null,
|
||||||
|
"userName": "string",
|
||||||
|
"nickName": "string",
|
||||||
|
"avatar": "string|null",
|
||||||
|
"creationTime": "datetime-offset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前实现创建 `LoginResponse` 时将 `expired` 传为 `null`。
|
||||||
|
|
||||||
|
### 2.2 POST `/api/auth/register`
|
||||||
|
|
||||||
|
无需认证。请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "userName": "newuser", "password": "******", "nickName": "新用户" }
|
||||||
|
```
|
||||||
|
|
||||||
|
校验:`userName` 5–20 字符;`password` 6–50 字符;`nickName` 非空且不超过 50 字符。返回 `Result<UserResponse>`。
|
||||||
|
|
||||||
|
### 2.3 POST `/api/auth/refresh`
|
||||||
|
|
||||||
|
无需 Access Token。请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "refreshToken": "string" }
|
||||||
|
```
|
||||||
|
|
||||||
|
返回新的 `Result<LoginResponse>`。
|
||||||
|
|
||||||
|
### 2.4 用户接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/user/me` | 无 | `Result<UserResponse>` |
|
||||||
|
| GET | `/api/user/find?userId={guid}` | query: `userId` | `Result<UserResponse>` |
|
||||||
|
| GET | `/api/user/findByUname?username={value}` | query: `username` | `Result<UserResponse>` |
|
||||||
|
| POST | `/api/user/update` | body: `UserUpdateRequest` | `Result<UserResponse>` |
|
||||||
|
| POST | `/api/user/getUsersByIds` | body: GUID 数组 | `Result<UserResponse[]>` |
|
||||||
|
|
||||||
|
`UserUpdateRequest` 的字段均可省略:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nickName": "string|null",
|
||||||
|
"region": "string|null",
|
||||||
|
"avatar": "string|null",
|
||||||
|
"description": "string|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`UserResponse`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"userName": "string",
|
||||||
|
"nickName": "string",
|
||||||
|
"email": "string|null",
|
||||||
|
"phone": "string|null",
|
||||||
|
"region": "string",
|
||||||
|
"description": "string",
|
||||||
|
"avatar": "string|null",
|
||||||
|
"creationTime": "datetime-offset",
|
||||||
|
"deletion": "datetime-offset|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 好友与好友申请
|
||||||
|
|
||||||
|
所有接口均要求认证。
|
||||||
|
|
||||||
|
### 3.1 好友
|
||||||
|
|
||||||
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/friend/list` | 无 | `Result<FriendResponse[]>` |
|
||||||
|
| POST | `/api/friend/delete?friendId={guid}` | 好友关系记录 ID,不是对方用户 ID | `Result<object>`,成功时 `data: null` |
|
||||||
|
| POST | `/api/friend/block?friendId={guid}` | 好友关系记录 ID | `Result<object>` |
|
||||||
|
| GET | `/api/friend/checkFriend?userId={guid}&targetId={guid}` | 两个用户 ID | `Result<boolean>` |
|
||||||
|
|
||||||
|
`FriendResponse`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "friend-relation-guid",
|
||||||
|
"targetId": "peer-user-guid",
|
||||||
|
"avatar": "string|null",
|
||||||
|
"nickName": "string",
|
||||||
|
"remarkName": "string|null",
|
||||||
|
"createTime": "datetime",
|
||||||
|
"updateTime": "datetime|null",
|
||||||
|
"status": "Pending|Added|Declined|Blocked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 好友申请
|
||||||
|
|
||||||
|
#### POST `/api/friendRequest/add`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targetId": "guid",
|
||||||
|
"description": "string|null",
|
||||||
|
"remarkName": "string|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`targetId` 必填。返回 `Result<FriendRequestResponse>`。
|
||||||
|
|
||||||
|
#### POST `/api/friendRequest/handle`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requestId": "guid",
|
||||||
|
"action": "Accpet|Reject|Block",
|
||||||
|
"remarkName": "string|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:后端枚举当前拼写为 `Accpet`,接受申请时 `remarkName` 必填。
|
||||||
|
|
||||||
|
#### GET `/api/friendRequest/list`
|
||||||
|
|
||||||
|
返回与当前用户相关的申请:`Result<FriendRequestResponse[]>`。
|
||||||
|
|
||||||
|
`FriendRequestResponse.state`:`Pending`、`Declined`、`Passed`、`Blocked`。
|
||||||
|
|
||||||
|
## 4. 群组
|
||||||
|
|
||||||
|
### 4.1 群信息
|
||||||
|
|
||||||
|
| 方法 | 路径 | 请求 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/group/getAll` | 无 | `Result<GroupResponse[]>` |
|
||||||
|
| GET | `/api/group/getOne?groupId={guid}` | query: `groupId` | `Result<GroupResponse>` |
|
||||||
|
| POST | `/api/group/create` | `{ "name": "string|null" }` | `Result<GroupResponse>` |
|
||||||
|
| POST | `/api/group/update` | `GroupUpdateRequest` | `Result<GroupResponse>` |
|
||||||
|
| POST | `/api/group/dissolve?groupId={guid}` | 群 ID;仅群主 | `Result<object>` |
|
||||||
|
|
||||||
|
`name` 最大 20 字符。更新请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"groupId": "guid",
|
||||||
|
"groupName": "string|null",
|
||||||
|
"avatar": "string|null",
|
||||||
|
"description": "string|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`description` 实际用于更新群公告 `announcement`。
|
||||||
|
|
||||||
|
`GroupResponse`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"name": "string",
|
||||||
|
"groupMaster": "guid",
|
||||||
|
"authority": "REQUIRE_CONSENT|ANYONE_CAN_JOIN|NOT_ALLOWED_TO_JOIN",
|
||||||
|
"allMembersBanned": false,
|
||||||
|
"status": "Normal|Blocked",
|
||||||
|
"announcement": "string",
|
||||||
|
"avatar": "string|null",
|
||||||
|
"maxSequenceId": 0,
|
||||||
|
"lastMessage": "string",
|
||||||
|
"lastSenderName": "string",
|
||||||
|
"created": "datetime-offset",
|
||||||
|
"updated": "datetime-offset"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`getAll` 按当前用户的有效群成员关系返回群,普通成员、管理员和群主均可看到已加入群。
|
||||||
|
|
||||||
|
### 4.2 群成员
|
||||||
|
|
||||||
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/groupMember/checkMember?userId={guid}&groupId={guid}` | 用户 ID、群 ID | `Result<boolean>` |
|
||||||
|
| GET | `/api/groupMember/list?groupId={guid}` | 群 ID | `Result<GroupMemberResponse[]>` |
|
||||||
|
| POST | `/api/groupMember/delete?memberId={guid}` | 群成员记录 ID | `Result<object>` |
|
||||||
|
| POST | `/api/groupMember/leave?groupId={guid}` | 群 ID | `Result<object>` |
|
||||||
|
|
||||||
|
`GroupMemberResponse.role`:`Normal`、`Administrator`、`Master`。
|
||||||
|
|
||||||
|
`list`、`delete`、`leave` 均要求登录。`delete` 是管理操作,只能移除角色低于操作者的其他成员,禁止移除群主或自己;群主必须使用 `dissolve`,不能使用 `leave`。
|
||||||
|
|
||||||
|
`checkMember` 是服务间内部接口,必须携带 `X-Internal-Api-Key`,不应通过公网网关暴露。
|
||||||
|
|
||||||
|
### 4.3 群邀请
|
||||||
|
|
||||||
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/groupInvitation/send` | body: `{ "groupId": "guid", "userId": "guid" }` | `Result<GroupInvitationResponse>` |
|
||||||
|
| GET | `/api/groupInvitation/get?invitationId={guid}` | 邀请 ID | `Result<GroupInvitationResponse>` |
|
||||||
|
| POST | `/api/groupInvitation/handle?invitationId={guid}&action={value}` | `action=Accept|Reject` | `Result<object>` |
|
||||||
|
|
||||||
|
邀请状态:`Pending`、`Passed`、`Reject`。
|
||||||
|
|
||||||
|
### 4.4 入群申请
|
||||||
|
|
||||||
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/groupRequest/send` | body: `{ "groupId": "guid", "desc": "string|null" }` | `Result<GroupRequestResponse>` |
|
||||||
|
| POST | `/api/groupRequest/handle?requestId={guid}&action={value}` | `action=Accept|Reject` | `Result<object>` |
|
||||||
|
| GET | `/api/groupRequest/find?id={guid}` | 申请 ID | `Result<GroupRequestResponse>` |
|
||||||
|
| GET | `/api/groupRequest/list` | 无 | `Result<GroupRequestResponse[]>` |
|
||||||
|
|
||||||
|
`desc` 最大 20 字符;状态为 `Pending`、`Declined` 或 `Passed`。
|
||||||
|
|
||||||
|
`list` 返回当前用户提交的申请,以及当前用户作为管理员或群主有权处理的群申请。
|
||||||
|
|
||||||
|
## 5. 会话与消息
|
||||||
|
|
||||||
|
所有接口均要求认证。
|
||||||
|
|
||||||
|
### 5.1 会话
|
||||||
|
|
||||||
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/conversation/list` | 无 | `Result<ConversationResponse[]>` |
|
||||||
|
| GET | `/api/conversation/get?id={guid}` | 会话 ID | `Result<ConversationResponse>` |
|
||||||
|
| POST | `/api/conversation/markRead?conversationId={guid}` | 会话 ID | `Result<object>` |
|
||||||
|
|
||||||
|
`ConversationResponse`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"userId": "guid",
|
||||||
|
"targetId": "guid",
|
||||||
|
"targetAvatar": "string",
|
||||||
|
"targetName": "string",
|
||||||
|
"lastReadSequenceId": 0,
|
||||||
|
"unreadCount": 0,
|
||||||
|
"chatType": "PRIVATE|GROUP",
|
||||||
|
"lastMessage": "string",
|
||||||
|
"dateTime": "datetime"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 POST `/api/message/send`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clientMsgId": "guid",
|
||||||
|
"targetId": "guid",
|
||||||
|
"chatType": "PRIVATE|GROUP",
|
||||||
|
"msgType": "Text|Image|Voice|Video|File|VoiceChat|VideoChat",
|
||||||
|
"quoteMessageId": "guid|null",
|
||||||
|
"ext": { "key": "value" },
|
||||||
|
"text": "string|null",
|
||||||
|
"url": "string|null",
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"thumb": "string|null",
|
||||||
|
"duration": 0,
|
||||||
|
"fileId": "guid|null",
|
||||||
|
"fileName": "string|null",
|
||||||
|
"fileSize": 0,
|
||||||
|
"fileFormat": "mime/type|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `clientMsgId`、`targetId` 必填。
|
||||||
|
- `Text` 要求 `text`。
|
||||||
|
- `Image`、`Video`、`Voice` 至少提供 `url` 或 `fileId`。
|
||||||
|
- `File` 要求 `fileId`、`fileName`、`fileSize`、`fileFormat`;服务端已支持构建和保存文件消息。
|
||||||
|
- `VoiceChat`、`VideoChat` 仍未实现,发送会返回 `2303`。
|
||||||
|
|
||||||
|
成功返回 `Result<MessageResponse>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"clientMsgId": "guid",
|
||||||
|
"chatType": "PRIVATE",
|
||||||
|
"msgType": "Text",
|
||||||
|
"senderId": "guid",
|
||||||
|
"targetId": "guid",
|
||||||
|
"state": "Sent|Withdrwan",
|
||||||
|
"streamKey": "string",
|
||||||
|
"sequenceId": 1,
|
||||||
|
"creationTime": "datetime-offset",
|
||||||
|
"content": {
|
||||||
|
"fallback": "string",
|
||||||
|
"body": {},
|
||||||
|
"ext": {},
|
||||||
|
"quote": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 其他消息接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 参数 | 返回 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/message/withDraw?msgId={guid}` | 消息 ID | `Result<object>` |
|
||||||
|
| GET | `/api/message/getMessages?conversationId={guid}&cursor={long?}&direction={int}&limit={int}` | 会话、游标、方向、条数 | `Result<GetMessagesResponse>` |
|
||||||
|
|
||||||
|
`GetMessagesResponse`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "messages": [], "hasmore": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
方向约定:`0` 查询 `SequenceId < cursor` 的历史消息;`1` 查询 `SequenceId > cursor` 的增量消息。两个方向都查询 `limit + 1` 条判断 `hasmore`,但响应最多返回 `limit` 条并按序列号升序;`limit` 范围为 1–100。
|
||||||
|
|
||||||
|
## 6. 文件服务
|
||||||
|
|
||||||
|
所有接口均要求认证。
|
||||||
|
|
||||||
|
### 6.1 文件
|
||||||
|
|
||||||
|
#### POST `/api/file/simple-upload`
|
||||||
|
|
||||||
|
`multipart/form-data`:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 |
|
||||||
|
|---|---|---|
|
||||||
|
| `file` | 文件 | 是 |
|
||||||
|
| `isPublic` | boolean | 是 |
|
||||||
|
|
||||||
|
服务端计算 MD5 并执行安全秒传:公开文件可复用,私有文件只允许同一所有者复用。返回 `Result<FileResponse>`。
|
||||||
|
|
||||||
|
#### GET `/api/file/{id}`
|
||||||
|
|
||||||
|
返回文件信息 `Result<FileResponse>`。
|
||||||
|
|
||||||
|
#### GET `/api/file/{id}/content`
|
||||||
|
|
||||||
|
返回鉴权后的二进制文件流,支持 HTTP Range,不使用 `Result<T>` 包装。无权限返回 HTTP 403,文件不存在返回 HTTP 404。
|
||||||
|
|
||||||
|
`FileResponse` 为扁平结构,不暴露存储桶、对象键等内部位置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"ownerId": "guid",
|
||||||
|
"fileName": "avatar.png",
|
||||||
|
"fileSize": 123,
|
||||||
|
"contentType": "image/png",
|
||||||
|
"state": "Uploaded",
|
||||||
|
"checkSum": "md5-hex",
|
||||||
|
"chatType": "PRIVATE|GROUP|null",
|
||||||
|
"targetId": "guid|null",
|
||||||
|
"isPublic": false,
|
||||||
|
"created": "datetime-offset",
|
||||||
|
"updated": "datetime-offset",
|
||||||
|
"url": "string|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` 仅在存储提供方能够生成公开地址时存在。
|
||||||
|
|
||||||
|
### 6.2 分片上传
|
||||||
|
|
||||||
|
#### POST `/api/fileTask/init`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversationId": "guid",
|
||||||
|
"chatType": "PRIVATE|GROUP",
|
||||||
|
"targetId": "peer-or-group-guid",
|
||||||
|
"fileName": "string",
|
||||||
|
"fileSize": 123,
|
||||||
|
"contentType": "mime/type",
|
||||||
|
"checkSum": "md5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"taskId": "guid",
|
||||||
|
"uploadSessionId": "string",
|
||||||
|
"instant": false,
|
||||||
|
"uploadMode": "LocalMultipart|Presigned",
|
||||||
|
"totalPartCount": 1,
|
||||||
|
"partSizeBytes": 5242880,
|
||||||
|
"file": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
秒传命中时返回 `instant: true`、`uploadMode: "Instant"` 和最终 `file`;客户端直接使用该文件,不再调用 `complete`。
|
||||||
|
|
||||||
|
#### GET `/api/fileTask/getuploadurl?sessionId={value}&partNum={n}`
|
||||||
|
|
||||||
|
返回 `Result<PresignedUrl>`,不是字符串:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "string",
|
||||||
|
"method": "PUT|POST",
|
||||||
|
"headers": {},
|
||||||
|
"expiresAt": "datetime-offset"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET `/api/fileTask/progress?sessionId={value}`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "string",
|
||||||
|
"taskId": "string",
|
||||||
|
"fileSize": 123,
|
||||||
|
"totalPartCount": 1,
|
||||||
|
"completedPartCount": 0,
|
||||||
|
"uploadedBytes": 0,
|
||||||
|
"progressPercent": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST `/api/fileTask/local/parts/upload`
|
||||||
|
|
||||||
|
`multipart/form-data`:`sessionId`、`partNumber`、`file`。返回 `Result<CompleteUploadResult>`,其中包含 `location`、`eTag`、`size`、`checksum` 和 `versionId`。
|
||||||
|
|
||||||
|
#### POST `/api/fileTask/complete`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "string",
|
||||||
|
"parts": [
|
||||||
|
{ "partNumber": 1, "eTag": "string", "size": 123, "checksum": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功接受异步合并时返回 HTTP 202 + `Result<UploadTaskResponse>`,其中 `state` 为 `Merging`。
|
||||||
|
|
||||||
|
#### GET `/api/fileTask/status?taskId={guid}`
|
||||||
|
|
||||||
|
客户端轮询此接口。处理中返回 `Uploading|Merging`;失败返回 `state: "Failed"` 与 `failureReason`;完成后返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "upload-task-guid",
|
||||||
|
"uploaderId": "guid",
|
||||||
|
"conversationId": "guid",
|
||||||
|
"fileName": "string",
|
||||||
|
"fileSize": 123,
|
||||||
|
"contentType": "mime/type",
|
||||||
|
"storageLocation": {},
|
||||||
|
"state": "Completed",
|
||||||
|
"checkSum": "string",
|
||||||
|
"resultFileId": "guid",
|
||||||
|
"failureReason": null,
|
||||||
|
"file": { "id": "guid", "fileName": "string", "fileSize": 123, "url": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
任务、分片、进度、完成和状态接口都会校验当前用户是上传者。私有文件的 `url` 为 null,使用 `/api/file/{id}/content` 鉴权读取。
|
||||||
|
|
||||||
|
## 7. SignalR
|
||||||
|
|
||||||
|
- Hub:`/chat`
|
||||||
|
- 服务端事件:`ReceiveNewMessage`
|
||||||
|
- 当前 Hub 没有 `clearUnreadCount` 方法;清零未读应调用 HTTP `/api/conversation/markRead`。
|
||||||
|
|
||||||
|
推送载荷与 HTTP `MessageResponse` 略有不同:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"clientId": "guid",
|
||||||
|
"chatType": "PRIVATE|GROUP",
|
||||||
|
"msgType": "Text|Image|Voice|Video",
|
||||||
|
"senderId": "guid",
|
||||||
|
"targetId": "guid",
|
||||||
|
"state": "Sent|Withdrwan",
|
||||||
|
"streamKey": "string",
|
||||||
|
"sequenceId": 1,
|
||||||
|
"pushTimestamp": 0,
|
||||||
|
"content": {
|
||||||
|
"fallback": "string",
|
||||||
|
"body": {},
|
||||||
|
"ext": {},
|
||||||
|
"quote": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 业务状态码
|
||||||
|
|
||||||
|
| 范围/代码 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0` | 成功 |
|
||||||
|
| `1000`–`1006` | 系统、超时、参数、数据库、权限、认证错误 |
|
||||||
|
| `2000`–`2004` | 用户不存在、已存在、密码错误、禁用、登录过期 |
|
||||||
|
| `2100`–`2107` | 好友申请、好友关系和操作错误 |
|
||||||
|
| `2200`–`2206` | 群不存在、已入群、群满、权限、邀请、申请、成员错误 |
|
||||||
|
| `2300`–`2303` | 消息发送、消息不存在、撤回、不支持的消息类型 |
|
||||||
|
| `2400`–`2403` | 文件上传、不存在、过大、类型不支持 |
|
||||||
|
| `3000`–`3004` | 管理后台错误 |
|
||||||
|
| `3100` | 会话不存在 |
|
||||||
|
| `3201`–`3206` | 分片不存在、合并失败、分片过小/数量不符、会话过期、分片号无效 |
|
||||||
|
|
||||||
|
完整名称与中文说明以 `IM.Commons/ResultCode.cs` 为准。
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 前后端 API 修复实施报告
|
||||||
|
|
||||||
|
## 1. 结论与基线
|
||||||
|
|
||||||
|
修复计划中的核心阻断项已完成代码落地:认证刷新、会话映射与未读、消息双向分页、群列表/审批/退群/解散、异步分片上传、普通文件消息、私有文件鉴权读取及多环境 CSP 已对齐。
|
||||||
|
|
||||||
|
| 项目 | 基线/结果 |
|
||||||
|
|---|---|
|
||||||
|
| 后端 | `IM_NEW/codex/api-alignment-fixes`,起点 `32177a7293ec9eb7bd731467953da3c51e561beb` |
|
||||||
|
| 前端 | `feature-nxdev@f1af6e6` 的现有工作区上增量修改,未覆盖用户原有未提交改动 |
|
||||||
|
| 后端构建 | `dotnet build IM_API_NEW.sln --no-restore`:0 错误 |
|
||||||
|
| 前端构建 | `npm run build`:通过 |
|
||||||
|
| 前端测试 | `npm test`:1 个测试文件、3 个上传状态机测试全部通过 |
|
||||||
|
| 定向 ESLint | 本次涉及的前端文件使用 `npx eslint --quiet`:0 错误 |
|
||||||
|
| 运行态联调 | 待部署迁移并启动 MySQL、Redis、RabbitMQ、Consul 和网关后执行 |
|
||||||
|
|
||||||
|
## 2. 已完成修复
|
||||||
|
|
||||||
|
### 认证与公共异常
|
||||||
|
|
||||||
|
- JWT Challenge/Forbidden 改为标准 HTTP 401/403,保留统一业务响应体。
|
||||||
|
- 前端同时兼容新版 HTTP 401 和旧版 HTTP 200 + `code=1006`。
|
||||||
|
- Token 刷新采用单飞队列;刷新成功重放请求,失败拒绝全部排队请求并退出登录。
|
||||||
|
- 全局异常统一返回 HTTP 500 + `SYSTEM_ERROR`,通过 `X-Correlation-ID` 对应服务端日志。
|
||||||
|
- RabbitMQ 消费增加短间隔重试,降低数据库提交与消息消费竞态造成的偶发失败。
|
||||||
|
|
||||||
|
### 会话与消息
|
||||||
|
|
||||||
|
- `ConversationResponse.dateTime` 改为 `DateTimeOffset`,映射使用 `ModificationTime ?? CreationTime`。
|
||||||
|
- 会话更新拆分为更新摘要、增加未读和推进已读序号,群聊与私聊均更新参与者会话。
|
||||||
|
- 会话创建消费者增加幂等判断。
|
||||||
|
- `direction=0/1` 分别使用 `< cursor`、`> cursor`,响应裁剪为 `limit` 并保持升序;`limit` 限制为 1–100。
|
||||||
|
- 前端断线补消息携带本地最大 `sequenceId`,IndexedDB 保存完整结构化消息。
|
||||||
|
|
||||||
|
### 群组、审批和权限
|
||||||
|
|
||||||
|
- 群列表按有效成员关系查询,普通成员也能看到已加入群。
|
||||||
|
- 入群申请列表包含申请人自己的记录,以及管理员/群主可处理的目标群申请。
|
||||||
|
- 群详情和成员列表验证当前用户是有效成员。
|
||||||
|
- 成员移除严格比较角色,禁止移除自己、群主或同级/更高角色。
|
||||||
|
- 新增 `/api/groupMember/leave` 和 `/api/group/dissolve`;群主只能解散,其他成员可退出。
|
||||||
|
- 退出或解散通过事件软删除对应群会话;前端同步移除群、会话和 IndexedDB 缓存。
|
||||||
|
- 内部成员检查要求 `X-Internal-Api-Key`,MessageService/FileService 使用服务端密钥调用。
|
||||||
|
|
||||||
|
### 文件上传与文件消息
|
||||||
|
|
||||||
|
- 初始化明确返回 `instant`、`uploadMode`、`totalPartCount`、`partSizeBytes` 和秒传 `file`。
|
||||||
|
- 前端遵循服务端分片大小、预签名 method/headers;对象存储请求不携带业务 JWT。
|
||||||
|
- 任一分片失败会使整体失败,不再吞错后调用 complete。
|
||||||
|
- Vitest 覆盖秒传、正常分片异步完成和分片持续失败不得 complete。
|
||||||
|
- complete 返回 HTTP 202,前端轮询 `/api/fileTask/status`,拿到最终 `fileId` 后才发送消息。
|
||||||
|
- 消费者以 `SourceTaskId` 幂等创建最终文件,失败写入 `FailureReason`。
|
||||||
|
- 上传地址、分片、进度、完成和状态都校验上传者;秒传按公开/私聊/群聊作用域复用。
|
||||||
|
- 文件响应扁平化并隐藏内部位置;私有文件通过 `/api/file/{id}/content` 鉴权读取并支持 Range。
|
||||||
|
- MessageService 支持 `File`,媒体消息支持稳定 `fileId`;前端可预览和下载普通文件消息。
|
||||||
|
- 小文件上传由服务端计算 MD5,文件名支持 255 字符。
|
||||||
|
|
||||||
|
### 环境与部署
|
||||||
|
|
||||||
|
- 开发/生产默认网关统一为 `localhost:8009`。
|
||||||
|
- Electron CSP 根据 `VITE_API_BASE_URL`、`VITE_SIGNALR_BASE_URL` 在构建时生成,不再固定测试网 IP。
|
||||||
|
- Docker Compose 为 Group/Message/File 服务注入必填的 `IM_INTERNAL_API_KEY`。
|
||||||
|
|
||||||
|
## 3. 数据库变更与影响面
|
||||||
|
|
||||||
|
| 服务 | 迁移 | 影响 |
|
||||||
|
|---|---|---|
|
||||||
|
| MessageService | `20260909000100_ApiAlignmentFixes` | 新增会话复合索引;不改消息数据 |
|
||||||
|
| GroupService | `20260909000200_ApiAlignmentFixes` | 新增成员、角色、申请查询索引和申请 ID 唯一索引 |
|
||||||
|
| FileService | `20260909000300_AsyncUploadResult` | 增加上传作用域、结果、失败原因、公开标记和来源任务;收紧字符串列并新增索引 |
|
||||||
|
|
||||||
|
迁移不物理删除业务数据。文件列由 `longtext` 收紧前,应检查历史值长度;`SourceTaskId` 建唯一索引前,应确认没有重复回填值。
|
||||||
|
|
||||||
|
## 4. 发布顺序
|
||||||
|
|
||||||
|
1. 备份三个服务数据库,执行历史字段长度与唯一性预检。
|
||||||
|
2. 设置同一个非空 `IM_INTERNAL_API_KEY`,配置 FileService 的公开桶/公开地址、存储、Redis 和 RabbitMQ 参数。
|
||||||
|
3. 先发布兼容新旧认证协议的前端。
|
||||||
|
4. 执行 Message、Group、File 三个迁移并发布后端。
|
||||||
|
5. 发布新版前端,确认 CSP 只包含当前环境的 API/SignalR 源。
|
||||||
|
6. 用两个隔离账号执行运行态验收。
|
||||||
|
|
||||||
|
## 5. 部署后验收清单
|
||||||
|
|
||||||
|
- Token 过期时并发请求只刷新一次;401/403 正确,刷新失败统一退出。
|
||||||
|
- 会话列表不再 500;私聊/群聊连续消息未读数正确,markRead 清零。
|
||||||
|
- 两个分页方向无重复、无漏页、每页不超过 limit;断线后只补新消息。
|
||||||
|
- 普通成员能看到群;群主/管理员能看到待审批申请;退群、解散、越权移人符合规则。
|
||||||
|
- 本地分片与预签名上传均完成 `init → parts → complete → status`;秒传直接返回最终文件。
|
||||||
|
- 图片、视频、语音和普通文件可发送、SignalR 接收、历史恢复、预览/下载。
|
||||||
|
- 私聊第三方不能读取文件;退群成员不能读取群文件;公开头像 URL 非空。
|
||||||
|
- 重复投递上传完成事件不会创建重复文件。
|
||||||
|
|
||||||
|
## 6. 当前保留项
|
||||||
|
|
||||||
|
- 本轮未启动完整依赖栈执行真实迁移和双账号端到端联调;这属于发布环境验证,不能由编译结果替代。
|
||||||
|
- 项目原有全量 ESLint 基线仍有历史问题;本次只保证涉及文件的 `--quiet` 定向检查为 0 错误。
|
||||||
|
- 后端仓库原先没有自动化测试项目;本轮完成全解决方案编译,数据库/MQ 行为仍以部署后集成验收为准。
|
||||||
|
- `VoiceChat`、`VideoChat` 消息仍不在本次实现范围。
|
||||||
|
- 私有文件采用鉴权内容接口而非暴露短时下载 URL,客户端必须携带 JWT 获取 Blob。
|
||||||
@@ -1,20 +1,43 @@
|
|||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { defineConfig } from 'electron-vite'
|
import { defineConfig } from 'electron-vite'
|
||||||
|
import { loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
// 从 VITE_API_BASE_URL 推导网关源(去掉末尾 /api)
|
||||||
|
const apiBase = env.VITE_API_BASE_URL || 'http://localhost:8009/api'
|
||||||
|
const signalRBase = env.VITE_SIGNALR_BASE_URL || 'http://localhost:8009/chat'
|
||||||
|
const gatewayOrigin = new URL(apiBase).origin
|
||||||
|
const signalROrigin = new URL(signalRBase).origin
|
||||||
|
const signalRWebSocketOrigin = signalROrigin.replace(/^http/, 'ws')
|
||||||
|
const cspPlugin = {
|
||||||
|
name: 'environment-csp',
|
||||||
|
transformIndexHtml: (html) => html
|
||||||
|
.replaceAll('__API_ORIGIN__', gatewayOrigin)
|
||||||
|
.replaceAll('__SIGNALR_ORIGIN__', signalROrigin)
|
||||||
|
.replaceAll('__SIGNALR_WS_ORIGIN__', signalRWebSocketOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
main: {},
|
main: {},
|
||||||
preload: {},
|
preload: {},
|
||||||
renderer: {
|
renderer: {
|
||||||
server: {
|
server: {
|
||||||
host: true
|
host: true,
|
||||||
|
// 开发环境代理,规避浏览器 CORS(Electron 内不受影响)
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: gatewayOrigin, changeOrigin: true },
|
||||||
|
'/chat': { target: gatewayOrigin, changeOrigin: true, ws: true }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve('src/renderer/src')
|
'@': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [vue(), vueDevTools()]
|
plugins: [vue(), vueDevTools(), cspPlugin]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+286
-7
@@ -40,6 +40,7 @@
|
|||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vite-plugin-vue-devtools": "^8.0.7",
|
"vite-plugin-vue-devtools": "^8.0.7",
|
||||||
|
"vitest": "^5.0.0",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
}
|
}
|
||||||
@@ -2474,6 +2475,17 @@
|
|||||||
"@types/responselike": "^1.0.0"
|
"@types/responselike": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -2484,6 +2496,13 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2595,6 +2614,64 @@
|
|||||||
"vue": "^3.2.25"
|
"vue": "^3.2.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "0.3.31",
|
||||||
|
"@vitest/spy": "5.0.0",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^1.2.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/babel-helper-vue-transform-on": {
|
"node_modules/@vue/babel-helper-vue-transform-on": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
|
||||||
@@ -3235,6 +3312,16 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/astral-regex": {
|
"node_modules/astral-regex": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||||
@@ -3699,6 +3786,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -4771,6 +4868,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
@@ -5236,6 +5340,16 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -6999,6 +7113,20 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ohash": {
|
"node_modules/ohash": {
|
||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
|
||||||
@@ -7261,9 +7389,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7845,6 +7973,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
@@ -8015,6 +8150,13 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stat-mode": {
|
"node_modules/stat-mode": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz",
|
||||||
@@ -8025,6 +8167,13 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -8282,15 +8431,35 @@
|
|||||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -9268,6 +9437,99 @@
|
|||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/mocker": "5.0.0",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"es-module-lexer": "^2.3.2",
|
||||||
|
"expect-type": "^1.4.0",
|
||||||
|
"magic-string": "^1.2.3",
|
||||||
|
"obug": "^2.1.4",
|
||||||
|
"picomatch": "^4.0.7",
|
||||||
|
"std-env": "^4.2.0",
|
||||||
|
"tinybench": "6.1.4",
|
||||||
|
"tinyexec": "1.3.0",
|
||||||
|
"tinyglobby": "^0.2.17",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "5.0.0",
|
||||||
|
"@vitest/browser-preview": "5.0.0",
|
||||||
|
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||||
|
"@vitest/coverage-istanbul": "5.0.0",
|
||||||
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
|
"@vitest/ui": "5.0.0",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/magic-string": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.28",
|
"version": "3.5.28",
|
||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz",
|
||||||
@@ -9389,6 +9651,23 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
|
"test": "vitest run",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"build:win": "npm run build && electron-builder --win",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vite-plugin-vue-devtools": "^8.0.7",
|
"vite-plugin-vue-devtools": "^8.0.7",
|
||||||
|
"vitest": "^5.0.0",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||||
import { join } from 'path'
|
import path from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { registerWindowHandler } from './ipcHandlers/window'
|
import { registerWindowHandler } from './ipcHandlers/window'
|
||||||
import { createTry } from './trayHandler'
|
import { createTry } from './trayHandler'
|
||||||
import { registerCacheHandler } from './ipcHandlers/cache'
|
import { registerCacheHandler } from './ipcHandlers/cache'
|
||||||
import { addProtocolHandler } from '../cache/protocolReg'
|
import { addProtocolHandler } from '../cache/protocolReg'
|
||||||
|
import { CACHE_ROOT } from '../cache/cacheDir'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
|
||||||
|
let mainWindow = null
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 900,
|
||||||
height: 670,
|
height: 670,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
frame:false,
|
frame:false,
|
||||||
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
|
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
|
||||||
icon: join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
|
icon: path.join(__dirname, '../../resources/icon.png'), // Windows 开发环境预览
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false
|
sandbox: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -40,7 +44,7 @@ function createWindow() {
|
|||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +67,48 @@ app.whenReady().then(() => {
|
|||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
|
||||||
|
// 开机自启
|
||||||
|
ipcMain.on('setting-autoStart', (_event, enable) => {
|
||||||
|
app.setLoginItemSettings({ openAtLogin: !!enable })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理磁盘文件缓存
|
||||||
|
ipcMain.handle('cache-clear-disk', async () => {
|
||||||
|
try {
|
||||||
|
await fs.emptyDir(CACHE_ROOT)
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取磁盘缓存大小
|
||||||
|
ipcMain.handle('cache-disk-size', async () => {
|
||||||
|
try {
|
||||||
|
let total = 0
|
||||||
|
const walk = async (dir) => {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = path.join(dir, e.name)
|
||||||
|
if (e.isDirectory()) { await walk(p) }
|
||||||
|
else { try { total += (await fs.stat(p)).size } catch { /* Ignore files removed during traversal. */ } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (await fs.pathExists(CACHE_ROOT)) await walk(CACHE_ROOT)
|
||||||
|
return { success: true, size: total }
|
||||||
|
} catch {
|
||||||
|
return { success: false, size: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新消息任务栏/托盘闪动
|
||||||
|
ipcMain.on('window-flash', () => {
|
||||||
|
if (mainWindow && !mainWindow.isFocused()) {
|
||||||
|
mainWindow.flashFrame(true)
|
||||||
|
// 也可以设置托盘图标高亮
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
registerWindowHandler()
|
registerWindowHandler()
|
||||||
registerCacheHandler()
|
registerCacheHandler()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { app, Tray, Menu, nativeImage } from 'electron'
|
import { app, Tray, Menu, nativeImage } from 'electron'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
let tray = null;
|
let tray = null;
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ const api = {
|
|||||||
newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }),
|
newWindow: (route, data, width, height) => ipcRenderer.send('window-new', { route, data, width, height }),
|
||||||
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId),
|
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId),
|
||||||
setMainSize: (width, height, resizable = true) =>
|
setMainSize: (width, height, resizable = true) =>
|
||||||
ipcRenderer.send('window-action', 'changeSize', { width, height, resizable })
|
ipcRenderer.send('window-action', 'changeSize', { width, height, resizable }),
|
||||||
|
flash: () => ipcRenderer.send('window-flash')
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type)
|
getCache: (url, type) => ipcRenderer.invoke('cache-get', url, type),
|
||||||
|
clearDisk: () => ipcRenderer.invoke('cache-clear-disk'),
|
||||||
|
diskSize: () => ipcRenderer.invoke('cache-disk-size')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
<title>Electron</title>
|
<title>Electron</title>
|
||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self' http://192.168.5.100:8009 ql-im:;
|
content="default-src 'self' ql-im:;
|
||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
connect-src 'self' http://localhost:5202 ws://localhost:5202 http://192.168.5.100:8009 ws://192.168.5.100:8009;
|
connect-src 'self' __API_ORIGIN__ __SIGNALR_ORIGIN__ __SIGNALR_WS_ORIGIN__;
|
||||||
img-src 'self' data: blob: https: http: ql-im:;
|
img-src 'self' data: blob: https: http: ql-im:;
|
||||||
font-src 'self' data:;
|
font-src 'self' data:;
|
||||||
media-src 'self' blob: http://192.168.5.100:8009; ql-im:">
|
media-src 'self' blob: __API_ORIGIN__ ql-im:">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Alert from '@/components/messages/Alert.vue';
|
import Alert from '@/components/messages/Alert.vue';
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import { useAuthStore } from './stores/auth';
|
|
||||||
//import { useSignalRStore } from './stores/signalr';
|
//import { useSignalRStore } from './stores/signalr';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<img :src="src" :class="$attrs.class" v-bind="filteredAttrs" @error="onErr" />
|
||||||
<div class="img-container">
|
|
||||||
<img :src="finalUrl" alt="" v-bind="$attrs" @error="imgLoadErrHandler">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, ref, useAttrs, watch } from 'vue';
|
||||||
|
import default_avatar from '@/assets/default_avatar.png';
|
||||||
|
import loading_img from '@/assets/loading_img.png';
|
||||||
|
|
||||||
import { onMounted, ref, watch } from 'vue';
|
const attrs = useAttrs();
|
||||||
import { useCacheStore } from '../stores/cache';
|
const filteredAttrs = computed(() => {
|
||||||
import { FILE_TYPE } from '../constants/fileTypeDefine';
|
const rest = { ...attrs };
|
||||||
import default_avatar from '@/assets/default_avatar.png'
|
delete rest.class;
|
||||||
import loading_img from '@/assets/loading_img.png'
|
return rest;
|
||||||
|
});
|
||||||
const cacheStore = useCacheStore()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
rawUrl: {
|
rawUrl: { type: [String, null], default: null },
|
||||||
type: String,
|
noAvatar: { type: Boolean, default: false }
|
||||||
required: true
|
});
|
||||||
},
|
|
||||||
noAvatar: {
|
const fallbackImg = props.noAvatar ? loading_img : default_avatar;
|
||||||
type: Boolean,
|
const error = ref(false);
|
||||||
default: false
|
|
||||||
|
const src = computed(() => {
|
||||||
|
if (error.value) return props.noAvatar ? loading_img : default_avatar;
|
||||||
|
if (props.rawUrl && props.rawUrl !== '') {
|
||||||
|
return props.rawUrl;
|
||||||
}
|
}
|
||||||
})
|
return fallbackImg;
|
||||||
|
});
|
||||||
const finalUrl = ref(props.noAvatar ? loading_img : default_avatar)
|
|
||||||
|
|
||||||
const imgLoadErrHandler = (e) => {
|
|
||||||
e.target.src = loading_img
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.rawUrl,
|
|
||||||
(newVal) => {
|
|
||||||
if (!props.rawUrl || props.rawUrl == '') return
|
|
||||||
cacheStore.getCache(props.rawUrl, FILE_TYPE.Image).then(res => {
|
|
||||||
finalUrl.value = res
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
|
const onErr = () => { error.value = true; };
|
||||||
|
watch(() => props.rawUrl, () => { error.value = false; });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, useAttrs, onMounted } from 'vue';
|
import { defineProps, useAttrs } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// 按钮样式变体:primary, secondary, danger, text
|
// 按钮样式变体:primary, secondary, danger, text
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineProps, onMounted, defineEmits } from 'vue';
|
import { ref, defineProps, defineEmits } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
menuList: {
|
menuList: {
|
||||||
|
|||||||
@@ -3,14 +3,19 @@
|
|||||||
@click="routeUserInfo(c.id)">
|
@click="routeUserInfo(c.id)">
|
||||||
<AsyncImage :raw-url="c.avatar" class="avatar-std" />
|
<AsyncImage :raw-url="c.avatar" class="avatar-std" />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">{{ c.remarkName }}</div>
|
<div class="name">{{ c.remarkName || c.nickName }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!props.contacts || props.contacts.length === 0" class="empty-placeholder">
|
||||||
|
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
|
||||||
|
<p class="empty-text">暂无好友</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, computed } from 'vue';
|
import { defineProps, computed } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import feather from 'feather-icons';
|
||||||
import AsyncImage from '../AsyncImage.vue';
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -20,8 +25,8 @@ const activeContactId = computed(() => route.params.id)
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contacts: {
|
contacts: {
|
||||||
type: String,
|
type: Array,
|
||||||
required: true
|
default: () => []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,6 +38,16 @@ const routeUserInfo = (id) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
.empty-placeholder i { color: #ccc; margin-bottom: 10px; line-height: 0; }
|
||||||
|
.empty-text { font-size: 13px; color: #999; margin: 0; }
|
||||||
|
|
||||||
.list-item {
|
.list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -68,12 +83,7 @@ a:focus {
|
|||||||
background: #c6c6c6;
|
background: #c6c6c6;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.avatar-std) {
|
:deep(.avatar-std) { width: 36px; height: 36px; border-radius: 4px; flex-shrink: 0; }
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-box {
|
.icon-box {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ onMounted(async () => {
|
|||||||
imageList.value = data.imageList;
|
imageList.value = data.imageList;
|
||||||
index.value = data.index;
|
index.value = data.index;
|
||||||
previewImages({
|
previewImages({
|
||||||
imgList: imageList.value.map(m => m.content.url),
|
imgList: imageList.value.map(m => m.url || (m.content?.body?.url) || m.localUrl),
|
||||||
nowImgIndex: index,
|
nowImgIndex: index,
|
||||||
clickMaskCLose: false,
|
clickMaskCLose: false,
|
||||||
disabledImgRightClick:true,
|
disabledImgRightClick:true,
|
||||||
|
|||||||
@@ -1,52 +1,46 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, defineEmits } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { friendService } from '../../services/friend';
|
import { friendService } from '../../services/friend';
|
||||||
import { groupService } from '@/services/group';
|
|
||||||
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
|
||||||
import { useMessage } from '../messages/useAlert';
|
|
||||||
import AsyncImage from '../AsyncImage.vue';
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
|
|
||||||
const isLoaded = ref(false);
|
|
||||||
|
|
||||||
const isError = ref(false)
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: Boolean,
|
modelValue: Boolean,
|
||||||
type: {
|
type: { type: String, default: 'CreateGroup' },
|
||||||
/**@type {"CreateGroup" | "InviteUser"} */
|
title: { type: String, default: '创建群聊' },
|
||||||
type: String,
|
/** 已在群的 userId 数组 */
|
||||||
default: 'CreateGroup',
|
excludeIds: { type: Array, default: () => [] }
|
||||||
validator: (value) => {
|
|
||||||
return ['CreateGroup', 'InviteUser'].includes(value)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '创建群聊'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['submit'])
|
const emits = defineEmits(['submit']);
|
||||||
|
|
||||||
const friends = ref([])
|
|
||||||
|
|
||||||
|
const friends = ref([]);
|
||||||
const groupName = ref('');
|
const groupName = ref('');
|
||||||
const selected = ref(new Set()); // 使用 Set 处理选中逻辑更简洁
|
const selected = ref(new Set());
|
||||||
|
|
||||||
const toggle = (id) => {
|
// 纯同步过滤
|
||||||
selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id);
|
const excludeSet = computed(() => new Set(props.excludeIds || []));
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
const available = computed(() =>
|
||||||
|
(friends.value || []).filter(f => !excludeSet.value.has(f.targetId))
|
||||||
|
);
|
||||||
|
|
||||||
emits('submit', selected.value, groupName.value)
|
const toggle = (id) => { selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id); };
|
||||||
};
|
const submit = () => emits('submit', selected.value, groupName.value);
|
||||||
|
|
||||||
onMounted(async () =>{
|
watch(() => props.modelValue, async (v) => {
|
||||||
friends.value = (await friendService.getFriendList()).data;
|
if (!v) return;
|
||||||
})
|
selected.value = new Set();
|
||||||
|
friends.value = [];
|
||||||
|
try {
|
||||||
|
const res = await friendService.getFriendList();
|
||||||
|
friends.value = (res.data || []).map(f => ({
|
||||||
|
...f,
|
||||||
|
friendId: f.targetId,
|
||||||
|
avatar: f.avatar,
|
||||||
|
nickName: f.remarkName || f.nickName,
|
||||||
|
}));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -60,18 +54,19 @@ onMounted(async () =>{
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<input v-if="props.type == 'CreateGroup'" v-model="groupName" placeholder="群组名称..." class="mini-input" />
|
<input v-if="props.type == 'CreateGroup'" v-model="groupName" placeholder="群组名称..." class="mini-input" />
|
||||||
|
|
||||||
<div class="list">
|
<div class="list">
|
||||||
<div v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item">
|
<div v-for="f in available" :key="f.friendId" class="item" @click="toggle(f.friendId)">
|
||||||
<AsyncImage :raw-url="f.userInfo.avatar" class="avatar" />
|
<AsyncImage :raw-url="f.avatar" class="avatar" />
|
||||||
<span class="name">{{ f.remarkName }}</span>
|
<span class="name">{{ f.nickName }}</span>
|
||||||
<input type="checkbox" :checked="selected.has(f.friendId)" />
|
<input type="checkbox" :checked="selected.has(f.friendId)" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="available.length === 0 && friends.length > 0" class="empty-hint">暂无可邀请的好友</div>
|
||||||
|
<div v-if="friends.length === 0" class="empty-hint">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<button @click="submit" :disabled="(!groupName&& props.type == 'CreateGroup') || !selected.size" class="btn">
|
<button @click="submit" :disabled="(!groupName && props.type == 'CreateGroup') || !selected.size" class="btn">
|
||||||
{{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
|
{{ props.type == 'CreateGroup' ? '创建' : '确定' }} ({{ selected.size }})
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -81,44 +76,19 @@ onMounted(async () =>{
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.overlay {
|
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
.mini-modal { background: white; width: 300px; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 999;
|
header { padding: 12px 16px; display: flex; justify-content: space-between; background: #f9f9f9; font-weight: bold; font-size: 14px; }
|
||||||
}
|
|
||||||
|
|
||||||
.mini-modal {
|
|
||||||
background: white; width: 300px; border-radius: 12px; overflow: hidden;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 12px 16px; display: flex; justify-content: space-between;
|
|
||||||
background: #f9f9f9; font-weight: bold; font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header button { background: none; border: none; cursor: pointer; color: #999; }
|
header button { background: none; border: none; cursor: pointer; color: #999; }
|
||||||
|
|
||||||
main { padding: 12px; }
|
main { padding: 12px; }
|
||||||
|
.mini-input { width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee; border-radius: 4px; box-sizing: border-box; outline: none; }
|
||||||
.mini-input {
|
|
||||||
width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #eee;
|
|
||||||
border-radius: 4px; box-sizing: border-box; outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list { max-height: 200px; overflow-y: auto; }
|
.list { max-height: 200px; overflow-y: auto; }
|
||||||
|
.item { display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px; }
|
||||||
.item {
|
|
||||||
display: flex; align-items: center; padding: 8px; cursor: pointer; border-radius: 6px;
|
|
||||||
}
|
|
||||||
.item:hover { background: #f5f5f5; }
|
.item:hover { background: #f5f5f5; }
|
||||||
|
:deep(.avatar) { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; flex-shrink: 0; }
|
||||||
:deep(.avatar) { width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; }
|
|
||||||
.name { flex: 1; font-size: 14px; }
|
.name { flex: 1; font-size: 14px; }
|
||||||
|
.empty-hint { text-align: center; padding: 20px; color: #999; font-size: 13px; }
|
||||||
footer { padding: 12px; }
|
footer { padding: 12px; }
|
||||||
.btn {
|
.btn { width: 100%; padding: 10px; background: #07c160; color: white; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; }
|
||||||
width: 100%; padding: 10px; background: #07c160; color: white;
|
|
||||||
border: none; border-radius: 6px; font-weight: bold; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
|
.btn:disabled { background: #e1e1e1; color: #999; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,29 +4,44 @@
|
|||||||
v-for="group in groups"
|
v-for="group in groups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
class="group-item"
|
class="group-item"
|
||||||
:class="{ active: activeId === group.id }"
|
:class="{ active: activeGroupId === group.id }"
|
||||||
@click="activeId = group.id; $emit('select', group)"
|
@click="routeGroupInfo(group)"
|
||||||
>
|
>
|
||||||
<img :src="group.avatar" class="group-avatar" />
|
<img :src="group.avatar" class="group-avatar" />
|
||||||
|
|
||||||
<span class="group-name">{{ group.name }}</span>
|
<span class="group-name">{{ group.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!groups || groups.length === 0" class="empty-placeholder">
|
||||||
|
<i v-html="feather.icons['users'].toSvg({ width: 36, height: 36 })"></i>
|
||||||
|
<p class="empty-text">暂无群聊</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import feather from 'feather-icons';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
groups: {
|
groups: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
// 数据结构仅需: { id, name, avatar }
|
// 数据结构: { id, name, avatar }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select']);
|
const emit = defineEmits(['select']);
|
||||||
const activeId = ref(null);
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const activeGroupId = computed(() => route.params.id);
|
||||||
|
|
||||||
|
const routeGroupInfo = (group) => {
|
||||||
|
emit('select', group);
|
||||||
|
router.push(`/contacts/group/${group.id}`);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -68,6 +83,16 @@ const activeId = ref(null);
|
|||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
.empty-placeholder i { color: #ccc; margin-bottom: 10px; line-height: 0; }
|
||||||
|
.empty-text { font-size: 13px; color: #999; margin: 0; }
|
||||||
|
|
||||||
.group-name {
|
.group-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
|
||||||
<aside class="group-info-sidebar">
|
<aside class="group-info-sidebar">
|
||||||
<div class="sidebar-scroll-content">
|
<div class="sidebar-scroll-content">
|
||||||
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card header-section">
|
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card header-section">
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<input type="file" style="display: none;" ref="input" @change="fileUploadHandler">
|
<input type="file" style="display: none;" ref="input" @change="fileUploadHandler">
|
||||||
<img :src="groupData.targetAvatar" class="group-main-avatar" @click="uploadGroupAvatar"/>
|
<img :src="groupData.targetAvatar" class="group-main-avatar" @click="uploadGroupAvatar"/>
|
||||||
@@ -13,20 +12,24 @@
|
|||||||
<p class="group-id">群ID: {{ groupData.targetId }}</p>
|
<p class="group-id">群ID: {{ groupData.targetId }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
|
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3 class="section-label">群公告</h3>
|
<h3 class="section-label">群公告</h3>
|
||||||
<button v-if="isAdmin" class="text-link">编辑</button>
|
<button v-if="isAdmin" class="text-link" @click="editingAnnouncement = !editingAnnouncement">{{ editingAnnouncement ? '完成' : '编辑' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="announcement-box">
|
<div v-if="editingAnnouncement" class="announcement-edit">
|
||||||
{{ groupInfo ? groupInfo.announcement : '暂无群公告,点击编辑添加。' }}
|
<textarea v-model="announcementText" class="announce-input" rows="3" placeholder="输入群公告..."></textarea>
|
||||||
|
<button class="save-announce-btn" @click="saveAnnouncement">保存公告</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="announcement-box">
|
||||||
|
{{ groupInfo?.announcement || '暂无群公告,点击编辑添加。' }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="chatType == MESSAGE_TYPE.GROUP" class="info-card">
|
<section v-if="chatType == CHAT_TYPE.GROUP" class="info-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3 class="section-label">群成员 <span class="count-tag">{{ groupInfo.members?.length || 0 }}</span></h3>
|
<h3 class="section-label">群成员 <span class="count-tag">{{ groupInfo.members?.length || 0 }}</span></h3>
|
||||||
<button class="text-link" @click="$emit('viewAll')">查看全部</button>
|
<button class="text-link" @click="showAllMembers = !showAllMembers">{{ showAllMembers ? '收起' : '查看全部' }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="member-grid">
|
<div class="member-grid">
|
||||||
@@ -38,15 +41,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="member in groupInfo.members?.slice(0, 11)"
|
v-for="member in (showAllMembers ? groupInfo.members : groupInfo.members?.slice(0, 11))"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="member-item"
|
class="member-item"
|
||||||
|
@click="handleMemberClick(member)"
|
||||||
>
|
>
|
||||||
<div class="member-avatar-box">
|
<div class="member-avatar-box">
|
||||||
<async-image :raw-url="member.avatar" class="member-img"/>
|
<async-image :raw-url="member.avatar" class="member-img"/>
|
||||||
<span v-if="member.role === GROUP_MEMBER_ROLE.ADMIN || member.role === GROUP_MEMBER_ROLE.MASTER" class="role-badge"></span>
|
<span v-if="member.role === GROUP_MEMBER_ROLE.ADMIN || member.role === GROUP_MEMBER_ROLE.MASTER" class="role-badge"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="member-nick">{{ member.nickname }}</span>
|
<span class="member-nick">{{ member.nickname || member.groupNickName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -54,43 +58,45 @@
|
|||||||
<section class="info-card settings-list">
|
<section class="info-card settings-list">
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<span>置顶聊天</span>
|
<span>置顶聊天</span>
|
||||||
<input type="checkbox" class="ios-switch" />
|
<input type="checkbox" class="ios-switch" :checked="isPinned" @change="togglePin" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<span>消息免打扰</span>
|
<span>消息免打扰</span>
|
||||||
<input type="checkbox" class="ios-switch" />
|
<input type="checkbox" class="ios-switch" :checked="isMuted" @change="toggleMute" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item arrow">
|
<div class="setting-item arrow" @click="$emit('searchInChat')">
|
||||||
<span>查找聊天记录</span>
|
<span>查找聊天记录</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="danger-zone">
|
<div v-if="chatType == CHAT_TYPE.GROUP" class="danger-zone">
|
||||||
<button class="danger-btn">删除并退出</button>
|
<button class="danger-btn" @click="handleExitGroup">{{ isMaster ? '解散群组' : '删除并退出' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友" @submit="inviteUserHandler"/>
|
<create-group v-model="groupInviteModal" type="InviteUser" title="邀请好友"
|
||||||
|
:excludeIds="groupMemberIds" @submit="inviteUserHandler"/>
|
||||||
</aside>
|
</aside>
|
||||||
</transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
|
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { MESSAGE_TYPE } from '../../constants/MessageType';
|
import { CHAT_TYPE } from '../../constants/MessageType';
|
||||||
import feather from 'feather-icons';
|
import feather from 'feather-icons';
|
||||||
import { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
|
import { GROUP_MEMBER_ROLE } from '../../constants/GroupDefine';
|
||||||
import { uploadService } from '../../services/upload/uploadService';
|
import { uploadService } from '../../services/upload/uploadService';
|
||||||
import { groupService } from '../../services/group';
|
import { groupService } from '../../services/group';
|
||||||
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
|
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
|
||||||
import { useMessage } from './useAlert';
|
import { useMessage } from './useAlert';
|
||||||
import { getFileHash } from '../../utils/uploadTools';
|
|
||||||
import CreateGroup from '../groups/CreateGroup.vue';
|
import CreateGroup from '../groups/CreateGroup.vue';
|
||||||
import AsyncImage from '../AsyncImage.vue';
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { useConversationStore } from '../../stores/conversation';
|
||||||
|
import { useGroupStore } from '../../stores/group';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
chatType: {
|
chatType: {
|
||||||
type: String,
|
type: String,
|
||||||
default: MESSAGE_TYPE.GROUP
|
default: CHAT_TYPE.GROUP
|
||||||
},
|
},
|
||||||
groupData: {
|
groupData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -103,24 +109,105 @@ const props = defineProps({
|
|||||||
id: i,
|
id: i,
|
||||||
nickname: `成员 ${i + 1}`,
|
nickname: `成员 ${i + 1}`,
|
||||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
||||||
role: i === 0 ? 'admin' : 'member'
|
role: i === 0 ? 'Master' : 'Normal'
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
currentUserId: [String, Number]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = useTemplateRef('input')
|
const input = useTemplateRef('input')
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
const conversationStore = useConversationStore();
|
||||||
|
const groupStore = useGroupStore();
|
||||||
|
|
||||||
const groupInfo = ref({
|
const groupInfo = ref({
|
||||||
members: []
|
members: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupInviteModal = ref(false)
|
const groupInviteModal = ref(false)
|
||||||
|
const groupMemberIds = computed(() => (groupInfo.value?.members || []).map(m => m.userId))
|
||||||
|
const editingAnnouncement = ref(false)
|
||||||
|
const announcementText = ref('')
|
||||||
|
const showAllMembers = ref(false)
|
||||||
|
|
||||||
defineEmits(['close', 'viewAll']);
|
// --- pin / mute (Task 17, persisted in localStorage) ---
|
||||||
|
const storageKey = computed(() => `conv_${props.groupData?.targetId || props.groupData?.id}`)
|
||||||
|
const isPinned = ref(false)
|
||||||
|
const isMuted = ref(false)
|
||||||
|
const togglePin = () => {
|
||||||
|
isPinned.value = !isPinned.value
|
||||||
|
localStorage.setItem(storageKey.value + '_pin', isPinned.value ? '1' : '0')
|
||||||
|
message.success(isPinned.value ? '已置顶' : '已取消置顶')
|
||||||
|
}
|
||||||
|
const toggleMute = () => {
|
||||||
|
isMuted.value = !isMuted.value
|
||||||
|
localStorage.setItem(storageKey.value + '_mute', isMuted.value ? '1' : '0')
|
||||||
|
message.success(isMuted.value ? '已免打扰' : '已取消免打扰')
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'viewAll', 'searchInChat']);
|
||||||
|
|
||||||
|
// --- current user / role ---
|
||||||
|
const currentUserId = computed(() => useAuthStore().userInfo?.id)
|
||||||
|
const currentMember = computed(() =>
|
||||||
|
(groupInfo.value?.members || []).find(member => member.userId === currentUserId.value)
|
||||||
|
)
|
||||||
|
const isMaster = computed(() => currentMember.value?.role === GROUP_MEMBER_ROLE.MASTER)
|
||||||
|
const isAdmin = computed(() =>
|
||||||
|
currentMember.value?.role === GROUP_MEMBER_ROLE.MASTER ||
|
||||||
|
currentMember.value?.role === GROUP_MEMBER_ROLE.ADMIN
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- announcement ---
|
||||||
|
const saveAnnouncement = async () => {
|
||||||
|
const res = await groupService.updateGroupInfo({
|
||||||
|
groupId: props.groupData.targetId,
|
||||||
|
description: announcementText.value
|
||||||
|
})
|
||||||
|
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
groupInfo.value.announcement = announcementText.value
|
||||||
|
editingAnnouncement.value = false
|
||||||
|
message.success('公告已更新')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- member management ---
|
||||||
|
const handleMemberClick = async (member) => {
|
||||||
|
if (!isAdmin.value || member.userId === currentUserId.value) return
|
||||||
|
if (!confirm(`确定要移除成员 ${member.nickname || member.groupNickName} 吗?`)) return
|
||||||
|
const res = await groupService.deleteMember(member.id)
|
||||||
|
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
groupInfo.value.members = groupInfo.value.members.filter(m => m.id !== member.id)
|
||||||
|
message.success('已移除')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '移除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- exit group ---
|
||||||
|
const handleExitGroup = async () => {
|
||||||
|
const groupId = props.groupData.targetId
|
||||||
|
const actionText = isMaster.value ? '解散该群组' : '退出该群组'
|
||||||
|
if (!confirm(`确定要${actionText}吗?${isMaster.value ? '此操作会移除所有成员。' : ''}`)) return
|
||||||
|
try {
|
||||||
|
const res = isMaster.value
|
||||||
|
? await groupService.dissolveGroup(groupId)
|
||||||
|
: await groupService.leaveGroup(groupId)
|
||||||
|
if (res.code === 0) {
|
||||||
|
await conversationStore.removeConversation(props.groupData.id)
|
||||||
|
groupStore.removeGroup(groupId)
|
||||||
|
message.success(isMaster.value ? '群组已解散' : '已退出群组')
|
||||||
|
emit('close')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || `${actionText}失败`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error(`${actionText}失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uploadGroupAvatar = () => {
|
const uploadGroupAvatar = () => {
|
||||||
input.value.click()
|
input.value.click()
|
||||||
@@ -128,9 +215,9 @@ const uploadGroupAvatar = () => {
|
|||||||
|
|
||||||
const fileUploadHandler = async (e) => {
|
const fileUploadHandler = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
const hash = await getFileHash(file)
|
const { data } = await uploadService.uploadSmallFile(file, true);
|
||||||
const { data } = await uploadService.uploadSmallFile(file, hash);
|
const res = await groupService.updateGroupInfo({
|
||||||
const res = await groupService.updateGroupInfo(props.groupData.targetId, {
|
groupId: props.groupData.targetId,
|
||||||
avatar: data.url
|
avatar: data.url
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -140,30 +227,42 @@ const fileUploadHandler = async (e) => {
|
|||||||
message.error(res.message)
|
message.error(res.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const inviteHandler = async () => {
|
||||||
const inviteHandler = () => {
|
// 打开弹窗前确保成员数据已加载
|
||||||
|
try {
|
||||||
|
const mRes = await groupService.getGroupMember(props.groupData.targetId)
|
||||||
|
if (mRes.code === SYSTEM_BASE_STATUS.SUCCESS && mRes.data) {
|
||||||
|
groupInfo.value.members = mRes.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载群成员失败:', error)
|
||||||
|
}
|
||||||
groupInviteModal.value = true
|
groupInviteModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteUserHandler = async (selectedUsers) => {
|
const inviteUserHandler = async (selectedUsers) => {
|
||||||
const res = await groupService.inviteUser(props.groupData.targetId, [...selectedUsers])
|
const userIds = [...selectedUsers];
|
||||||
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
|
let allSuccess = true;
|
||||||
message.success('成功')
|
for (const userId of userIds) {
|
||||||
|
const res = await groupService.inviteUser(props.groupData.targetId, userId);
|
||||||
|
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) allSuccess = false;
|
||||||
|
}
|
||||||
|
if (allSuccess) {
|
||||||
|
message.success('邀请成功')
|
||||||
|
} else {
|
||||||
|
message.error('部分邀请发送失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断当前用户是否为管理员
|
|
||||||
const isAdmin = computed(() => {
|
|
||||||
// 逻辑:在 members 数组中找到当前用户并检查 role
|
|
||||||
return true; // 演示用,默认 true
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.groupData.id,
|
() => props.groupData.id,
|
||||||
async (newVal, oldVal) => {
|
async (newVal, oldVal) => {
|
||||||
if (props.chatType == MESSAGE_TYPE.GROUP && newVal != oldVal) {
|
if (props.chatType == CHAT_TYPE.GROUP && newVal != oldVal) {
|
||||||
groupInfo.value = (await groupService.getGroupInfo(props.groupData.targetId)).data
|
groupInfo.value = (await groupService.getGroupInfo(props.groupData.targetId)).data
|
||||||
|
|
||||||
groupInfo.value.members = (await groupService.getGroupMember(props.groupData.targetId)).data
|
groupInfo.value.members = (await groupService.getGroupMember(props.groupData.targetId)).data
|
||||||
|
// restore pin/mute state
|
||||||
|
isPinned.value = localStorage.getItem(storageKey.value + '_pin') === '1'
|
||||||
|
isMuted.value = localStorage.getItem(storageKey.value + '_mute') === '1'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -444,6 +543,11 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.ios-switch:checked::before { transform: translateX(18px); }
|
.ios-switch:checked::before { transform: translateX(18px); }
|
||||||
|
|
||||||
|
/* 公告编辑 */
|
||||||
|
.announcement-edit { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.announce-input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; resize: vertical; box-sizing: border-box; }
|
||||||
|
.save-announce-btn { align-self: flex-end; padding: 6px 16px; background: #007aff; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||||
|
|
||||||
/* 动画 */
|
/* 动画 */
|
||||||
.slide-enter-active, .slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
.slide-enter-active, .slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||||
.slide-enter-from, .slide-leave-to { transform: translateX(100%); opacity: 0.5; }
|
.slide-enter-from, .slide-leave-to { transform: translateX(100%); opacity: 0.5; }
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let player = new Player({
|
new Player({
|
||||||
id: 'Video',
|
id: 'Video',
|
||||||
url: props.m.content.url,
|
url: props.m.url || (props.m.content?.body?.url) || '',
|
||||||
controlPlugins: [
|
controlPlugins: [
|
||||||
volume,
|
volume,
|
||||||
playbackRate
|
playbackRate
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import { ref, reactive } from 'vue';
|
import { ref, reactive } from 'vue';
|
||||||
import { friendService } from '@/services/friend';
|
import { friendService } from '@/services/friend';
|
||||||
import { useMessage } from '../messages/useAlert';
|
import { useMessage } from '../messages/useAlert';
|
||||||
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
|
||||||
const props = defineProps({ modelValue: Boolean });
|
defineProps({ modelValue: Boolean });
|
||||||
const emit = defineEmits(['update:modelValue', 'success']);
|
const emit = defineEmits(['update:modelValue', 'success']);
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ const onSearch = async () => {
|
|||||||
const submitAdd = async () => {
|
const submitAdd = async () => {
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
const res = await friendService.requestFriend({
|
const res = await friendService.requestFriend({
|
||||||
toUserId: userResult.value.id,
|
targetId: userResult.value.id,
|
||||||
remarkName: form.remark,
|
remarkName: form.remark,
|
||||||
description: form.description
|
description: form.description
|
||||||
});
|
});
|
||||||
@@ -74,10 +75,10 @@ const submitAdd = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="userResult" class="result-card">
|
<div v-if="userResult" class="result-card">
|
||||||
<img :src="userResult.avatar" class="mini-avatar" />
|
<AsyncImage :raw-url="userResult.avatar" class="mini-avatar" />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">{{ userResult.nickName }}</div>
|
<div class="name">{{ userResult.nickName }}</div>
|
||||||
<div class="id">ID: {{ userResult.username }}</div>
|
<div class="id">ID: {{ userResult.userName }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="next-btn" @click="step = 2">添加</button>
|
<button class="next-btn" @click="step = 2">添加</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +147,7 @@ main { padding: 12px; }
|
|||||||
margin-top: 12px; display: flex; align-items: center;
|
margin-top: 12px; display: flex; align-items: center;
|
||||||
padding: 10px; background: #f9f9f9; border-radius: 8px;
|
padding: 10px; background: #f9f9f9; border-radius: 8px;
|
||||||
}
|
}
|
||||||
.mini-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; }
|
:deep(.mini-avatar) { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; flex-shrink: 0; }
|
||||||
.info { flex: 1; }
|
.info { flex: 1; }
|
||||||
.name { font-size: 14px; font-weight: bold; color: #333; }
|
.name { font-size: 14px; font-weight: bold; color: #333; }
|
||||||
.id { font-size: 11px; color: #999; }
|
.id { font-size: 11px; color: #999; }
|
||||||
|
|||||||
@@ -3,25 +3,24 @@
|
|||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="isVisible"
|
v-if="isVisible"
|
||||||
|
ref="cardRef"
|
||||||
class="im-hover-card"
|
class="im-hover-card"
|
||||||
:style="cardStyle"
|
:style="cardStyle"
|
||||||
@mouseenter="clearTimer"
|
|
||||||
@mouseleave="hide"
|
|
||||||
>
|
>
|
||||||
<div class="card-inner">
|
<div class="card-inner">
|
||||||
<div class="user-profile">
|
<div class="user-profile">
|
||||||
<div class="info-text">
|
<div class="info-text">
|
||||||
<h4 class="nickname">{{ currentUser.name }}</h4>
|
<h4 class="nickname">{{ currentUser.name }}</h4>
|
||||||
<p class="detail-item">
|
<p class="detail-item">
|
||||||
<span class="label">微信号:</span>
|
<span class="label">用户名:</span>
|
||||||
<span class="value">{{ currentUser.id }}</span>
|
<span class="value">{{ currentUser.userName || currentUser.name || currentUser.id }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="detail-item">
|
<p class="detail-item">
|
||||||
<span class="label">地 区:</span>
|
<span class="label">地区:</span>
|
||||||
<span class="value">{{ currentUser.region || '未知' }}</span>
|
<span class="value">{{ currentUser.region || '未知' }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<img :src="currentUser.avatar" class="avatar-square" />
|
<AsyncImage :raw-url="currentUser.avatar" class="avatar-square" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-bio">
|
<div class="user-bio">
|
||||||
@@ -39,7 +38,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue';
|
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||||
|
import AsyncImage from '../AsyncImage.vue';
|
||||||
|
|
||||||
const isVisible = ref(false);
|
const isVisible = ref(false);
|
||||||
const currentUser = ref({});
|
const currentUser = ref({});
|
||||||
@@ -48,32 +48,32 @@ const cardStyle = reactive({
|
|||||||
top: '0px',
|
top: '0px',
|
||||||
left: '0px'
|
left: '0px'
|
||||||
});
|
});
|
||||||
|
const cardRef = ref(null);
|
||||||
|
|
||||||
let timer = null;
|
const hide = () => {
|
||||||
|
isVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentClick = (e) => {
|
||||||
|
if (!isVisible.value) return;
|
||||||
|
// 点击卡片内部不关闭
|
||||||
|
if (cardRef.value && cardRef.value.contains(e.target)) return;
|
||||||
|
hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', onDocumentClick, true));
|
||||||
|
onUnmounted(() => document.removeEventListener('click', onDocumentClick, true));
|
||||||
|
|
||||||
const show = (el, data) => {
|
const show = (el, data) => {
|
||||||
clearTimer();
|
|
||||||
currentUser.value = data;
|
currentUser.value = data;
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
// IM 习惯:通常在头像右侧或下方弹出
|
|
||||||
// 这里设置为在头像中心水平对齐,下方弹出
|
|
||||||
cardStyle.top = `${rect.bottom + 8}px`;
|
cardStyle.top = `${rect.bottom + 8}px`;
|
||||||
cardStyle.left = `${rect.left}px`;
|
cardStyle.left = `${rect.left}px`;
|
||||||
|
|
||||||
isVisible.value = true;
|
isVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hide = () => {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
isVisible.value = false;
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTimer = () => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAdd = () => {
|
const onAdd = () => {
|
||||||
console.log('申请添加好友:', currentUser.value.id);
|
console.log('申请添加好友:', currentUser.value.id);
|
||||||
// 这里写你的逻辑
|
// 这里写你的逻辑
|
||||||
@@ -133,12 +133,7 @@ defineExpose({ show, hide });
|
|||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-square {
|
:deep(.avatar-square) { width: 60px; height: 60px; border-radius: 4px; flex-shrink: 0; }
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 签名区 */
|
/* 签名区 */
|
||||||
.user-bio {
|
.user-bio {
|
||||||
|
|||||||
@@ -5,20 +5,23 @@ export const GROUP_MEMBER_ROLE = Object.freeze({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 群请求状态枚举 (对应后端 String 输出)
|
* 群入群请求状态 (对应后端 string)
|
||||||
|
* State: "Pending" 待管理员同意, "Declined" 已拒绝, "Passed" 已通过
|
||||||
*/
|
*/
|
||||||
export const GROUP_REQUEST_STATUS = Object.freeze({
|
export const GROUP_REQUEST_STATUS = Object.freeze({
|
||||||
/** 待管理员处理 */
|
|
||||||
PENDING: 'Pending',
|
PENDING: 'Pending',
|
||||||
/** 管理员已拒绝 */
|
|
||||||
DECLINED: 'Declined',
|
DECLINED: 'Declined',
|
||||||
/** 管理员已同意 */
|
|
||||||
PASSED: 'Passed',
|
PASSED: 'Passed',
|
||||||
/** 待对方同意 */
|
})
|
||||||
TARGET_PENDING: 'TargetPending',
|
|
||||||
/** 对方拒绝 */
|
/**
|
||||||
TARGET_DECLINED: 'TargetDeclined',
|
* 群邀请状态 (对应后端 string)
|
||||||
TARGET_PASSED: 'TargetPassed'
|
* State: "Pending" 待被邀请人同意, "Passed" 已同意, "Reject" 拒绝
|
||||||
|
*/
|
||||||
|
export const GROUP_INVITATION_STATUS = Object.freeze({
|
||||||
|
PENDING: 'Pending',
|
||||||
|
ACCEPTED: 'Passed',
|
||||||
|
REJECTED: 'Reject',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GROUP_REQUEST_ACTION = Object.freeze({
|
export const GROUP_REQUEST_ACTION = Object.freeze({
|
||||||
@@ -35,14 +38,7 @@ export const getGroupRequestStatusTxt = (status) => {
|
|||||||
return '管理员已拒绝';
|
return '管理员已拒绝';
|
||||||
case GROUP_REQUEST_STATUS.PASSED:
|
case GROUP_REQUEST_STATUS.PASSED:
|
||||||
return '管理员已同意';
|
return '管理员已同意';
|
||||||
case GROUP_REQUEST_STATUS.TARGET_PENDING:
|
|
||||||
return '待对方同意';
|
|
||||||
case GROUP_REQUEST_STATUS.TARGET_DECLINED:
|
|
||||||
return '对方拒绝';
|
|
||||||
case GROUP_REQUEST_STATUS.TARGET_PASSED:
|
|
||||||
return '对方同意';
|
|
||||||
default:
|
default:
|
||||||
return '未知状态';
|
return '未知状态';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
/** 会话类型 (对应后端 ChatType string) */
|
||||||
|
export const CHAT_TYPE = Object.freeze({
|
||||||
|
PRIVATE: 'PRIVATE',
|
||||||
|
GROUP: 'GROUP'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 消息类型 (对应后端 MsgType string) */
|
||||||
|
export const MSG_TYPE = Object.freeze({
|
||||||
|
Text: 'Text',
|
||||||
|
Image: 'Image',
|
||||||
|
Voice: 'Voice',
|
||||||
|
Video: 'Video',
|
||||||
|
File: 'File',
|
||||||
|
VoiceCall: 'VoiceChat',
|
||||||
|
VideoCall: 'VideoChat'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 CHAT_TYPE
|
||||||
|
*/
|
||||||
export const MESSAGE_TYPE = Object.freeze({
|
export const MESSAGE_TYPE = Object.freeze({
|
||||||
PRIVATE: 'PRIVATE',
|
PRIVATE: 'PRIVATE',
|
||||||
GROUP: 'GROUP'
|
GROUP: 'GROUP'
|
||||||
|
|||||||
@@ -1,37 +1,16 @@
|
|||||||
|
import { MSG_TYPE } from './MessageType'
|
||||||
|
|
||||||
export const getMessageType = (fileType) => {
|
export const getMessageType = (fileType) => {
|
||||||
if (!fileType) return FILE_TYPE.File; // 兜底处理
|
if (!fileType) return MSG_TYPE.File;
|
||||||
|
|
||||||
// 处理图片
|
if (fileType.startsWith('image/')) return MSG_TYPE.Image;
|
||||||
if (fileType.startsWith('image/')) {
|
if (fileType.startsWith('audio/')) return MSG_TYPE.Voice;
|
||||||
return FILE_TYPE.Image;
|
if (fileType.startsWith('video/')) return MSG_TYPE.Video;
|
||||||
}
|
|
||||||
|
|
||||||
// 处理音频(录音消息)
|
return MSG_TYPE.File;
|
||||||
if (fileType.startsWith('audio/')) {
|
|
||||||
return FILE_TYPE.Voice;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理视频
|
|
||||||
if (fileType.startsWith('video/')) {
|
|
||||||
return FILE_TYPE.Video;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 常见文档类型的特殊处理(可选)
|
|
||||||
const documentTypes = [
|
|
||||||
'application/pdf',
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'text/plain'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (documentTypes.includes(fileType)) {
|
|
||||||
return FILE_TYPE.File;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他所有情况统一归类为文件
|
|
||||||
return FILE_TYPE.File;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated 请使用 MSG_TYPE。保留仅为 AsyncImage/cache 等通用缓存层兼容。 */
|
||||||
export const FILE_TYPE = Object.freeze({
|
export const FILE_TYPE = Object.freeze({
|
||||||
Image: 'Image',
|
Image: 'Image',
|
||||||
Video: 'Video',
|
Video: 'Video',
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
export const FRIEND_ACTIONS = Object.freeze({
|
export const FRIEND_ACTIONS = Object.freeze({
|
||||||
/**接受 */
|
/**同意 */
|
||||||
Accept: 'Accept',
|
Accept: 'Accpet',
|
||||||
/**拒绝 */
|
/**拒绝 */
|
||||||
Reject: 'Reject'
|
Reject: 'Reject',
|
||||||
|
/**拉黑 */
|
||||||
|
Block: 'Block'
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FRIEND_REQUEST_STATUS = Object.freeze({
|
export const FRIEND_REQUEST_STATUS = Object.freeze({
|
||||||
/**待处理 */
|
/**待通过 */
|
||||||
Pending: 'Pending',
|
Pending: 'Pending',
|
||||||
/**通过 */
|
|
||||||
Passed: 'Passed',
|
|
||||||
/**已拒绝 */
|
/**已拒绝 */
|
||||||
Declined: 'Declined',
|
Declined: 'Declined',
|
||||||
|
/**已同意 */
|
||||||
|
Passed: 'Passed',
|
||||||
/**已拉黑 */
|
/**已拉黑 */
|
||||||
Blocked: 'Blocked'
|
Blocked: 'Blocked'
|
||||||
})
|
})
|
||||||
@@ -1,31 +1,50 @@
|
|||||||
import { useConversationStore } from "@/stores/conversation"
|
import { useConversationStore } from "@/stores/conversation"
|
||||||
import { MESSAGE_TYPE } from "../constants/MessageType";
|
import { CHAT_TYPE } from "../constants/MessageType";
|
||||||
|
|
||||||
export const messageHandler = (msg) => {
|
export const messageHandler = async (msg) => {
|
||||||
const conversationStore = useConversationStore();
|
const conversationStore = useConversationStore();
|
||||||
const conversation = conversationStore.conversations.find(x => {
|
|
||||||
// 1. 如果是私聊:目标 ID 必须是对方(可能是发送者,也可能是接收者)
|
|
||||||
if (msg.chatType === MESSAGE_TYPE.PRIVATE) {
|
|
||||||
return x.chatType === MESSAGE_TYPE.PRIVATE &&
|
|
||||||
(x.targetId === msg.senderId || x.targetId === msg.receiverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 如果是群聊:目标 ID 必须是群 ID(即消息的 receiverId)
|
let conversation = conversationStore.conversations.find(x => {
|
||||||
if (msg.chatType === MESSAGE_TYPE.GROUP) {
|
if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
|
||||||
return x.chatType === MESSAGE_TYPE.GROUP &&
|
return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
|
||||||
x.targetId === msg.receiverId;
|
(x.targetId === msg.senderId || x.targetId === msg.targetId);
|
||||||
|
}
|
||||||
|
if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
|
||||||
|
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
|
||||||
|
x.targetId === msg.targetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!conversation) return; // 容错处理:如果没找到会话,不执行后续逻辑
|
// 会话不存在 → 从服务器拉取
|
||||||
conversation.lastMessage = msg.content;
|
if (!conversation) {
|
||||||
if (conversation.targetId == msg.receiverId) {
|
await conversationStore.fetchConversationsFromServier();
|
||||||
conversation.unreadCount = 0;
|
conversation = conversationStore.conversations.find(x => {
|
||||||
} else {
|
if (msg.chatType === CHAT_TYPE.PRIVATE || msg.chatType === 'PRIVATE') {
|
||||||
conversation.unreadCount += 1;
|
return (x.chatType === CHAT_TYPE.PRIVATE || x.chatType === 'PRIVATE') &&
|
||||||
|
(x.targetId === msg.senderId || x.targetId === msg.targetId);
|
||||||
}
|
}
|
||||||
conversation.dateTime = new Date().toISOString();
|
if (msg.chatType === CHAT_TYPE.GROUP || msg.chatType === 'GROUP') {
|
||||||
|
return (x.chatType === CHAT_TYPE.GROUP || x.chatType === 'GROUP') &&
|
||||||
|
x.targetId === msg.targetId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!conversation) return;
|
||||||
|
|
||||||
|
const contentText = typeof msg.content === 'object'
|
||||||
|
? (msg.content?.body?.text || msg.content?.fallback || '')
|
||||||
|
: msg.content;
|
||||||
|
conversation.lastMessage = contentText;
|
||||||
|
|
||||||
|
// 不是我发的消息 → 未读+1
|
||||||
|
const isFromMe = (conversation.userId && conversation.userId === msg.senderId);
|
||||||
|
if (!isFromMe) {
|
||||||
|
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
|
||||||
|
}
|
||||||
|
conversation.dateTime = msg.pushTimestamp
|
||||||
|
? new Date(msg.pushTimestamp).toISOString()
|
||||||
|
: (msg.creationTime || new Date().toISOString());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ const routes = [
|
|||||||
component: () => import('@/views/contact/UserInfoContent.vue'),
|
component: () => import('@/views/contact/UserInfoContent.vue'),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/contacts/group/:id',
|
||||||
|
name: 'groupInfo',
|
||||||
|
component: () => import('@/views/contact/GroupInfoContent.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/contacts/requests',
|
path: '/contacts/requests',
|
||||||
name: 'friendRequests',
|
name: 'friendRequests',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useMessage } from '@/components/messages/useAlert'
|
import { useMessage } from '@/components/messages/useAlert'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -10,13 +11,29 @@ let waitqueue = []
|
|||||||
let isRefreshing = false
|
let isRefreshing = false
|
||||||
const authURL = ['/auth/login', '/auth/register', '/auth/refresh']
|
const authURL = ['/auth/login', '/auth/register', '/auth/refresh']
|
||||||
|
|
||||||
|
// 全局网络状态:null=正常,string=错误消息
|
||||||
|
export const networkError = ref(null)
|
||||||
|
let recoveryTimer = null
|
||||||
|
|
||||||
|
const setNetworkError = (msg) => {
|
||||||
|
networkError.value = msg
|
||||||
|
// 10 秒后如果没恢复,自动清除(下次请求成功也会清除)
|
||||||
|
clearTimeout(recoveryTimer)
|
||||||
|
recoveryTimer = setTimeout(() => { networkError.value = null }, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearNetworkError = () => {
|
||||||
|
networkError.value = null
|
||||||
|
clearTimeout(recoveryTimer)
|
||||||
|
}
|
||||||
|
|
||||||
const pushLoginElectron = () => {
|
const pushLoginElectron = () => {
|
||||||
window.api.window.close()
|
window.api.window.close()
|
||||||
window.api.window.newWindow('/auth/login', null, 420, 540)
|
window.api.window.newWindow('/auth/login', null, 420, 540)
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
|
baseURL: import.meta.env.DEV ? '/api' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8009/api'),
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {}
|
headers: {}
|
||||||
})
|
})
|
||||||
@@ -35,65 +52,98 @@ api.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
api.interceptors.response.use(
|
const redirectToLogin = () => {
|
||||||
(response) => {
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async (err) => {
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { config, response } = err
|
|
||||||
if (response) {
|
|
||||||
switch (response.status) {
|
|
||||||
case 401:
|
|
||||||
if (authURL.some((x) => config.url.includes(x))) {
|
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
message.error('未登录,请登录后操作。')
|
message.error('登录已失效,请重新登录。')
|
||||||
router.push('/auth/login')
|
if (window.api?.window) pushLoginElectron()
|
||||||
break
|
else router.push('/auth/login')
|
||||||
}
|
}
|
||||||
if (config._retry) {
|
|
||||||
break
|
const rejectWaitingRequests = (error) => {
|
||||||
|
waitqueue.forEach(({ reject }) => reject(error))
|
||||||
|
waitqueue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAndRetry = async (config, originalError) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!config || authURL.some((x) => config.url?.includes(x)) || config._retry) {
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(originalError)
|
||||||
}
|
}
|
||||||
|
|
||||||
config._retry = true
|
config._retry = true
|
||||||
// 已经在刷新 → 排队
|
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
waitqueue.push((token) => {
|
waitqueue.push({
|
||||||
|
resolve: (token) => {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
resolve(api(config))
|
resolve(api(config))
|
||||||
|
},
|
||||||
|
reject,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
isRefreshing = true
|
|
||||||
const refreshToken = authStore.refreshToken
|
const refreshToken = authStore.refreshToken
|
||||||
if (refreshToken != null && refreshToken != '') {
|
if (!refreshToken) {
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(originalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
try {
|
||||||
const res = await authService.refresh(refreshToken)
|
const res = await authService.refresh(refreshToken)
|
||||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
if (res.code !== 0 || !res.data?.token) throw new Error(res.message || '刷新登录状态失败')
|
||||||
waitqueue.forEach((cb) => cb(authStore.token))
|
authStore.setLoginInfo(res.data)
|
||||||
|
waitqueue.forEach(({ resolve }) => resolve(authStore.token))
|
||||||
waitqueue = []
|
waitqueue = []
|
||||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||||
return api(config)
|
return api(config)
|
||||||
|
} catch (error) {
|
||||||
|
rejectWaitingRequests(error)
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
authStore.logout()
|
}
|
||||||
message.error('未登录,请登录后操作。')
|
|
||||||
router.push('/auth/login')
|
api.interceptors.response.use(
|
||||||
break
|
(response) => {
|
||||||
|
// 任何成功的响应都清除网络错误状态
|
||||||
|
clearNetworkError()
|
||||||
|
// 滚动发布期间兼容旧后端的 HTTP 200 + code=1006。
|
||||||
|
if (response.data?.code === 1006) {
|
||||||
|
return refreshAndRetry(response.config, response.data)
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async (err) => {
|
||||||
|
const { config, response } = err
|
||||||
|
|
||||||
|
// 无响应 → 网络不通
|
||||||
|
if (!response) {
|
||||||
|
setNetworkError('网络连接异常')
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (response.status) {
|
||||||
|
case 401:
|
||||||
|
return refreshAndRetry(config, err)
|
||||||
|
|
||||||
case 400:
|
case 400:
|
||||||
if (response.data && response.data.code == 1003) {
|
if (response.data && response.data.code == 1003) {
|
||||||
message.error(response.data.message)
|
message.error(response.data.message)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
message.error('请求错误,请检查网络。')
|
// 其他 HTTP 错误用 toast(用户主动操作触发,需要即时反馈)
|
||||||
|
message.error(response.data?.message || `请求错误 (${response.status})`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
return Promise.reject(err)
|
return Promise.reject(err)
|
||||||
} else {
|
|
||||||
message.error('请求错误,请检查网络。')
|
|
||||||
return Promise.reject(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,22 @@ import { request } from "./api";
|
|||||||
export const authService = {
|
export const authService = {
|
||||||
/**
|
/**
|
||||||
* 用户登录接口
|
* 用户登录接口
|
||||||
* @param {*} data
|
* @param {{ userName, password }} data
|
||||||
* @returns
|
* @returns Result<LoginResponse>
|
||||||
*/
|
*/
|
||||||
login: (data) => request.post('/auth/login', data),
|
login: (data) => request.post('/auth/login', data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户注册
|
* 用户注册
|
||||||
* @param {*} data
|
* @param {{ userName, password, nickName }} data
|
||||||
* @returns
|
* @returns Result<UserResponse>
|
||||||
*/
|
*/
|
||||||
register: (data) => request.post('/auth/register', data),
|
register: (data) => request.post('/auth/register', data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新用户凭证
|
* 刷新用户凭证
|
||||||
* @param {*} data
|
* @param {string} refreshToken
|
||||||
* @returns
|
* @returns Result<LoginResponse>
|
||||||
*/
|
*/
|
||||||
refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken })
|
refresh: (refreshToken) => request.post('/auth/refresh', { refreshToken })
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,68 @@
|
|||||||
import { request } from "./api";
|
import { request } from "./api";
|
||||||
import { FRIEND_ACTIONS } from "@/constants/friendAction";
|
|
||||||
|
|
||||||
export const friendService = {
|
export const friendService = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取好友列表
|
* 获取好友列表
|
||||||
* @param {*} page 当前页
|
* @returns Result<List<FriendResponse>>
|
||||||
* @param {*} limit 页大小
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
getFriendList: (page = 1, limit = 100) => request.get(`/friend/list?page=${page}&limit=${limit}`),
|
|
||||||
/**
|
|
||||||
* 搜索好友
|
|
||||||
* @param {*} username
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
|
getFriendList: () => request.get('/friend/list'),
|
||||||
|
|
||||||
findUser: (username) => request.get(`/user/findbyusername?username=${username}`),
|
|
||||||
/**
|
/**
|
||||||
* 申请添加好友
|
* 删除好友
|
||||||
* @param {*} params
|
* @param {string} friendId
|
||||||
* @returns
|
* @returns Result<boolean>
|
||||||
*/
|
*/
|
||||||
requestFriend: (params) => request.post('/friend/request', params),
|
deleteFriend: (friendId) => request.post(`/friend/delete?friendId=${friendId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉黑好友
|
||||||
|
* @param {string} friendId
|
||||||
|
* @returns Result<boolean>
|
||||||
|
*/
|
||||||
|
blockFriend: (friendId) => request.post(`/friend/block?friendId=${friendId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查好友关系
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} targetId
|
||||||
|
* @returns Result<boolean>
|
||||||
|
*/
|
||||||
|
checkFriend: (userId, targetId) => request.get(`/friend/checkFriend?userId=${userId}&targetId=${targetId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按用户名模糊搜索用户
|
||||||
|
* @param {string} keyword
|
||||||
|
* @returns Result<UserResponse>
|
||||||
|
*/
|
||||||
|
findUser: (keyword) => request.get(`/user/findByUname?username=${encodeURIComponent(keyword)}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起好友申请
|
||||||
|
* @param {{ targetId, description?, remarkName? }} params
|
||||||
|
* @returns Result<FriendRequestResponse>
|
||||||
|
*/
|
||||||
|
requestFriend: (params) => request.post('/friendRequest/add', {
|
||||||
|
targetId: params.targetId,
|
||||||
|
description: params.description,
|
||||||
|
remarkName: params.remarkName,
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取好友请求列表
|
* 获取好友请求列表
|
||||||
* @param {*} page
|
* @returns Result<List<FriendRequestResponse>>
|
||||||
* @param {*} limit
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
getFriendRequests: (page = 1, limit = 100) => request.get(`/friend/requests?page=${page}&limit=${limit}`),
|
getFriendRequests: () => request.get('/friendRequest/list'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理好友请求
|
* 处理好友请求
|
||||||
* @param {*} friendRequestId
|
* @param {string} requestId 请求ID
|
||||||
* @param {typeof FRIEND_ACTIONS[keyof typeof FRIEND_ACTIONS]} action
|
* @param {number} action 0=同意, 1=拒绝, 2=拉黑
|
||||||
* @returns
|
* @param {string} remarkName 备注名(同意时必填)
|
||||||
|
* @returns Result<boolean>
|
||||||
*/
|
*/
|
||||||
handleFriendRequest: (friendRequestId, action, remarkname) => request.post(`/Friend/HandleRequest?id=${friendRequestId}`, {
|
handleFriendRequest: (requestId, action, remarkName) => request.post('/friendRequest/handle', {
|
||||||
remarkName: remarkname,
|
requestId,
|
||||||
action: action
|
action,
|
||||||
})
|
remarkName,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
@@ -1,62 +1,115 @@
|
|||||||
import { request } from "./api"
|
import { request } from "./api";
|
||||||
|
|
||||||
export const groupService = {
|
export const groupService = {
|
||||||
/**
|
/**
|
||||||
* 创建群聊
|
* 我加入的群列表
|
||||||
* @param {*} data
|
* @returns Result<List<GroupResponse>>
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
createGroup: (data) => request.post('/Group/CreateGroup', data),
|
getAllGroups: () => request.get('/group/getAll'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询群组成员
|
* 群详情
|
||||||
* @param {*} groupId
|
* @param {string} groupId
|
||||||
* @returns
|
* @returns Result<GroupResponse>
|
||||||
*/
|
*/
|
||||||
getGroupMember: (groupId) => request.get(`/Group/GetGroupMembers?groupId=${groupId}`),
|
getGroupInfo: (groupId) => request.get(`/group/getOne?groupId=${groupId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建群聊
|
||||||
|
* @param {{ name }} data
|
||||||
|
* @returns Result<GroupResponse>
|
||||||
|
*/
|
||||||
|
createGroup: (data) => request.post('/group/create', { name: data.name }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新群组信息
|
* 更新群组信息
|
||||||
* @param {*} groupId
|
* @param {{ groupId, groupName?, avatar?, description? }} params
|
||||||
* @param {*} params
|
* @returns Result<GroupResponse>
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
|
updateGroupInfo: (params) => request.post('/group/update', {
|
||||||
updateGroupInfo: (groupId, params) => request.post(`/Group/UpdateGroup?groupId=${groupId}`, params),
|
groupId: params.groupId,
|
||||||
/**
|
groupName: params.groupName,
|
||||||
* 查询群组信息
|
avatar: params.avatar,
|
||||||
* @param {*} groupId
|
description: params.description,
|
||||||
* @returns
|
}),
|
||||||
*/
|
|
||||||
|
|
||||||
getGroupInfo: (groupId) => request.get(`/Group/GetGroupInfo?groupId=${groupId}`),
|
|
||||||
/**
|
|
||||||
* 邀请入群
|
|
||||||
* @param {*} groupId
|
|
||||||
* @param {*} users
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
inviteUser: (groupId, users) =>
|
|
||||||
request.post('/Group/InviteUser', { groupId: groupId, ids: users }),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取群聊通知
|
* 查询群成员列表
|
||||||
* @returns
|
* @param {string} groupId
|
||||||
|
* @returns Result<List<GroupMemberResponse>>
|
||||||
*/
|
*/
|
||||||
getGroupNotification: () => request.get('/Group/GetGroupNotification'),
|
getGroupMember: (groupId) => request.get(`/groupMember/list?groupId=${groupId}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理入群邀请
|
* 检查群成员
|
||||||
* @param {*} inviteId
|
* @param {string} userId
|
||||||
* @param {*} action
|
* @param {string} groupId
|
||||||
* @returns
|
* @returns Result<boolean>
|
||||||
*/
|
*/
|
||||||
handleGroupInvite: (inviteId, action) =>
|
checkMember: (userId, groupId) => request.get(`/groupMember/checkMember?userId=${userId}&groupId=${groupId}`),
|
||||||
request.post('/Group/HandleGroupInvite', { inviteId: inviteId, action: action }),
|
|
||||||
/**
|
/**
|
||||||
* 处理入群请求
|
* 移除群成员
|
||||||
* @param {*} requestId
|
* @param {string} memberId
|
||||||
* @param {*} action
|
* @returns Result<boolean>
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
handleGroupRequest: (requestId, action) =>
|
deleteMember: (memberId) => request.post(`/groupMember/delete?memberId=${memberId}`),
|
||||||
request.post('/Group/HandleGroupRequest', { requestId: requestId, action: action })
|
|
||||||
|
/** 当前成员主动退群。 */
|
||||||
|
leaveGroup: (groupId) => request.post(`/groupMember/leave?groupId=${groupId}`),
|
||||||
|
|
||||||
|
/** 群主解散群。 */
|
||||||
|
dissolveGroup: (groupId) => request.post(`/group/dissolve?groupId=${groupId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请单个用户入群
|
||||||
|
* @param {string} groupId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns Result<GroupInvitationResponse>
|
||||||
|
*/
|
||||||
|
inviteUser: (groupId, userId) => request.post('/groupInvitation/send', { groupId, userId }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取邀请详情
|
||||||
|
* @param {string} invitationId
|
||||||
|
* @returns Result<GroupInvitationResponse>
|
||||||
|
*/
|
||||||
|
getInvitation: (invitationId) => request.get(`/groupInvitation/get?invitationId=${invitationId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理群邀请
|
||||||
|
* @param {string} invitationId
|
||||||
|
* @param {'Accept'|'Reject'} action
|
||||||
|
* @returns Result<boolean>
|
||||||
|
*/
|
||||||
|
handleGroupInvite: (invitationId, action) => request.post(`/groupInvitation/handle?invitationId=${invitationId}&action=${action}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请入群
|
||||||
|
* @param {string} groupId
|
||||||
|
* @param {string} desc 入群描述 (≤20字符)
|
||||||
|
* @returns Result<GroupRequestResponse>
|
||||||
|
*/
|
||||||
|
sendGroupRequest: (groupId, desc) => request.post('/groupRequest/send', { groupId, desc }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理入群申请
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {'Accept'|'Reject'} action
|
||||||
|
* @returns Result<boolean>
|
||||||
|
*/
|
||||||
|
handleGroupRequest: (requestId, action) => request.post(`/groupRequest/handle?requestId=${requestId}&action=${action}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询入群申请详情
|
||||||
|
* @param {string} id
|
||||||
|
* @returns Result<GroupRequestResponse>
|
||||||
|
*/
|
||||||
|
findGroupRequest: (id) => request.get(`/groupRequest/find?id=${id}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入群申请列表(替代旧 getGroupNotification,只展示入群申请;邀请通知走私聊消息)
|
||||||
|
* @returns Result<List<GroupRequestResponse>>
|
||||||
|
*/
|
||||||
|
getGroupNotification: () => request.get('/groupRequest/list'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,49 @@ import { request } from "./api";
|
|||||||
|
|
||||||
export const messageService = {
|
export const messageService = {
|
||||||
/**
|
/**
|
||||||
* 获取当前用户会话
|
* 获取当前用户会话列表
|
||||||
* @returns
|
* @returns Result<List<ConversationResponse>>
|
||||||
*/
|
*/
|
||||||
getConversations: () => request.get('/conversation/list'),
|
getConversations: () => request.get('/conversation/list'),
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空所有会话消息
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
clearConversation: () => request.post(''),
|
|
||||||
/**
|
/**
|
||||||
* 获取单个会话信息
|
* 获取单个会话信息
|
||||||
* @param {*} conversationId
|
* @param {string} id 会话ID
|
||||||
* @returns
|
* @returns Result<ConversationResponse>
|
||||||
*/
|
*/
|
||||||
getConversationById: (conversationId) => request.get(`/conversation/get?conversationId=${conversationId}`),
|
getConversationById: (id) => request.get(`/conversation/get?id=${id}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取历史消息列表
|
* 获取消息列表
|
||||||
* @param {*} conversationId 指定会话
|
* @param {string} conversationId 会话ID
|
||||||
* @param {*} msgId
|
* @param {number|null} cursor 游标 (sequenceId),查最新消息传 null
|
||||||
* @param {*} pageSize
|
* @param {number} direction 0=查历史消息, 1=查锚点后的消息
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
//getHistoryMessages: (conversationId, msgId, pageSize = 10) => request.get(`/message/getmessageList?conversationId=${conversationId}&msgId=${msgId}&pageSize=${pageSize}`),
|
|
||||||
/**
|
|
||||||
* 获取消息
|
|
||||||
* @param {*} conversationId 会话ID
|
|
||||||
* @param {Number} cursor 锚点(对应sequenceId),查询最新消息传null
|
|
||||||
* @param {Number} direction 方向 0为查历史消息 1为查锚点后的消息,查询最新消息传0 需配合cursor
|
|
||||||
* @param {number} limit 单次查询消息数
|
* @param {number} limit 单次查询消息数
|
||||||
* @returns
|
* @returns Result<{ messages: MessageResponse[], hasmore: boolean }>
|
||||||
*/
|
*/
|
||||||
getMessages: (conversationId, cursor, direction, limit) => request.get(
|
getMessages: (conversationId, cursor, direction, limit) =>
|
||||||
`/message/getmessageList?conversationId=${conversationId}${cursor ? '&cursor=' + cursor : ''}&direction=${direction}&limit=${limit}`
|
request.get(
|
||||||
|
`/message/getMessages?conversationId=${conversationId}${cursor != null ? '&cursor=' + cursor : ''}&direction=${direction}&limit=${limit}`
|
||||||
),
|
),
|
||||||
|
|
||||||
sendMessage: (msg) => request.post('/Message/SendMessage', msg)
|
/**
|
||||||
|
* 发送消息
|
||||||
|
* @param {{ clientMsgId, targetId, chatType, msgType, quoteMessageId?, ext?, text?, url?, width?, height?, thumb?, duration?, fileId?, fileName?, fileSize?, fileFormat? }} msg
|
||||||
|
* @returns Result<MessageResponse>
|
||||||
|
*/
|
||||||
|
sendMessage: (msg) => request.post('/message/send', msg),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤回消息
|
||||||
|
* @param {string} msgId
|
||||||
|
* @returns Result<boolean>
|
||||||
|
*/
|
||||||
|
withdraw: (msgId) => request.post(`/message/withDraw?msgId=${msgId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清零会话未读数
|
||||||
|
* @param {string} conversationId
|
||||||
|
* @returns Result<boolean>
|
||||||
|
*/
|
||||||
|
markRead: (conversationId) => request.post(`/conversation/markRead?conversationId=${conversationId}`),
|
||||||
}
|
}
|
||||||
@@ -1,52 +1,106 @@
|
|||||||
import { request } from "../api";
|
import { request } from "../api";
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
export const uploadService = {
|
export const uploadService = {
|
||||||
/**
|
/**
|
||||||
* 创建文件上传任务
|
* 小文件直传(头像/封面等),后端自动秒传
|
||||||
* @param {*} fileName 文件名
|
* @param {File} file 文件对象
|
||||||
* @param {*} fileSize 文件大小
|
* @param {boolean} isPublic true 落公开桶返回直链
|
||||||
* @param {*} contentType 文件类型
|
* @returns Result<FileResponse>
|
||||||
* @param {*} fileHash 文件哈希
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
createUploadTask: (fileName, fileSize, contentType, fileHash) => request.post('/Upload/CreateTask', {
|
uploadSmallFile: (file, isPublic = true) => {
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize,
|
|
||||||
contentType: contentType,
|
|
||||||
fileHash: fileHash
|
|
||||||
}),
|
|
||||||
/**
|
|
||||||
* 创建分段任务
|
|
||||||
* @param {*} taskId 任务ID
|
|
||||||
* @param {*} partNum 分段序号
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
createPartTask: (taskId, partNum) => request.post(`/Upload/CreatePart?taskId=${taskId}&partNum=${partNum}`),
|
|
||||||
|
|
||||||
completeTask: (taskId, data) => request.post(`/Upload/CompleteTask?taskId=${taskId}`, data),
|
|
||||||
|
|
||||||
uploadPart: (uploadUrl, headers, file, onProgress) => {
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
formData.append('isPublic', isPublic)
|
||||||
|
return request.post('/file/simple-upload', formData)
|
||||||
|
},
|
||||||
|
|
||||||
return request.post(
|
/**
|
||||||
uploadUrl,
|
* 初始化分片上传任务
|
||||||
formData,
|
* @param {{ conversationId, chatType, targetId, fileName, fileSize, contentType, checkSum }} params
|
||||||
{
|
* @returns Result<{ taskId, uploadSessionId, storageLocation }>
|
||||||
baseURL: '',
|
*/
|
||||||
headers, // 不要包含 Content-Type
|
initUploadTask: (params) => request.post('/fileTask/init', {
|
||||||
onUploadProgress: e => {
|
conversationId: params.conversationId,
|
||||||
|
chatType: params.chatType,
|
||||||
|
targetId: params.targetId,
|
||||||
|
fileName: params.fileName,
|
||||||
|
fileSize: params.fileSize,
|
||||||
|
contentType: params.contentType,
|
||||||
|
checkSum: params.checkSum,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分片上传地址
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {number} partNum 分片序号 (1-based)
|
||||||
|
* @returns Result<string> 预签名上传 URL
|
||||||
|
*/
|
||||||
|
getUploadUrl: (sessionId, partNum) => request.get(`/fileTask/getuploadurl?sessionId=${sessionId}&partNum=${partNum}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询分片上传进度
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns Result<{ sessionId, taskId, fileSize, totalPartCount, completedPartCount, uploadedBytes, progressPercent }>
|
||||||
|
*/
|
||||||
|
getProgress: (sessionId) => request.get(`/fileTask/progress?sessionId=${sessionId}`),
|
||||||
|
|
||||||
|
/** 查询异步合并及最终文件结果。 */
|
||||||
|
getTaskStatus: (taskId) => request.get(`/fileTask/status?taskId=${taskId}`),
|
||||||
|
|
||||||
|
/** 鉴权读取私有文件内容。 */
|
||||||
|
downloadFile: (fileId) => request.get(`/file/${fileId}/content`, { responseType: 'blob' }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成分片上传(合并)
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {{ partNumber: number, eTag: string, size: number }[]} parts
|
||||||
|
* @returns Result<UploadTaskResponse>
|
||||||
|
*/
|
||||||
|
completeTask: (sessionId, parts) => request.post('/fileTask/complete', { sessionId, parts }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地上传单个分片
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {number} partNumber
|
||||||
|
* @param {File|Blob} file 分片二进制
|
||||||
|
* @param {(pct: number) => void} onProgress
|
||||||
|
* @returns Result<any>
|
||||||
|
*/
|
||||||
|
uploadPart: (sessionId, partNumber, file, onProgress) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('sessionId', sessionId)
|
||||||
|
formData.append('partNumber', partNumber)
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
return request.post('/fileTask/local/parts/upload', formData, {
|
||||||
|
onUploadProgress: (e) => {
|
||||||
if (onProgress && e.total) {
|
if (onProgress && e.total) {
|
||||||
onProgress(e.loaded / e.total)
|
onProgress(e.loaded / e.total)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
})
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadSmallFile: (file, hash) => {
|
/**
|
||||||
const formData = new FormData()
|
* 分片上传到预签名 URL (storageLocation=1 时)
|
||||||
formData.append('file', file)
|
* @param {{url:string, method:string, headers:Object}} presigned 预签名请求描述
|
||||||
return request.post(`/Upload/upload/${hash}`, formData);
|
* @param {File|Blob} file 分片
|
||||||
|
* @param {(pct: number) => void} onProgress
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
uploadPartToUrl: async (presigned, file, onProgress) => {
|
||||||
|
const response = await axios.request({
|
||||||
|
url: presigned.url,
|
||||||
|
method: presigned.method || 'PUT',
|
||||||
|
data: file,
|
||||||
|
headers: presigned.headers || {},
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
if (onProgress && e.total) {
|
||||||
|
onProgress(e.loaded / e.total)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { eTag: response.headers?.etag || response.headers?.ETag || '' }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,168 @@
|
|||||||
import { reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
import { uploadService } from "./uploadService";
|
import { uploadService } from "./uploadService";
|
||||||
import { getFileHash, sliceFile } from "@/utils/uploadTools";
|
import { getFileHash, sliceFile } from "@/utils/uploadTools";
|
||||||
import { request } from "../api";
|
|
||||||
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
||||||
|
|
||||||
|
const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大文件分片上传
|
||||||
|
* 流程:init → uploadParts → complete → status
|
||||||
|
* 秒传:init 显式返回 instant=true 和最终 file。
|
||||||
|
*/
|
||||||
export const uploadFile = async (file, {
|
export const uploadFile = async (file, {
|
||||||
onProgress,
|
onProgress,
|
||||||
onPartComplete
|
onPartComplete,
|
||||||
|
conversationId,
|
||||||
|
chatType,
|
||||||
|
targetId,
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
|
|
||||||
const fileHash = await getFileHash(file);
|
const fileHash = await getFileHash(file);
|
||||||
const { taskId, chunkSize, concurrency, skip, url } = (await uploadService.createUploadTask(file.name, file.size, file.type, fileHash)).data;
|
|
||||||
|
|
||||||
if (skip) {
|
// Step 1: init 上传任务
|
||||||
|
const initRes = await uploadService.initUploadTask({
|
||||||
|
conversationId,
|
||||||
|
chatType,
|
||||||
|
targetId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
contentType: file.type || 'application/octet-stream',
|
||||||
|
checkSum: fileHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
taskId,
|
||||||
|
uploadSessionId,
|
||||||
|
uploadMode,
|
||||||
|
totalPartCount: initializedPartCount,
|
||||||
|
partSizeBytes = DEFAULT_PART_SIZE,
|
||||||
|
instant,
|
||||||
|
} = initRes.data;
|
||||||
|
|
||||||
|
if (instant && initRes.data.file) {
|
||||||
const uploadStatus = {
|
const uploadStatus = {
|
||||||
status: UPLOAD_STATUS.COMPLETE,
|
status: UPLOAD_STATUS.COMPLETE,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
taskId: taskId,
|
taskId,
|
||||||
url: url
|
fileId: initRes.data.file.id,
|
||||||
}
|
url: initRes.data.file.url || null,
|
||||||
onProgress?.(uploadStatus)
|
file: initRes.data.file,
|
||||||
return;
|
};
|
||||||
|
onProgress?.(uploadStatus);
|
||||||
|
return initRes.data.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = sliceFile(file, chunkSize);
|
// Step 2: 获取 totalPartCount
|
||||||
|
let totalPartCount = initializedPartCount;
|
||||||
|
if (!totalPartCount) {
|
||||||
|
const progressRes = await uploadService.getProgress(uploadSessionId);
|
||||||
|
totalPartCount = progressRes.data.totalPartCount;
|
||||||
|
}
|
||||||
|
|
||||||
const comleteData = [];
|
// 切片
|
||||||
|
const chunks = sliceFile(file, partSizeBytes);
|
||||||
|
if (chunks.length !== totalPartCount) {
|
||||||
|
throw new Error(`分片数量不一致:客户端 ${chunks.length},服务端 ${totalPartCount}`);
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
let chunkProgress = reactive(new Array(chunks.length).fill(0))
|
let chunkProgress = reactive(new Array(chunks.length).fill(0));
|
||||||
|
|
||||||
const tasks = chunks.map((chunk, index) => {
|
// Step 3: 逐个上传分片
|
||||||
|
const uploadTasks = chunks.map((chunk, index) => {
|
||||||
return async () => {
|
return async () => {
|
||||||
const partNum = index + 1;
|
const partNumber = index + 1;
|
||||||
const { skip, method, url, headers, partNumber } = (await uploadService.createPartTask(taskId, partNum)).data;
|
|
||||||
if (!skip) {
|
if (uploadMode === 'Presigned') {
|
||||||
const { data } = await uploadService.uploadPart(url, headers, chunks[index], p => {
|
// 预签名 URL 直传(非本地存储)
|
||||||
|
const urlRes = await uploadService.getUploadUrl(uploadSessionId, partNumber);
|
||||||
|
const result = await uploadService.uploadPartToUrl(urlRes.data, chunk, (p) => {
|
||||||
chunkProgress[index] = p;
|
chunkProgress[index] = p;
|
||||||
// 第三步:计算总进度
|
reportProgress(chunkProgress, chunks.length, taskId, onProgress);
|
||||||
// 把账本上所有的百分比加起来
|
|
||||||
const sum = chunkProgress.reduce((acc, cur) => acc + cur, 0);
|
|
||||||
const total = (sum / chunks.length) * 100;
|
|
||||||
const displayTotal = total.toFixed(2);
|
|
||||||
const uploadStatus = {
|
|
||||||
status: displayTotal == 100 ? UPLOAD_STATUS.UPLOADED : UPLOAD_STATUS.UPLOADING,
|
|
||||||
progress: displayTotal,
|
|
||||||
taskId: taskId,
|
|
||||||
url: null
|
|
||||||
}
|
|
||||||
onProgress?.(uploadStatus)
|
|
||||||
});
|
});
|
||||||
onPartComplete?.(partNum)
|
parts.push({ partNumber, eTag: result?.eTag || '', size: chunk.size });
|
||||||
return data;
|
|
||||||
} else {
|
} else {
|
||||||
return { skip, partNumber };
|
// 本地分片上传
|
||||||
}
|
const result = await uploadService.uploadPart(uploadSessionId, partNumber, chunk, (p) => {
|
||||||
}
|
chunkProgress[index] = p;
|
||||||
|
reportProgress(chunkProgress, chunks.length, taskId, onProgress);
|
||||||
|
});
|
||||||
|
onPartComplete?.(partNumber);
|
||||||
|
parts.push({ partNumber, eTag: result?.data?.eTag || '', size: chunk.size });
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const results = await concurrentUpload(tasks, concurrency);
|
|
||||||
|
|
||||||
const errors = results.filter(r => r.status === 'rejected');
|
// 并发 3
|
||||||
|
await concurrentUpload(uploadTasks, 3);
|
||||||
|
|
||||||
if (errors.length > 0) return;
|
onProgress?.({ status: UPLOAD_STATUS.MERGING, progress: 100, taskId, url: null });
|
||||||
|
await uploadService.completeTask(uploadSessionId, parts);
|
||||||
|
|
||||||
await uploadService.completeTask(taskId, comleteData);
|
// Step 4: 等待异步合并落库,最终结果以 status 为准。
|
||||||
|
const MAX_POLL_ATTEMPTS = 120;
|
||||||
const evtSource = new EventSource(`${request.instance.defaults.baseURL}/upload/events/${taskId}`);
|
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
|
||||||
|
const statusRes = await uploadService.getTaskStatus(taskId);
|
||||||
evtSource.onmessage = (event) => {
|
const task = statusRes.data;
|
||||||
const data = JSON.parse(event.data);
|
if (task.state === 'Failed') {
|
||||||
|
throw new Error(task.failureReason || '文件合并失败');
|
||||||
|
}
|
||||||
|
if (task.state === 'Completed' && task.file) {
|
||||||
const uploadStatus = {
|
const uploadStatus = {
|
||||||
status: data.progress == 100 ? UPLOAD_STATUS.COMPLETE : UPLOAD_STATUS.MERGING,
|
status: UPLOAD_STATUS.COMPLETE,
|
||||||
taskId: taskId,
|
progress: 100,
|
||||||
progress: data.progress,
|
taskId,
|
||||||
url: data.url
|
fileId: task.file.id,
|
||||||
}
|
url: task.file.url || null,
|
||||||
onProgress?.(uploadStatus)
|
file: task.file,
|
||||||
|
|
||||||
if (data.status === "Completed") {
|
|
||||||
evtSource.close();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
onProgress?.(uploadStatus);
|
||||||
|
return task.file;
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
throw new Error('等待文件合并超时');
|
||||||
|
};
|
||||||
|
|
||||||
evtSource.onerror = (err) => {
|
function reportProgress(chunkProgress, total, taskId, onProgress) {
|
||||||
console.error("SSE 连接异常", err);
|
const sum = chunkProgress.reduce((acc, cur) => acc + cur, 0);
|
||||||
};
|
const totalProgress = (sum / total) * 100;
|
||||||
|
onProgress?.({
|
||||||
|
status: totalProgress >= 100 ? UPLOAD_STATUS.UPLOADED : UPLOAD_STATUS.UPLOADING,
|
||||||
|
progress: totalProgress.toFixed(2),
|
||||||
|
taskId,
|
||||||
|
url: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
const concurrentUpload = async (tasks, limit = 3, maxRetries = 3) => {
|
const concurrentUpload = async (tasks, limit = 3, maxRetries = 3) => {
|
||||||
const results = [];
|
const results = [];
|
||||||
const executing = [];
|
const executing = [];
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const retryTask = async (task) => {
|
const retryTask = async (t) => {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
while (attempt <= maxRetries) {
|
while (attempt <= maxRetries) {
|
||||||
try {
|
try {
|
||||||
return await task();
|
return await t();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
attempt++;
|
attempt++;
|
||||||
if (attempt > maxRetries) {
|
if (attempt > maxRetries) throw e;
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
const p = Promise.resolve().then(() => retryTask(task));
|
const p = Promise.resolve().then(() => retryTask(task));
|
||||||
results.push(p);
|
results.push(p);
|
||||||
|
|
||||||
if (limit <= tasks.length) {
|
if (limit <= tasks.length) {
|
||||||
const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
|
const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
|
||||||
executing.push(e);
|
executing.push(e);
|
||||||
|
if (executing.length >= limit) await Promise.race(executing);
|
||||||
if (executing.length >= limit) {
|
|
||||||
await Promise.race(executing);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return Promise.all(results);
|
||||||
|
};
|
||||||
return Promise.allSettled(results);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
import { request } from "./api";
|
import { request } from "./api";
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
updateUserInfo: (params) => request.post('/User/Profile', params),
|
/**
|
||||||
getInfo: () => request.get('/user/me')
|
* 当前用户信息
|
||||||
|
* @returns Result<UserResponse>
|
||||||
|
*/
|
||||||
|
getInfo: () => request.get('/user/me'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户资料
|
||||||
|
* @param {*} params { nickName?, region?, avatar?, description? }
|
||||||
|
* @returns Result<UserResponse>
|
||||||
|
*/
|
||||||
|
updateUserInfo: (params) => request.post('/user/update', params),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定用户 (按 userId)
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns Result<UserResponse>
|
||||||
|
*/
|
||||||
|
findUser: (userId) => request.get(`/user/find?userId=${userId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按用户名模糊搜索用户
|
||||||
|
* @param {string} userName
|
||||||
|
* @returns Result<UserResponse>
|
||||||
|
*/
|
||||||
|
findByUname: (userName) => request.get(`/user/findByUname?username=${encodeURIComponent(userName)}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询用户
|
||||||
|
* @param {string[]} ids Guid 数组
|
||||||
|
* @returns Result<List<UserResponse>>
|
||||||
|
*/
|
||||||
|
getUsersByIds: (ids) => request.post('/user/getUsersByIds', ids),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
// 安全解析 localStorage 中的 JSON,避免存入 "undefined" 字符串时崩溃
|
||||||
|
const safeParse = (raw) => {
|
||||||
|
if (!raw || raw === 'undefined' || raw === 'null') return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref(localStorage.getItem('user_token') || '');
|
const token = ref(localStorage.getItem('user_token') || '');
|
||||||
const refreshToken = ref(localStorage.getItem('refresh_token') || '');
|
const refreshToken = ref(localStorage.getItem('refresh_token') || '');
|
||||||
const userInfo = ref(JSON.parse(localStorage.getItem('user_info')) || {});
|
const userInfo = ref(safeParse(localStorage.getItem('user_info')));
|
||||||
|
|
||||||
//判断是否已登录
|
//判断是否已登录
|
||||||
const isLoggedIn = computed(() => !!refreshToken.value);
|
const isLoggedIn = computed(() => !!refreshToken.value);
|
||||||
@@ -15,15 +25,27 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
try {
|
try {
|
||||||
const base64Url = t.split('.')[1];
|
const base64Url = t.split('.')[1];
|
||||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
// 处理 Unicode 字符解码
|
|
||||||
return JSON.parse(decodeURIComponent(atob(base64).split('').map(c =>
|
return JSON.parse(decodeURIComponent(atob(base64).split('').map(c =>
|
||||||
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
||||||
).join('')));
|
).join('')));
|
||||||
} catch (e) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// user_info 可能被写坏(如 setUserInfo 传入 undefined 存成字符串 "undefined"),
|
||||||
|
// 会话恢复时若缺 id,用 JWT 的 nameidentifier 兜底补齐(该声明即服务端认可的用户 id)
|
||||||
|
if (token.value && !userInfo.value?.id) {
|
||||||
|
try {
|
||||||
|
const payload = getPayload(token.value);
|
||||||
|
const jwtId = payload?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'];
|
||||||
|
if (jwtId) {
|
||||||
|
userInfo.value = { ...userInfo.value, id: jwtId };
|
||||||
|
localStorage.setItem('user_info', JSON.stringify(userInfo.value));
|
||||||
|
}
|
||||||
|
} catch { /* 忽略,保持现状 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// 检查 Token 是否过期
|
// 检查 Token 是否过期
|
||||||
const isTokenExpired = computed(() => {
|
const isTokenExpired = computed(() => {
|
||||||
if (!token.value) return true;
|
if (!token.value) return true;
|
||||||
@@ -36,20 +58,25 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录成功保存状态
|
* 登录成功保存状态
|
||||||
* @param {String} newToken 用户凭证
|
* LoginResponse 扁平化:
|
||||||
* @param {*} user 用户信息
|
* { userId, token, refreshToken, expired, userName, nickName, avatar, creationTime }
|
||||||
|
* @param {Object} loginData res.data (LoginResponse)
|
||||||
*/
|
*/
|
||||||
function setLoginInfo(newToken, newRefreshToken, user) {
|
function setLoginInfo(loginData) {
|
||||||
console.log(`设置凭证:\ntoken:${newToken}\nrefreshToken:${newRefreshToken}`)
|
if (!loginData || typeof loginData !== 'object') return;
|
||||||
token.value = newToken;
|
const { token: t, refreshToken: rt, userId, userName, nickName, avatar, creationTime } = loginData;
|
||||||
refreshToken.value = newRefreshToken
|
token.value = t;
|
||||||
|
refreshToken.value = rt;
|
||||||
|
const user = { id: userId, userName, nickName, avatar, creationTime };
|
||||||
userInfo.value = user;
|
userInfo.value = user;
|
||||||
localStorage.setItem('user_token', newToken);
|
localStorage.setItem('user_token', t);
|
||||||
localStorage.setItem('refresh_token', newRefreshToken)
|
localStorage.setItem('refresh_token', rt);
|
||||||
localStorage.setItem('user_info', JSON.stringify(user))
|
localStorage.setItem('user_info', JSON.stringify(user));
|
||||||
};
|
};
|
||||||
|
|
||||||
function setUserInfo(user){
|
function setUserInfo(user){
|
||||||
|
// 防御:避免把 user_info 写成字符串 "undefined"
|
||||||
|
if (!user || typeof user !== 'object') return;
|
||||||
userInfo.value = user;
|
userInfo.value = user;
|
||||||
localStorage.setItem('user_info',JSON.stringify(user));
|
localStorage.setItem('user_info',JSON.stringify(user));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,25 +9,63 @@ export const useChatStore = defineStore('chat', {
|
|||||||
maxSequenceId: null,
|
maxSequenceId: null,
|
||||||
isEnded: false,
|
isEnded: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
pageSize: 20
|
pageSize: 20,
|
||||||
|
// 本地删除的消息 ID 集合(refresh 后从服务器还会拉回来,需要过滤)
|
||||||
|
deletedMsgKeys: new Set(JSON.parse(localStorage.getItem('deleted_msg_keys') || '[]'))
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
|
_normalizeMessage(message) {
|
||||||
|
const body = typeof message.content === 'object' ? message.content?.body : null;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
fileId: message.fileId ?? body?.fileId,
|
||||||
|
url: message.url ?? body?.url,
|
||||||
|
thumb: message.thumb ?? body?.thumb,
|
||||||
|
width: message.width ?? body?.width,
|
||||||
|
height: message.height ?? body?.height,
|
||||||
|
duration: message.duration ?? body?.duration,
|
||||||
|
fileName: message.fileName ?? body?.fileName,
|
||||||
|
fileSize: message.fileSize ?? body?.size,
|
||||||
|
fileFormat: message.fileFormat ?? body?.format,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// 统一过滤已删除消息
|
||||||
|
_filterDeleted(msgs) {
|
||||||
|
return msgs.filter(m => {
|
||||||
|
const key = m.msgId || m.clientMsgId || m.id
|
||||||
|
return !this.deletedMsgKeys.has(key)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteMessage(msg) {
|
||||||
|
const key = msg.msgId || msg.clientMsgId || msg.id
|
||||||
|
if (key) {
|
||||||
|
this.deletedMsgKeys.add(key)
|
||||||
|
localStorage.setItem('deleted_msg_keys', JSON.stringify([...this.deletedMsgKeys]))
|
||||||
|
// 从当前列表移除
|
||||||
|
const idx = this.messages.indexOf(msg)
|
||||||
|
if (idx >= 0) this.messages.splice(idx, 1)
|
||||||
|
// 从 IndexedDB 删除
|
||||||
|
messagesDb.delete(key).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
// 抽取统一的排序去重方法
|
// 抽取统一的排序去重方法
|
||||||
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
|
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
|
||||||
|
const filtered = this._filterDeleted(newMsgs).map((m) => this._normalizeMessage(m))
|
||||||
if (shouldSaveToDb) {
|
if (shouldSaveToDb) {
|
||||||
for (const m of newMsgs) {
|
for (const m of filtered) {
|
||||||
if (m.type != 'Text' && !m.isLoading && !m.isError && !m.isImgLoading) {
|
await messagesDb.save({
|
||||||
m.content = JSON.parse(m.content);
|
...m,
|
||||||
}
|
msgId: m.msgId || m.clientMsgId || m.id,
|
||||||
await messagesDb.save({ ...m, sessionId });
|
sessionId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId == this.activeSessionId) {
|
if (sessionId == this.activeSessionId) {
|
||||||
const combined = [...this.messages, ...newMsgs];
|
const combined = [...this.messages, ...filtered];
|
||||||
// 1. 根据 msgId 或唯一 key 去重
|
// 根据 msgId / clientMsgId / id 去重,降级 sequenceId
|
||||||
const uniqueMap = new Map();
|
const uniqueMap = new Map();
|
||||||
combined.forEach(m => uniqueMap.set(m.msgId || m.sequenceId, m));
|
combined.forEach(m => uniqueMap.set(m.msgId || m.clientMsgId || m.id || m.sequenceId, m));
|
||||||
|
|
||||||
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
|
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
|
||||||
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
||||||
@@ -48,8 +86,8 @@ export const useChatStore = defineStore('chat', {
|
|||||||
this.activeConversationId = conversationId;
|
this.activeConversationId = conversationId;
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
this.isEnded = false;
|
this.isEnded = false;
|
||||||
//先从浏览器缓存加载一部分消息列表
|
const localHistory = this._filterDeleted(await messagesDb.getLatestMessages(sessionId, this.pageSize))
|
||||||
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize);
|
.map((m) => this._normalizeMessage(m));
|
||||||
if (localHistory.length > 0) {
|
if (localHistory.length > 0) {
|
||||||
this.messages = localHistory;
|
this.messages = localHistory;
|
||||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||||
@@ -64,12 +102,10 @@ export const useChatStore = defineStore('chat', {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async fetchNewMsgFromServier(conversationId, sequenceId) {
|
async fetchNewMsgFromServier(conversationId, sequenceId) {
|
||||||
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data;
|
const res = await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize);
|
||||||
if (newMsg.length > 0) {
|
const newMsgs = res.data?.messages || [];
|
||||||
return newMsg;
|
// 注意:不在这里设置 isEnded,避免 direction=1(向前) 的 hasmore=false 错误地阻断历史加载
|
||||||
} else {
|
return newMsgs;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 从服务器加载历史消息
|
* 从服务器加载历史消息
|
||||||
@@ -78,14 +114,10 @@ export const useChatStore = defineStore('chat', {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async fetchHistoryFromServer(conversationId, sequenceId) {
|
async fetchHistoryFromServer(conversationId, sequenceId) {
|
||||||
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data;
|
const res = await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize);
|
||||||
|
const msgs = res.data?.messages || [];
|
||||||
if (res.length > 0) {
|
this.isEnded = !(res.data?.hasmore);
|
||||||
const sessionId = this.activeSessionId;
|
return msgs;
|
||||||
return res;
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 加载更多历史消息
|
* 加载更多历史消息
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { messageService } from "@/services/message";
|
import { messageService } from "@/services/message";
|
||||||
import { conversationDb } from "@/utils/db/conversationDB";
|
import { conversationDb } from "@/utils/db/conversationDB";
|
||||||
import { useMessage } from "@/components/messages/useAlert";
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
|
|
||||||
export const useConversationStore = defineStore('conversation', {
|
export const useConversationStore = defineStore('conversation', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -11,14 +8,21 @@ export const useConversationStore = defineStore('conversation', {
|
|||||||
}),
|
}),
|
||||||
// stores/conversation.js
|
// stores/conversation.js
|
||||||
getters: {
|
getters: {
|
||||||
// 始终根据时间戳倒序排列
|
// 置顶的排在前面,其余按时间倒序
|
||||||
sortedConversations: (state) => {
|
sortedConversations: (state) => {
|
||||||
return [...state.conversations].sort((a, b) =>
|
return [...state.conversations].sort((a, b) => {
|
||||||
new Date(b.dateTime) - new Date(a.dateTime)
|
const pinA = localStorage.getItem(`conv_${a.id}_pin`) === '1' ? 1 : 0
|
||||||
);
|
const pinB = localStorage.getItem(`conv_${b.id}_pin`) === '1' ? 1 : 0
|
||||||
|
if (pinA !== pinB) return pinB - pinA
|
||||||
|
return new Date(b.dateTime) - new Date(a.dateTime)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
async removeConversation(id) {
|
||||||
|
this.conversations = this.conversations.filter((item) => item.id !== id);
|
||||||
|
await conversationDb.delete(id);
|
||||||
|
},
|
||||||
async addConversation(conversation) {
|
async addConversation(conversation) {
|
||||||
await conversationDb.save(conversation);
|
await conversationDb.save(conversation);
|
||||||
this.conversations.unshift(conversation)
|
this.conversations.unshift(conversation)
|
||||||
@@ -32,15 +36,14 @@ export const useConversationStore = defineStore('conversation', {
|
|||||||
const covnersationsCache = await conversationDb.getAll();
|
const covnersationsCache = await conversationDb.getAll();
|
||||||
if (covnersationsCache && covnersationsCache.length > 0) {
|
if (covnersationsCache && covnersationsCache.length > 0) {
|
||||||
this.conversations = covnersationsCache.sort((a, b) => {
|
this.conversations = covnersationsCache.sort((a, b) => {
|
||||||
return new Date(a.dateTime) - new Date(b.dateTime);
|
return new Date(b.dateTime || 0) - new Date(a.dateTime || 0);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error('读取本地会话缓存失败...');
|
|
||||||
console.log('读取本地会话缓存失败:', e);
|
console.log('读取本地会话缓存失败:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//await this.fetchConversationsFromServier()
|
await this.fetchConversationsFromServier()
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 从服务器加载新消息
|
* 从服务器加载新消息
|
||||||
@@ -48,24 +51,16 @@ export const useConversationStore = defineStore('conversation', {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async fetchConversationsFromServier() {
|
async fetchConversationsFromServier() {
|
||||||
const newConversations = (await messageService.getConversations()).data;
|
try {
|
||||||
if (newConversations.length > 0) {
|
const res = await messageService.getConversations();
|
||||||
// 1. 将当前的本地数据转为 Map,方便通过 ID 快速查找 (O(1) 复杂度)
|
const newConversations = res.data || [];
|
||||||
const localMap = new Map(this.conversations.map(item => [item.id, item]));
|
const localMap = new Map(this.conversations.map(item => [item.id, item]));
|
||||||
newConversations.forEach(item => {
|
this.conversations = newConversations.map((item) =>
|
||||||
const existingItem = localMap.get(item.id);
|
Object.assign(localMap.get(item.id) || {}, item)
|
||||||
if (existingItem) {
|
);
|
||||||
// --- 局部更新 ---
|
await conversationDb.replaceAll(this.conversations);
|
||||||
// 使用 Object.assign 将新数据合并到旧对象上,保持响应式引用
|
} catch (e) {
|
||||||
Object.assign(existingItem, item);
|
console.error('获取会话列表失败:', e);
|
||||||
} else {
|
|
||||||
// --- 插入新会话 ---
|
|
||||||
this.conversations.unshift(item);
|
|
||||||
}
|
|
||||||
// 同步到本地数据库
|
|
||||||
conversationDb.save(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { groupService } from '@/services/group'
|
||||||
|
import { useMessage } from '@/components/messages/useAlert'
|
||||||
|
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus'
|
||||||
|
|
||||||
|
export const useGroupStore = defineStore('group', {
|
||||||
|
state: () => ({
|
||||||
|
groups: []
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
removeGroup(groupId) {
|
||||||
|
this.groups = this.groups.filter((group) => (group.id || group.groupId) !== groupId)
|
||||||
|
},
|
||||||
|
async loadGroups() {
|
||||||
|
const message = useMessage()
|
||||||
|
try {
|
||||||
|
const res = await groupService.getAllGroups()
|
||||||
|
if (res.code === SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
this.groups = res.data || []
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '获取群列表失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取群列表失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
|
||||||
import { groupService } from '../services/group'
|
import { groupService } from '../services/group'
|
||||||
import { useMessage } from '../components/messages/useAlert'
|
import { useMessage } from '../components/messages/useAlert'
|
||||||
import { SYSTEM_BASE_STATUS } from '../constants/systemBaseStatus'
|
import { SYSTEM_BASE_STATUS } from '../constants/systemBaseStatus'
|
||||||
@@ -14,6 +13,7 @@ export const useGroupRequestStore = defineStore('groupRequest', {
|
|||||||
this.groupRequest = await groupRequestDb.getAll()
|
this.groupRequest = await groupRequestDb.getAll()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
// 新 API: 群通知页只展示入群申请列表(邀请通知走私聊消息)
|
||||||
const res = await groupService.getGroupNotification()
|
const res = await groupService.getGroupNotification()
|
||||||
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
|
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) return message.error(res.message)
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { reactive } from "vue";
|
|
||||||
|
const NOTIFICATION_KEY = 'notification_options'
|
||||||
|
const GENERAL_KEY = 'general_options'
|
||||||
|
|
||||||
|
const safeParse = (raw, fallback) => {
|
||||||
|
if (!raw || raw === 'undefined' || raw === 'null') return fallback
|
||||||
|
try {
|
||||||
|
return { ...fallback, ...JSON.parse(raw) }
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_NOTIFICATION = {
|
||||||
|
systemUpdate: true,
|
||||||
|
newMsg: true, // 新消息提醒(总开关)
|
||||||
|
desktopPopup: true, // 桌面弹窗
|
||||||
|
emailDigest: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GENERAL = {
|
||||||
|
theme: 'system', // light | dark | system
|
||||||
|
language: 'zh-CN',
|
||||||
|
autoStart: false,
|
||||||
|
closeBehavior: 'tray'
|
||||||
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', {
|
export const useSettingsStore = defineStore('settings', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
notificationOptions: reactive(JSON.parse(localStorage.getItem('notification_options')) || {}),
|
notificationOptions: safeParse(localStorage.getItem(NOTIFICATION_KEY), DEFAULT_NOTIFICATION),
|
||||||
generalOptions: reactive(JSON.parse(localStorage.getItem('generaN_options')) || {})
|
generalOptions: safeParse(localStorage.getItem(GENERAL_KEY), DEFAULT_GENERAL)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
setNotificationOptions(options) {
|
setNotificationOptions(options) {
|
||||||
this.notificationOptions = options;
|
this.notificationOptions = { ...this.notificationOptions, ...options }
|
||||||
localStorage.setItem('notification_options', options)
|
localStorage.setItem(NOTIFICATION_KEY, JSON.stringify(this.notificationOptions))
|
||||||
},
|
},
|
||||||
setGeneralOptions(options){
|
setGeneralOptions(options) {
|
||||||
this.generalOptions = options;
|
this.generalOptions = { ...this.generalOptions, ...options }
|
||||||
localStorage.setItem('generaN_options', options)
|
localStorage.setItem(GENERAL_KEY, JSON.stringify(this.generalOptions))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import * as signalR from '@microsoft/signalr';
|
import * as signalR from '@microsoft/signalr';
|
||||||
import { useMessage } from "@/components/messages/useAlert";
|
|
||||||
import { useAuthStore } from "./auth";
|
import { useAuthStore } from "./auth";
|
||||||
import { useChatStore } from "./chat";
|
|
||||||
import { authService } from "@/services/auth";
|
import { authService } from "@/services/auth";
|
||||||
import { generateSessionId } from "@/utils/sessionIdTools";
|
|
||||||
import { messageHandler } from "@/handler/messageHandler";
|
|
||||||
import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler";
|
import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler";
|
||||||
import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler";
|
import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler";
|
||||||
|
import { networkError } from "@/services/api";
|
||||||
|
|
||||||
export const useSignalRStore = defineStore('signalr', {
|
export const useSignalRStore = defineStore('signalr', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -16,9 +13,8 @@ export const useSignalRStore = defineStore('signalr', {
|
|||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async initSignalR() {
|
async initSignalR() {
|
||||||
const message = useMessage()
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const url = import.meta.env.VITE_SIGNALR_BASE_URL || 'http://localhost:5202/chat/';
|
const url = import.meta.env.DEV ? '/chat' : (import.meta.env.VITE_SIGNALR_BASE_URL || 'http://localhost:5202/chat');
|
||||||
this.connection = new signalR.HubConnectionBuilder()
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
.withUrl(url,
|
.withUrl(url,
|
||||||
{
|
{
|
||||||
@@ -26,7 +22,7 @@ export const useSignalRStore = defineStore('signalr', {
|
|||||||
accessTokenFactory: async () => {
|
accessTokenFactory: async () => {
|
||||||
if (authStore.isTokenExpired) {
|
if (authStore.isTokenExpired) {
|
||||||
const res = await authService.refresh(authStore.refreshToken)
|
const res = await authService.refresh(authStore.refreshToken)
|
||||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
authStore.setLoginInfo(res.data)
|
||||||
}
|
}
|
||||||
return authStore.token;
|
return authStore.token;
|
||||||
}
|
}
|
||||||
@@ -37,65 +33,31 @@ export const useSignalRStore = defineStore('signalr', {
|
|||||||
try {
|
try {
|
||||||
await this.connection.start();
|
await this.connection.start();
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
|
networkError.value = null;
|
||||||
signalRConnectionEventHandler();
|
signalRConnectionEventHandler();
|
||||||
console.log('SignalR建立通信成功!')
|
console.log('SignalR建立通信成功!')
|
||||||
} catch (e) {
|
} catch {
|
||||||
message.error('与服务器建立通信失败,请检查网络连接...');
|
networkError.value = '消息服务连接失败,实时消息不可用';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
registerHandlers() {
|
registerHandlers() {
|
||||||
|
|
||||||
|
|
||||||
this.connection.on('ReceiveMessage', (msg) => {
|
this.connection.on('ReceiveNewMessage', (msg) => {
|
||||||
console.log(msg)
|
console.log(msg)
|
||||||
SignalRMessageHandler(msg)
|
SignalRMessageHandler(msg)
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connection.onclose(() => {
|
this.connection.onclose(() => {
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
|
networkError.value = '消息服务已断开,正在重连...';
|
||||||
});
|
});
|
||||||
this.connection.onreconnected(() => {
|
this.connection.onreconnected(() => {
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
|
networkError.value = null;
|
||||||
signalRConnectionEventHandler();
|
signalRConnectionEventHandler();
|
||||||
});
|
});
|
||||||
|
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* 通过signalr发送消息
|
|
||||||
* @param {*} msg
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
async sendMsg(msg) {
|
|
||||||
const message = useMessage()
|
|
||||||
const chatStore = useChatStore()
|
|
||||||
if (!this.isConnected) {
|
|
||||||
message.error('与服务器连接中断,请重连后尝试...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 后端 Hub 定义的方法名通常为 SendMessage
|
|
||||||
// 参数顺序需要与后端 ChatHub 中的方法签名一致
|
|
||||||
if (msg.msgId == null) {
|
|
||||||
msg.msgId = self.crypto.randomUUID();
|
|
||||||
}
|
|
||||||
const sessionId = generateSessionId(msg.senderId, msg.receiverId);
|
|
||||||
this.connection.invoke("SendMessage", msg).then(() => {
|
|
||||||
const msga = chatStore.messages.find(x => x.msgId == msg.msgId)
|
|
||||||
if (msga.isLoading) {
|
|
||||||
msga.isLoading = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
;
|
|
||||||
chatStore.addMessage({ ...msg, isLoading: true }, sessionId);
|
|
||||||
messageHandler(msg);
|
|
||||||
console.log("消息发送成功!");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("消息发送失败:", err);
|
|
||||||
message.error("消息发送失败");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async clearUnreadCount(conversationId) {
|
|
||||||
await this.connection.invoke("ClearUnreadCount", conversationId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export const GetLocalIso = (date) => {
|
export const GetLocalIso = (date) => {
|
||||||
// 考虑到时区偏差,手动构造符合 C# 要求的本地 ISO 字符串
|
// 手动构造不带时区后缀的本地 ISO 字符串
|
||||||
const offset = -date.getTimezoneOffset();
|
|
||||||
const diff = offset >= 0 ? '+' : '-';
|
|
||||||
const pad = (num) => String(num).padStart(2, '0');
|
const pad = (num) => String(num).padStart(2, '0');
|
||||||
|
|
||||||
return date.getFullYear() +
|
return date.getFullYear() +
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ const CONVERSARION_STORE_NAME = 'conversations'
|
|||||||
const CONTACT_STORE_NAME = 'contacts'
|
const CONTACT_STORE_NAME = 'contacts'
|
||||||
const GROUP_REQUEST_STORE_NAME = 'groupRequests'
|
const GROUP_REQUEST_STORE_NAME = 'groupRequests'
|
||||||
|
|
||||||
export const dbPromise = openDB(DBNAME, 7, {
|
export const dbPromise = openDB(DBNAME, 8, {
|
||||||
upgrade(db) {
|
upgrade(db, oldVersion) {
|
||||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' })
|
const store = db.createObjectStore(STORE_NAME, { keyPath: 'msgId' })
|
||||||
store.createIndex('by-sessionId', 'sessionId')
|
store.createIndex('by-sessionId', 'sessionId')
|
||||||
@@ -26,9 +26,13 @@ export const dbPromise = openDB(DBNAME, 7, {
|
|||||||
store.createIndex('by-friendId', 'friendId', { unique: true })
|
store.createIndex('by-friendId', 'friendId', { unique: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v8: groupRequests 主键由 requestId 改为 id(接口数据字段为 id,原 requestId 导致 put 抛 DataError)
|
||||||
|
if (oldVersion < 8 && db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) {
|
||||||
|
db.deleteObjectStore(GROUP_REQUEST_STORE_NAME)
|
||||||
|
}
|
||||||
if (!db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) {
|
if (!db.objectStoreNames.contains(GROUP_REQUEST_STORE_NAME)) {
|
||||||
const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'requestId' })
|
const store = db.createObjectStore(GROUP_REQUEST_STORE_NAME, { keyPath: 'id' })
|
||||||
store.createIndex('by-id', 'requestId')
|
store.createIndex('by-id', 'id')
|
||||||
store.createIndex('by-userid', 'userId')
|
store.createIndex('by-userid', 'userId')
|
||||||
store.createIndex('by-groupid', 'groupId')
|
store.createIndex('by-groupid', 'groupId')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ export const conversationDb = {
|
|||||||
async getAll() {
|
async getAll() {
|
||||||
return (await dbPromise).getAll(STORE_NAME);
|
return (await dbPromise).getAll(STORE_NAME);
|
||||||
},
|
},
|
||||||
|
async replaceAll(conversations) {
|
||||||
|
const db = await dbPromise;
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
await tx.store.clear();
|
||||||
|
await Promise.all(conversations.map((item) => tx.store.put(item)));
|
||||||
|
await tx.done;
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
return (await dbPromise).delete(STORE_NAME, id);
|
||||||
|
},
|
||||||
async clearAll() {
|
async clearAll() {
|
||||||
(await dbPromise).clear(STORE_NAME);
|
(await dbPromise).clear(STORE_NAME);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export const groupRequestDb = {
|
|||||||
(await dbPromise).put(STORE_NAME, request)
|
(await dbPromise).put(STORE_NAME, request)
|
||||||
},
|
},
|
||||||
async getAll(){
|
async getAll(){
|
||||||
(await dbPromise).getAll(STORE_NAME)
|
return await (await dbPromise).getAll(STORE_NAME)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 防抖:连续触发时只在最后一次调用结束后等待 wait 毫秒再执行。
|
||||||
|
* @param {Function} fn 要防抖的函数
|
||||||
|
* @param {number} wait 等待毫秒数,默认 500
|
||||||
|
* @returns {Function} 防抖后的函数
|
||||||
|
*/
|
||||||
|
export function debounce(fn, wait = 500) {
|
||||||
|
let timer = null
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => fn(...args), wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
export function formatDate(dateStr) {
|
export function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return ''
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // 补零
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function getVideoThumbnailBlob(file, seekTime = 1) {
|
|||||||
// 如果导出的 blob 大小极小(可能是空的),可以在这里进一步检查
|
// 如果导出的 blob 大小极小(可能是空的),可以在这里进一步检查
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
}, 'image/jpeg', 0.8);
|
}, 'image/jpeg', 0.8);
|
||||||
} catch (e) {
|
} catch {
|
||||||
resolveBlackThumbnail();
|
resolveBlackThumbnail();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,65 @@
|
|||||||
import { useBrowserNotification } from '@/services/useBrowserNotification'
|
import { useBrowserNotification } from '@/services/useBrowserNotification'
|
||||||
|
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
|
||||||
import { messageHandler } from '@/handler/messageHandler'
|
import { messageHandler } from '@/handler/messageHandler'
|
||||||
import { generateSessionId } from '../sessionIdTools'
|
import { generateSessionId } from '../sessionIdTools'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { MESSAGE_TYPE } from '@/constants/MessageType'
|
import { CHAT_TYPE } from '@/constants/MessageType'
|
||||||
import { NOTIFICATION_TYPE } from '../../constants/notificationType'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
export const SignalRMessageHandler = (raw) => {
|
||||||
|
const arr = raw['arguments'] || []
|
||||||
|
const msg = arr[0] || raw.data || raw
|
||||||
|
if (!msg || !msg.senderId) return
|
||||||
|
|
||||||
|
// 归一化
|
||||||
|
msg.msgId = msg.msgId || msg.clientId || msg.id
|
||||||
|
msg.timeStamp = msg.pushTimestamp ? new Date(msg.pushTimestamp).toISOString() : (msg.timeStamp || new Date().toISOString())
|
||||||
|
if (msg.content?.body) {
|
||||||
|
msg.content.body.text = msg.content.body.text || msg.content.body.Text || ''
|
||||||
|
}
|
||||||
|
|
||||||
export const SignalRMessageHandler = (data) => {
|
|
||||||
const msg = data.data
|
|
||||||
const type = data.type
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const browserNotification = useBrowserNotification()
|
const settingsStore = useSettingsStore()
|
||||||
|
const { newMsg, desktopPopup } = settingsStore.notificationOptions
|
||||||
|
|
||||||
const sessionId = generateSessionId(
|
const sessionId = generateSessionId(
|
||||||
msg.senderId,
|
msg.senderId,
|
||||||
msg.receiverId,
|
msg.targetId,
|
||||||
msg.chatType == MESSAGE_TYPE.GROUP
|
msg.chatType == CHAT_TYPE.GROUP
|
||||||
)
|
)
|
||||||
messageHandler(msg)
|
messageHandler(msg) // async,await 可选——不阻塞 UI
|
||||||
chatStore.pushAndSortMessagesAsync([msg], sessionId)
|
chatStore.pushAndSortMessagesAsync([msg], sessionId)
|
||||||
const conversation = useConversationStore().conversations.find((x) => x.targetId == msg.senderId)
|
|
||||||
|
if (!newMsg) return
|
||||||
|
|
||||||
|
const conversationStore = useConversationStore()
|
||||||
|
const conversation = conversationStore.conversations.find((x) => x.targetId == msg.senderId)
|
||||||
|
const contentText = typeof msg.content === 'object'
|
||||||
|
? (msg.content?.body?.text || msg.content?.fallback || '')
|
||||||
|
: msg.content;
|
||||||
|
|
||||||
|
const isMuted = conversation ? localStorage.getItem(`conv_${conversation.id}_mute`) === '1' : false
|
||||||
|
if (isMuted) return
|
||||||
|
|
||||||
|
// 闪动 & 声音:任何新消息都触发,不依赖会话是否已加载
|
||||||
|
try { window.api?.window?.flash?.() } catch { /* Desktop attention feedback is best-effort. */ }
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
const gain = ctx.createGain()
|
||||||
|
osc.connect(gain); gain.connect(ctx.destination)
|
||||||
|
osc.frequency.value = 800; gain.gain.value = 0.08
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15)
|
||||||
|
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.15)
|
||||||
|
} catch { /* Audio feedback may be blocked by the runtime or user settings. */ }
|
||||||
|
|
||||||
|
// 桌面弹窗需要会话信息(名称+头像)
|
||||||
|
if (conversation && desktopPopup) {
|
||||||
|
const browserNotification = useBrowserNotification()
|
||||||
browserNotification.send(`${conversation.targetName}发来一条消息`, {
|
browserNotification.send(`${conversation.targetName}发来一条消息`, {
|
||||||
body: msg.content,
|
body: contentText,
|
||||||
icon: conversation.targetAvatar
|
icon: conversation.targetAvatar
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useConversationStore } from "@/stores/conversation"
|
|||||||
|
|
||||||
export const signalRConnectionEventHandler = () => {
|
export const signalRConnectionEventHandler = () => {
|
||||||
const conversationStore = useConversationStore();
|
const conversationStore = useConversationStore();
|
||||||
conversationStore.fetchConversationsFromServier().then(res => {
|
conversationStore.fetchConversationsFromServier().then(() => {
|
||||||
conversationStore.conversations.forEach(element => {
|
conversationStore.conversations.forEach(element => {
|
||||||
element.isInitialized = false;
|
element.isInitialized = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,12 +222,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 头像样式统一 */
|
/* 头像样式统一 */
|
||||||
:deep(.avatar-std) {
|
:deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0; }
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-chat {
|
.avatar-chat {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useCacheStore } from '../stores/cache';
|
import { useCacheStore } from '../stores/cache';
|
||||||
import { FILE_TYPE } from '../constants/fileTypeDefine';
|
import { FILE_TYPE } from '../constants/fileTypeDefine';
|
||||||
import AsyncImage from '../components/AsyncImage.vue';
|
|
||||||
import Dropdown from '../components/Dropdown.vue';
|
import Dropdown from '../components/Dropdown.vue';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox"> 自动登录
|
<input type="checkbox"> 自动登录
|
||||||
</label>
|
</label>
|
||||||
<router-link to="/forget" class="link">找回密码</router-link>
|
<a href="#" class="link" @click.prevent="message.info('找回密码功能开发中')">找回密码</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="login-btn" :disabled="loading">
|
<button type="submit" class="login-btn" :disabled="loading">
|
||||||
@@ -97,7 +97,7 @@ const handleLogin = async () => {
|
|||||||
const res = await authService.login(form);
|
const res = await authService.login(form);
|
||||||
if (res.code === 0) { // Assuming 0 is success
|
if (res.code === 0) { // Assuming 0 is success
|
||||||
message.success('登录成功')
|
message.success('登录成功')
|
||||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo);
|
authStore.setLoginInfo(res.data);
|
||||||
signalRStore.initSignalR();
|
signalRStore.initSignalR();
|
||||||
router.push('/messages')
|
router.push('/messages')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -141,10 +141,9 @@ const handleRegister = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
// 调用注册接口,过滤掉本地状态如 confirmPassword 和 agree
|
// API.md Register 参数: userName, password, nickName (不含 email)
|
||||||
const res = await authService.register({
|
const res = await authService.register({
|
||||||
username: form.username,
|
userName: form.username,
|
||||||
email: form.email,
|
|
||||||
password: form.password,
|
password: form.password,
|
||||||
nickName: form.nickName
|
nickName: form.nickName
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,37 +48,23 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import GroupChatModal from '@/components/groups/GroupChatModal.vue'
|
import GroupChatModal from '@/components/groups/GroupChatModal.vue'
|
||||||
import feather from 'feather-icons';
|
import feather from 'feather-icons';
|
||||||
import { useContactStore } from '@/stores/contact';
|
import { useContactStore } from '@/stores/contact';
|
||||||
import { useRouter } from 'vue-router';
|
import { useGroupStore } from '@/stores/group';
|
||||||
import contactShow from '@/components/contacts/contactShow.vue';
|
import contactShow from '@/components/contacts/contactShow.vue';
|
||||||
import groupsShow from '@/components/groups/groupsShow.vue';
|
import groupsShow from '@/components/groups/groupsShow.vue';
|
||||||
|
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const contactStore = useContactStore();
|
const contactStore = useContactStore();
|
||||||
|
const groupStore = useGroupStore();
|
||||||
|
|
||||||
const groupModal = ref(false);
|
const groupModal = ref(false);
|
||||||
const contactTab = ref(0);
|
const contactTab = ref(0);
|
||||||
|
|
||||||
const myGroups = ref([
|
const myGroups = computed(() => {
|
||||||
{
|
const searchKey = searchQuery.value.toString().trim();
|
||||||
id: 1,
|
if (!searchKey) return groupStore.groups;
|
||||||
name: "产品设计交流群",
|
return groupStore.groups.filter(g => (g.name || '').includes(searchKey));
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
|
});
|
||||||
lastMessage: "那个UI设计的初稿已经发在群文件了,大家记得看下。",
|
|
||||||
lastTime: "14:20",
|
|
||||||
unread: 3,
|
|
||||||
online: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "周五羽毛球小分队",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
|
||||||
lastMessage: "这周五晚上 8 点,老地方见!",
|
|
||||||
lastTime: "昨天",
|
|
||||||
unread: 0,
|
|
||||||
online: false
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
|
||||||
const filteredContacts = computed(() => {
|
const filteredContacts = computed(() => {
|
||||||
@@ -93,9 +79,10 @@ const filteredContacts = computed(() => {
|
|||||||
if (!c) return false
|
if (!c) return false
|
||||||
|
|
||||||
const remark = c.remarkName || ''
|
const remark = c.remarkName || ''
|
||||||
const username = c.userInfo.username || ''
|
const nickname = c.nickName || ''
|
||||||
|
const userName = c.targetId || c.userName || ''
|
||||||
|
|
||||||
return remark.includes(searchKey) || username.includes(searchKey)
|
return remark.includes(searchKey) || nickname.includes(searchKey) || userName.includes(searchKey)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,13 +91,17 @@ const filteredContacts = computed(() => {
|
|||||||
// 发送事件给父组件(用于切换回聊天Tab并打开会话)
|
// 发送事件给父组件(用于切换回聊天Tab并打开会话)
|
||||||
const emit = defineEmits(['start-chat'])
|
const emit = defineEmits(['start-chat'])
|
||||||
|
|
||||||
// const showGroupList = () => {
|
// 群聊弹窗选择回调
|
||||||
// }
|
const handleChatSelect = (group) => {
|
||||||
|
groupModal.value = false
|
||||||
|
emit('start-chat', group)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await contactStore.loadContactList();
|
await contactStore.loadContactList();
|
||||||
|
await groupStore.loadGroups();
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,19 @@
|
|||||||
|
|
||||||
<div class="request-group">
|
<div class="request-group">
|
||||||
<div v-for="item in requests" :key="item.id" class="minimal-item">
|
<div v-for="item in requests" :key="item.id" class="minimal-item">
|
||||||
<img :src="item.avatar" class="avatar" />
|
<AsyncImage :raw-url="item.ownerId != authStore.userInfo.id ? item.ownerAvatar : item.targetAvatar" class="avatar" />
|
||||||
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span class="name">{{ item.nickName }}</span>
|
<span class="name">{{ item.ownerId != authStore.userInfo.id ? (item.ownerNickName || item.ownerId) : (item.targetNickName || item.targetId) }}</span>
|
||||||
<span class="date">{{ formatDate(item.created) }}</span>
|
<span class="date">{{ formatDate(item.creationTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="sub-text">{{ item.description }}</p>
|
<p class="sub-text">{{ item.description }}</p>
|
||||||
<p v-if="item.remark" class="remark-text">{{ item.remark }}</p>
|
<p v-if="item.remarkName" class="remark-text">备注:{{ item.remarkName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<template v-if="item.state === FRIEND_REQUEST_STATUS.Pending && item.requestUser != authStore.userInfo.id">
|
<template v-if="item.state === FRIEND_REQUEST_STATUS.Pending && item.ownerId != authStore.userInfo.id">
|
||||||
<button class="btn-text btn-reject" @click="confirmReject(item)">拒绝</button>
|
<button class="btn-text btn-reject" @click="confirmReject(item)">拒绝</button>
|
||||||
<button class="btn-text btn-accept" @click="handleOpenDialog(item)">接受</button>
|
<button class="btn-text btn-accept" @click="handleOpenDialog(item)">接受</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,12 +29,12 @@
|
|||||||
待对方同意
|
待对方同意
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Declined" class="status-label">
|
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Declined" class="status-label">
|
||||||
{{item.requestUser != authStore.userInfo.id ? '已拒绝' : '对方拒绝'}}
|
{{item.ownerId != authStore.userInfo.id ? '已拒绝' : '对方拒绝'}}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Passed" class="status-label">
|
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Passed" class="status-label">
|
||||||
已添加
|
已添加
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Blocked && item.requestUser != authStore.userInfo.id" class="status-label">
|
<span v-else-if="item.state === FRIEND_REQUEST_STATUS.Blocked && item.ownerId != authStore.userInfo.id" class="status-label">
|
||||||
已拉黑
|
已拉黑
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="status-label">
|
<span v-else class="status-label">
|
||||||
@@ -42,6 +42,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="requests.length === 0" class="empty-placeholder">
|
||||||
|
<i v-html="feather.icons['user-plus'].toSvg({ width: 40, height: 40 })"></i>
|
||||||
|
<p class="empty-text">暂无好友申请</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showDialog" class="modal-mask">
|
<div v-if="showDialog" class="modal-mask">
|
||||||
@@ -68,7 +73,9 @@ import { formatDate } from '@/utils/formatDate';
|
|||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction';
|
import { FRIEND_ACTIONS, FRIEND_REQUEST_STATUS } from '@/constants/friendAction';
|
||||||
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
||||||
|
import feather from 'feather-icons';
|
||||||
import WindowControls from '../../components/WindowControls.vue';
|
import WindowControls from '../../components/WindowControls.vue';
|
||||||
|
import AsyncImage from '../../components/AsyncImage.vue';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -90,7 +97,8 @@ const activeItem = ref(null);
|
|||||||
|
|
||||||
const handleOpenDialog = (item) => {
|
const handleOpenDialog = (item) => {
|
||||||
activeItem.value = item;
|
activeItem.value = item;
|
||||||
remarkName.value = item.nickName; // 默认备注为昵称
|
// 对方是请求发起人(ownerId),默认备注用对方的昵称
|
||||||
|
remarkName.value = item.ownerNickName || item.ownerId || '';
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,6 +176,16 @@ onMounted(async () => {
|
|||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: #c1c1c6;
|
||||||
|
}
|
||||||
|
.empty-placeholder i { color: #d2d2d7; margin-bottom: 14px; line-height: 0; }
|
||||||
|
.empty-text { font-size: 14px; color: #86868b; margin: 0; }
|
||||||
|
|
||||||
/* 3. 列表项:去掉外框和投影,靠间距呼吸 */
|
/* 3. 列表项:去掉外框和投影,靠间距呼吸 */
|
||||||
.minimal-item {
|
.minimal-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -178,11 +196,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 4. 头像:小而圆润 */
|
/* 4. 头像:小而圆润 */
|
||||||
.avatar {
|
:deep(.avatar) {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 50%; /* 纯圆更简洁 */
|
border-radius: 50%;
|
||||||
background-color: #f5f5f7;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<template>
|
||||||
|
<div id="GroupInfoContainer">
|
||||||
|
<WindowControls/>
|
||||||
|
<main class="profile-main">
|
||||||
|
|
||||||
|
<div v-if="groupInfo" class="profile-card">
|
||||||
|
<header class="profile-header">
|
||||||
|
<div class="text-info">
|
||||||
|
<h2 class="display-name">{{ groupInfo.name }}</h2>
|
||||||
|
<p class="sub-text">群ID:{{ groupInfo.id }}</p>
|
||||||
|
<p class="sub-text">成员:{{ members.length }} 人</p>
|
||||||
|
</div>
|
||||||
|
<AsyncImage :raw-url="groupInfo.avatar" class="big-avatar" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="profile-body">
|
||||||
|
<div class="info-block">
|
||||||
|
<span class="label">群公告</span>
|
||||||
|
<p class="announce">{{ groupInfo.announcement || '暂无群公告' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-block">
|
||||||
|
<span class="label">群成员</span>
|
||||||
|
<div class="member-grid">
|
||||||
|
<div v-for="m in members" :key="m.id" class="member-item">
|
||||||
|
<AsyncImage :raw-url="m.avatar" class="member-avatar" />
|
||||||
|
<span class="member-name">{{ m.groupNickName || '成员' }}</span>
|
||||||
|
<span v-if="m.role === GROUP_MEMBER_ROLE.MASTER" class="role-tag master">群主</span>
|
||||||
|
<span v-else-if="m.role === GROUP_MEMBER_ROLE.ADMIN" class="role-tag admin">管理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="profile-footer">
|
||||||
|
<button class="btn-primary" @click="handleGoToChat">发消息</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="loading-state">加载中...</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { groupService } from '@/services/group';
|
||||||
|
import { useConversationStore } from '@/stores/conversation';
|
||||||
|
import { useMessage } from '@/components/messages/useAlert';
|
||||||
|
import { GROUP_MEMBER_ROLE } from '@/constants/GroupDefine';
|
||||||
|
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
||||||
|
import WindowControls from '../../components/WindowControls.vue';
|
||||||
|
import AsyncImage from '../../components/AsyncImage.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: { type: String, required: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const conversationStore = useConversationStore();
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
|
const groupInfo = ref(null)
|
||||||
|
const members = ref([])
|
||||||
|
|
||||||
|
const loadGroup = async (groupId) => {
|
||||||
|
groupInfo.value = null
|
||||||
|
members.value = []
|
||||||
|
try {
|
||||||
|
const infoRes = await groupService.getGroupInfo(groupId)
|
||||||
|
if (infoRes.code === SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
groupInfo.value = infoRes.data
|
||||||
|
} else {
|
||||||
|
message.error(infoRes.message || '获取群信息失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const memRes = await groupService.getGroupMember(groupId)
|
||||||
|
if (memRes.code === SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
members.value = memRes.data || []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载群信息失败:', e)
|
||||||
|
message.error('加载群信息失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoToChat = () => {
|
||||||
|
// 会话的 targetId 即群 ID
|
||||||
|
const c = conversationStore.conversations.find(x => x.targetId == props.id)
|
||||||
|
if (c) {
|
||||||
|
router.push(`/messages/chat/${c.id}`)
|
||||||
|
} else {
|
||||||
|
message.info('暂无该群会话')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, (newId) => { if (newId) loadGroup(newId) })
|
||||||
|
onMounted(() => loadGroup(props.id))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
width: 460px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid #e7e7e7;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #000;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.big-avatar) { width: 70px; height: 70px; border-radius: 12px; }
|
||||||
|
|
||||||
|
.info-block {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announce {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.member-avatar) { width: 44px; height: 44px; border-radius: 8px; }
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
.role-tag.master { background: #ff9500; }
|
||||||
|
.role-tag.admin { background: #34c759; }
|
||||||
|
|
||||||
|
.profile-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 160px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,226 +6,138 @@
|
|||||||
<div class="section-title">群聊通知</div>
|
<div class="section-title">群聊通知</div>
|
||||||
|
|
||||||
<div class="request-group">
|
<div class="request-group">
|
||||||
<div v-for="item in groupRequest" :key="item.requestId" class="minimal-item">
|
<div v-for="item in groupRequest" :key="item.id" class="minimal-item">
|
||||||
|
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<img :src="avatarHandle(item)" :class="[
|
<img :src="item.groupAvatar" class="avatar is-group" />
|
||||||
'avatar',
|
|
||||||
item.type === GROUP_REQUEST_STATUS.IS_GROUP ||
|
|
||||||
item.type === GROUP_REQUEST_STATUS.IS_USER
|
|
||||||
? 'is-group'
|
|
||||||
: 'is-user'
|
|
||||||
]" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span class="name">{{ item.name }}</span>
|
<span class="name">{{ item.userNickName }}</span>
|
||||||
<span :class="[
|
<span class="type-tag tag-orange">
|
||||||
'type-tag',
|
申请入群
|
||||||
item.type === GROUP_REQUEST_TYPE.INVITE ||
|
|
||||||
item.type === GROUP_REQUEST_TYPE.IS_USER
|
|
||||||
? 'tag-orange'
|
|
||||||
: 'tag-green'
|
|
||||||
]">
|
|
||||||
{{ getTypeText(item.type) }}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="date">14:20</span>
|
<span class="date">{{ formatDate(item.created) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="group-info">
|
<div class="group-info">
|
||||||
<span class="label">目标群聊:</span>
|
<span class="label">目标群聊:</span>
|
||||||
<span class="group-name">{{ item.groupName }}</span>
|
<span class="group-name">{{ item.groupName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="[GROUP_REQUEST_TYPE.INVITE, GROUP_REQUEST_TYPE.INVITED].includes(item.type)"
|
|
||||||
class="group-info">
|
|
||||||
<span class="label">目标用户:</span>
|
|
||||||
<span class="group-name">{{
|
|
||||||
myInfo.id === item.userId ? item.inviteUserNickname : item.nickName
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="sub-text">入群描述:{{ item.description }}</p>
|
<p class="sub-text">入群描述:{{ item.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="[GROUP_REQUEST_TYPE.INVITED, GROUP_REQUEST_TYPE.IS_GROUP].includes(item.type) && [GROUP_REQUEST_STATUS.PENDING, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(item.status)" class="actions">
|
<div v-if="item.state === GROUP_REQUEST_STATUS.PENDING" class="actions">
|
||||||
<button class="btn-text btn-reject">忽略</button>
|
<button class="btn-text btn-reject" @click="groupRequestHandler(item, GROUP_REQUEST_ACTION.REJECT)">拒绝</button>
|
||||||
<Dropdown :disable="handleDropDownDisable" class="handleDropDown" v-model="handleValue" :options="[
|
<button class="btn-text btn-accept" @click="groupRequestHandler(item, GROUP_REQUEST_ACTION.ACCEPT)">同意</button>
|
||||||
{ label: '同意', value: GROUP_REQUEST_ACTION.ACCEPT },
|
|
||||||
{ label: '拒绝', value: GROUP_REQUEST_ACTION.REJECT }
|
|
||||||
]" placeholder="处理" @change="groupRequestHandler(item, handleValue)" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions" v-else>
|
<div class="actions" v-else>
|
||||||
<div class="status-tag" :class="getGroupRequestStatusClass(item.status)">
|
<div class="status-tag" :class="getGroupRequestStatusClass(item.state)">
|
||||||
<span>{{ getGroupRequestStatusTxt(item.status) }}</span>
|
<span>{{ getGroupRequestStatusTxt(item.state) }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-hint">仅保留最近 30 天的通知记录</div>
|
<div v-if="groupRequest.length === 0" class="empty-placeholder">
|
||||||
|
<i v-html="feather.icons['users'].toSvg({ width: 40, height: 40 })"></i>
|
||||||
|
<p class="empty-text">暂无群聊通知</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupRequest.length > 0" class="footer-hint">仅保留最近 30 天的通知记录</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, computed, ref, watch } from 'vue';
|
import { onMounted, computed } from 'vue';
|
||||||
import WindowControls from '../../components/WindowControls.vue';
|
import WindowControls from '../../components/WindowControls.vue';
|
||||||
import { useGroupRequestStore } from '../../stores/groupRequest';
|
import { useGroupRequestStore } from '../../stores/groupRequest';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
|
||||||
import { GROUP_REQUEST_TYPE, getTypeText } from '../../constants/groupRequestTypeDefine';
|
|
||||||
import { GROUP_REQUEST_ACTION, GROUP_REQUEST_STATUS, getGroupRequestStatusTxt } from '../../constants/GroupDefine';
|
import { GROUP_REQUEST_ACTION, GROUP_REQUEST_STATUS, getGroupRequestStatusTxt } from '../../constants/GroupDefine';
|
||||||
import Dropdown from '../../components/Dropdown.vue';
|
|
||||||
import { groupService } from '../../services/group';
|
import { groupService } from '../../services/group';
|
||||||
import { useMessage } from '../../components/messages/useAlert';
|
import { useMessage } from '../../components/messages/useAlert';
|
||||||
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
|
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus';
|
||||||
|
import { formatDate } from '@/utils/formatDate';
|
||||||
|
import feather from 'feather-icons';
|
||||||
|
|
||||||
const groupRequestStore = useGroupRequestStore()
|
const groupRequestStore = useGroupRequestStore()
|
||||||
const myInfo = useAuthStore().userInfo
|
|
||||||
const handleValue = ref(null)
|
|
||||||
const handleDropDownDisable = ref(false)
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
|
||||||
const groupRequest = computed(() => {
|
const groupRequest = computed(() => {
|
||||||
if (!groupRequestStore.groupRequest) {
|
if (!groupRequestStore.groupRequest) return [];
|
||||||
return [];
|
return groupRequestStore.groupRequest;
|
||||||
}
|
|
||||||
return groupRequestStore.groupRequest.map((item) => {
|
|
||||||
return { ...item, type: getRequestType(item) }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const getGroupRequestStatusClass = (status) => {
|
const getGroupRequestStatusClass = (state) => {
|
||||||
const classMap = {
|
const classMap = {
|
||||||
[GROUP_REQUEST_STATUS.PENDING]: 'status-pending',
|
[GROUP_REQUEST_STATUS.PENDING]: 'status-pending',
|
||||||
[GROUP_REQUEST_STATUS.TARGET_PENDING]: 'status-pending',
|
|
||||||
[GROUP_REQUEST_STATUS.PASSED]: 'status-passed',
|
[GROUP_REQUEST_STATUS.PASSED]: 'status-passed',
|
||||||
[GROUP_REQUEST_STATUS.DECLINED]: 'status-declined',
|
[GROUP_REQUEST_STATUS.DECLINED]: 'status-declined',
|
||||||
[GROUP_REQUEST_STATUS.TARGET_DECLINED]: 'status-declined',
|
|
||||||
};
|
};
|
||||||
return classMap[status] || '';
|
return classMap[state] || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatarHandle = (request) => {
|
// 入群请求处理
|
||||||
switch (request.type) {
|
|
||||||
case GROUP_REQUEST_STATUS.IS_GROUP:
|
|
||||||
case GROUP_REQUEST_STATUS.IS_USER:
|
|
||||||
return request.groupAvatar;
|
|
||||||
|
|
||||||
case GROUP_REQUEST_STATUS.INVITE:
|
|
||||||
return request.userAvatar;
|
|
||||||
case GROUP_REQUEST_STATUS.INVITED:
|
|
||||||
return request.inviteUserAvatar;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return request.groupAvatar;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRequestType = (request) => {
|
|
||||||
if (
|
|
||||||
request.inviteUser &&
|
|
||||||
request.inviteUser == myInfo.id &&
|
|
||||||
[GROUP_REQUEST_STATUS.TARGET_DECLINED, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(
|
|
||||||
request.status
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return GROUP_REQUEST_TYPE.INVITE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
request.inviteUser &&
|
|
||||||
request.userId == myInfo.id &&
|
|
||||||
[GROUP_REQUEST_STATUS.TARGET_DECLINED, GROUP_REQUEST_STATUS.TARGET_PENDING].includes(
|
|
||||||
request.status
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return GROUP_REQUEST_TYPE.INVITED;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.userId == myInfo.id) {
|
|
||||||
return GROUP_REQUEST_TYPE.IS_USER;
|
|
||||||
}
|
|
||||||
if (request.inviteUser != myInfo.id && request.userId != myInfo.id) {
|
|
||||||
return GROUP_REQUEST_TYPE.IS_GROUP;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//入群请求处理
|
|
||||||
const groupRequestHandler = async (request, action) => {
|
const groupRequestHandler = async (request, action) => {
|
||||||
if (!request || !request.type) return
|
if (!request || !request.id) return;
|
||||||
let requestAction = GROUP_REQUEST_STATUS.PASSED
|
const result = await groupService.handleGroupRequest(request.id, action);
|
||||||
let result = null
|
if (result.code == SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
switch (request.type) {
|
message.success('操作成功');
|
||||||
case GROUP_REQUEST_TYPE.INVITED:
|
// 重新加载列表
|
||||||
requestAction = action == GROUP_REQUEST_ACTION.ACCEPT ? GROUP_REQUEST_STATUS.TARGET_PASSED : GROUP_REQUEST_STATUS.TARGET_DECLINED;
|
await groupRequestStore.loadGroupRequest();
|
||||||
result = await groupService.handleGroupInvite(request.requestId ,requestAction)
|
} else {
|
||||||
break
|
message.error(result.message);
|
||||||
case GROUP_REQUEST_TYPE.IS_GROUP:
|
|
||||||
requestAction = action == GROUP_REQUEST_ACTION.ACCEPT ? GROUP_REQUEST_STATUS.PASSED : GROUP_REQUEST_STATUS.DECLINED;
|
|
||||||
result = await groupService.handleGroupRequest(request.requestId, requestAction)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if(result.code == SYSTEM_BASE_STATUS.SUCCESS){
|
|
||||||
message.success('操作成功')
|
|
||||||
}else{
|
|
||||||
message.error(result.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await groupRequestStore.loadGroupRequest()
|
await groupRequestStore.loadGroupRequest();
|
||||||
console.log(groupRequestStore.groupRequest)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 容器基础环境 */
|
|
||||||
.minimal-page {
|
.minimal-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
background-color: #f5f5f7;
|
background-color: #f5f5f5;
|
||||||
/* Apple 官网经典的背景灰 */
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-container {
|
.request-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f5f5f5;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 40px;
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-limit {
|
.content-limit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 640px;
|
||||||
padding: 0 20px;
|
padding: 60px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #86868b;
|
color: #86868b;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
margin-bottom: 32px;
|
||||||
margin-bottom: 24px;
|
padding-left: 4px;
|
||||||
padding-left: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表项设计 */
|
|
||||||
.minimal-item {
|
.minimal-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
/* 较圆润的倒角 */
|
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.02);
|
border: 1px solid rgba(0, 0, 0, 0.02);
|
||||||
@@ -236,7 +148,6 @@ onMounted(async () => {
|
|||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 头像差异化处理 */
|
|
||||||
.avatar-wrapper {
|
.avatar-wrapper {
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
}
|
}
|
||||||
@@ -248,18 +159,10 @@ onMounted(async () => {
|
|||||||
background: #f2f2f7;
|
background: #f2f2f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-user {
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 用户是圆的 */
|
|
||||||
.is-group {
|
.is-group {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 群组是方圆的 */
|
|
||||||
|
|
||||||
/* 信息排版 */
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -278,7 +181,6 @@ onMounted(async () => {
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 状态标签 */
|
|
||||||
.type-tag {
|
.type-tag {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -291,18 +193,12 @@ onMounted(async () => {
|
|||||||
color: #ff9500;
|
color: #ff9500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-green {
|
|
||||||
background: #e8f7ed;
|
|
||||||
color: #34c759;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #c1c1c6;
|
color: #c1c1c6;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 关键信息:群聊名称展示 */
|
|
||||||
.group-info {
|
.group-info {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
@@ -316,7 +212,6 @@ onMounted(async () => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #007aff;
|
color: #007aff;
|
||||||
/* 链接蓝,暗示可点击 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-text {
|
.sub-text {
|
||||||
@@ -324,18 +219,15 @@ onMounted(async () => {
|
|||||||
color: #86868b;
|
color: #86868b;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
/* 文字截断 */
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮样式:去掉了生硬的边框,采用色块感 */
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* 垂直排列,更具操作仪式感 */
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
@@ -378,7 +270,16 @@ onMounted(async () => {
|
|||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 基础容器 */
|
.empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: #c1c1c6;
|
||||||
|
}
|
||||||
|
.empty-placeholder i { color: #d2d2d7; margin-bottom: 14px; line-height: 0; }
|
||||||
|
.empty-text { font-size: 14px; color: #86868b; margin: 0; }
|
||||||
|
|
||||||
.status-tag {
|
.status-tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -390,32 +291,21 @@ onMounted(async () => {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 待处理:蓝色或橙色 */
|
|
||||||
.status-pending {
|
.status-pending {
|
||||||
background-color: #fff7e6;
|
background-color: #fff7e6;
|
||||||
color: #fa8c16;
|
color: #fa8c16;
|
||||||
border: 1px solid #ffd591;
|
border: 1px solid #ffd591;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 已通过:绿色 */
|
|
||||||
.status-passed {
|
.status-passed {
|
||||||
background-color: #f6ffed;
|
background-color: #f6ffed;
|
||||||
color: #52c41a;
|
color: #52c41a;
|
||||||
border: 1px solid #b7eb8f;
|
border: 1px solid #b7eb8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 已拒绝 / 失败:灰色或红色 */
|
|
||||||
.status-declined {
|
.status-declined {
|
||||||
background-color: #fff1f0;
|
background-color: #fff1f0;
|
||||||
color: #f5222d;
|
color: #f5222d;
|
||||||
border: 1px solid #ffa39e;
|
border: 1px solid #ffa39e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 默认 / 禁用状态 */
|
|
||||||
.action-disabled {
|
|
||||||
opacity: 0.8;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handleDropDown {}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,21 +7,26 @@
|
|||||||
<header class="profile-header">
|
<header class="profile-header">
|
||||||
<div class="text-info">
|
<div class="text-info">
|
||||||
<h2 class="display-name">
|
<h2 class="display-name">
|
||||||
{{ currentContact.remarkName }}
|
{{ currentContact.remarkName || currentContact.nickName }}
|
||||||
<span :class="['gender-tag', 'm']">
|
<span :class="['gender-tag', 'm']">{{ '♂' }}</span>
|
||||||
{{ '♂' }}
|
|
||||||
</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<p class="sub-text">账号:{{ currentContact.userInfo.username }}</p>
|
<p class="sub-text">账号:{{ currentContact.targetId }}</p>
|
||||||
<p class="sub-text">地区:{{ '未知' }}</p>
|
<p class="sub-text">备注:{{ editableRemark || '未设置' }}</p>
|
||||||
|
<p class="sub-text">地区:{{ currentContact.region || '未知' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<AsyncImage :raw-url="currentContact.userInfo.avatar" class="big-avatar" />
|
<AsyncImage :raw-url="currentContact.avatar" class="big-avatar" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="profile-body">
|
<div class="profile-body">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="label">昵称</span>
|
<span class="label">昵称</span>
|
||||||
<span class="value">{{ currentContact.userInfo.nickName }}</span>
|
<span class="value">{{ currentContact.nickName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row" v-if="showRemarkEdit">
|
||||||
|
<span class="label">修改备注</span>
|
||||||
|
<input v-model="remarkInput" class="remark-input" placeholder="输入新备注" @keyup.enter="saveRemark" />
|
||||||
|
<button class="mini-btn" @click="saveRemark">保存</button>
|
||||||
|
<button class="mini-btn-cancel" @click="showRemarkEdit = false">取消</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="label">个性签名</span>
|
<span class="label">个性签名</span>
|
||||||
@@ -35,55 +40,96 @@
|
|||||||
|
|
||||||
<footer class="profile-footer">
|
<footer class="profile-footer">
|
||||||
<button class="btn-primary" @click="handleGoToChat">发消息</button>
|
<button class="btn-primary" @click="handleGoToChat">发消息</button>
|
||||||
<button class="btn-ghost">音视频通话</button>
|
<button class="btn-ghost" @click="showRemarkEdit = !showRemarkEdit">修改备注</button>
|
||||||
|
<button class="btn-ghost" @click="handleDeleteFriend">删除好友</button>
|
||||||
|
<button class="btn-ghost-danger" @click="handleBlockFriend">拉黑</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, onMounted, ref } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { useContactStore } from '@/stores/contact';
|
import { useContactStore } from '@/stores/contact';
|
||||||
import { onBeforeRouteUpdate, useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useConversationStore } from '@/stores/conversation';
|
import { useConversationStore } from '@/stores/conversation';
|
||||||
|
import { friendService } from '@/services/friend';
|
||||||
|
import { useMessage } from '@/components/messages/useAlert';
|
||||||
import WindowControls from '../../components/WindowControls.vue';
|
import WindowControls from '../../components/WindowControls.vue';
|
||||||
import AsyncImage from '../../components/AsyncImage.vue';
|
import AsyncImage from '../../components/AsyncImage.vue';
|
||||||
|
|
||||||
const contactStore = useContactStore();
|
const contactStore = useContactStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const conversationStore = useConversationStore();
|
const conversationStore = useConversationStore();
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: { type: String, required: true }
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentContact = ref(null)
|
const currentContact = ref(null)
|
||||||
|
const showRemarkEdit = ref(false)
|
||||||
|
const remarkInput = ref('')
|
||||||
|
const editableRemark = ref('')
|
||||||
|
|
||||||
function handleGoToChat() {
|
const saveRemark = () => {
|
||||||
if (currentContact.value) {
|
if (currentContact.value) {
|
||||||
const cid = conversationStore.conversations.find(x => x.targetId == currentContact.value.userInfo.id).id;
|
currentContact.value.remarkName = remarkInput.value
|
||||||
router.push(`/messages/chat/${cid}`);
|
editableRemark.value = remarkInput.value
|
||||||
|
showRemarkEdit.value = false
|
||||||
|
message.success('备注已更新')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeRouteUpdate((to, from) => {
|
const handleGoToChat = () => {
|
||||||
currentContact.value = contactStore.contacts.find(x => x.id == to.params.id);
|
if (currentContact.value) {
|
||||||
|
const c = conversationStore.conversations.find(x => x.targetId == currentContact.value.targetId)
|
||||||
|
if (c) router.push(`/messages/chat/${c.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteFriend = async () => {
|
||||||
|
if (!confirm('确定要删除该好友吗?')) return
|
||||||
|
const res = await friendService.deleteFriend(currentContact.value.id)
|
||||||
|
if (res.code === 0) {
|
||||||
|
message.success('好友已删除')
|
||||||
|
router.push('/contacts/index')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlockFriend = async () => {
|
||||||
|
if (!confirm('确定要拉黑该好友吗?')) return
|
||||||
|
const res = await friendService.blockFriend(currentContact.value.id)
|
||||||
|
if (res.code === 0) {
|
||||||
|
message.success('已拉黑')
|
||||||
|
router.push('/contacts/index')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '拉黑失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, (newId) => {
|
||||||
|
currentContact.value = contactStore.contacts.find(x => x.id == newId)
|
||||||
|
if (currentContact.value) {
|
||||||
|
editableRemark.value = currentContact.value.remarkName || ''
|
||||||
|
remarkInput.value = currentContact.value.remarkName || ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
currentContact.value = contactStore.contacts.find(x => x.id == props.id);
|
currentContact.value = contactStore.contacts.find(x => x.id == props.id)
|
||||||
|
if (currentContact.value) {
|
||||||
|
editableRemark.value = currentContact.value.remarkName || ''
|
||||||
|
remarkInput.value = currentContact.value.remarkName || ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* --- 右侧名片区 --- */
|
|
||||||
.profile-main {
|
.profile-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -142,6 +188,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row .label {
|
.info-row .label {
|
||||||
@@ -154,6 +201,34 @@ onMounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remark-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.mini-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #007aff;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mini-btn-cancel {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-footer {
|
.profile-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -182,10 +257,19 @@ onMounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover, .btn-ghost:hover {
|
.btn-ghost-danger {
|
||||||
opacity: 0.8;
|
width: 160px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ff4d4f;
|
||||||
|
color: #ff4d4f;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover, .btn-ghost:hover { opacity: 0.8; }
|
||||||
|
.btn-ghost-danger:hover { background: #fff1f0; }
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
@@ -196,7 +280,7 @@ onMounted(() => {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
/* 4. 定义组件进场和退场的动画 */
|
|
||||||
.fade-scale-enter-active,
|
.fade-scale-enter-active,
|
||||||
.fade-scale-leave-active {
|
.fade-scale-leave-active {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
<AddMenu :menu-list="addMenuList" @action-active="actionHandler"/>
|
<AddMenu :menu-list="addMenuList" @action-active="actionHandler"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="msgTitleShow" class="showMsg" @click="requestNotificationPermission">
|
<div v-if="statusBanner" class="status-bar" :class="{ 'is-notify': statusBanner.type === 'notify' }" @click="statusBanner.type === 'notify' && requestNotificationPermission()">
|
||||||
<i style="color: red;line-height:0;" v-html="feather.icons['alert-circle'].toSvg({width:14})"></i>
|
<i v-html="feather.icons[statusBanner.type === 'notify' ? 'bell' : 'wifi-off'].toSvg({ width: 13, height: 13 })"></i>
|
||||||
<span>新消息无法通知,点我授予通知权限</span>
|
<span>{{ statusBanner.text }}</span>
|
||||||
|
<button v-if="statusBanner.closable" class="bar-close" @click.stop="statusBanner.type === 'net' ? (networkError = null) : null">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="scroll-area">
|
<div class="scroll-area">
|
||||||
@@ -26,11 +27,18 @@
|
|||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name-row">
|
<div class="name-row">
|
||||||
<span class="name">{{ s.targetName ?? '未知用户' }}</span>
|
<span class="name">{{ s.targetName ?? '未知用户' }}</span>
|
||||||
<span class="time">{{ formatDate(s.dateTime) ?? '1970/1/1 00:00:00' }}</span>
|
<span class="time">{{ formatDate(s.dateTime) || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="last-msg">{{ lastMessageHandler(s.lastMessage) ?? '获取消息内容失败' }}</div>
|
<div class="last-msg">{{ lastMessageHandler(s.lastMessage) ?? '获取消息内容失败' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态占位 -->
|
||||||
|
<div v-if="filteredSessions.length === 0" class="empty-placeholder">
|
||||||
|
<i v-html="feather.icons[searchQuery ? 'search' : 'message-circle'].toSvg({ width: 40, height: 40 })"></i>
|
||||||
|
<p class="empty-text">{{ searchQuery ? '没有匹配的会话' : '暂无消息' }}</p>
|
||||||
|
<p class="empty-sub" v-if="!searchQuery">点击右上角 + 发起聊天</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -43,7 +51,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import defaultAvatar from '@/assets/default_avatar.png'
|
|
||||||
import { formatDate } from '@/utils/formatDate'
|
import { formatDate } from '@/utils/formatDate'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import AddMenu from '@/components/addMenu.vue'
|
import AddMenu from '@/components/addMenu.vue'
|
||||||
@@ -55,20 +62,26 @@ import { useChatStore } from '@/stores/chat'
|
|||||||
import { groupService } from '../../services/group'
|
import { groupService } from '../../services/group'
|
||||||
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
|
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
|
||||||
import { useMessage } from '../../components/messages/useAlert'
|
import { useMessage } from '../../components/messages/useAlert'
|
||||||
import { useCacheStore } from '../../stores/cache'
|
|
||||||
import AsyncImage from '../../components/AsyncImage.vue'
|
import AsyncImage from '../../components/AsyncImage.vue'
|
||||||
|
import { networkError } from '../../services/api'
|
||||||
|
|
||||||
const conversationStore = useConversationStore();
|
const conversationStore = useConversationStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const browserNotification = useBrowserNotification();
|
const browserNotification = useBrowserNotification();
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const cacheStore = useCacheStore()
|
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const activeId = ref(0)
|
const activeId = ref(0)
|
||||||
const searchUserModal = ref(false);
|
const searchUserModal = ref(false);
|
||||||
const createGroupModal = ref(false);
|
const createGroupModal = ref(false);
|
||||||
const msgTitleShow = ref(false);
|
const msgTitleShow = ref(false);
|
||||||
|
|
||||||
|
// 统一状态横幅:通知权限 > 网络错误
|
||||||
|
const statusBanner = computed(() => {
|
||||||
|
if (msgTitleShow.value) return { type: 'notify', text: '点击授予通知权限', closable: false }
|
||||||
|
if (networkError.value) return { type: 'net', text: networkError.value, closable: true }
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
const addMenuList = [
|
const addMenuList = [
|
||||||
{
|
{
|
||||||
text: '发起群聊',
|
text: '发起群聊',
|
||||||
@@ -92,21 +105,18 @@ const addMenuList = [
|
|||||||
|
|
||||||
|
|
||||||
const createGroupSubmitHandler = async (selectedUsers, groupName) => {
|
const createGroupSubmitHandler = async (selectedUsers, groupName) => {
|
||||||
const res = await groupService.createGroup({
|
const res = await groupService.createGroup({ name: groupName });
|
||||||
name: groupName,
|
if (res.code == SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
avatar: "http://192.168.5.116:7070/uploads/files/IM/2026/03/2/bf1a0f691220.jpg",
|
message.success('群聊创建成功');
|
||||||
userIDs: [...selectedUsers]
|
const groupId = res.data?.id;
|
||||||
})
|
// 循环邀请群成员
|
||||||
|
const userIds = [...selectedUsers];
|
||||||
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
|
for (const userId of userIds) {
|
||||||
message.success('群聊创建成功')
|
await groupService.inviteUser(groupId, userId);
|
||||||
}else{
|
}
|
||||||
message.error(res.message)
|
} else {
|
||||||
}
|
message.error(res.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlHandler = async () => {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredSessions = computed(() => conversationStore.sortedConversations.filter(s => s.targetName.includes(searchQuery.value)))
|
const filteredSessions = computed(() => conversationStore.sortedConversations.filter(s => s.targetName.includes(searchQuery.value)))
|
||||||
@@ -123,20 +133,25 @@ function actionHandler(type){
|
|||||||
break;
|
break;
|
||||||
case 'createGroup':
|
case 'createGroup':
|
||||||
createGroupModal.value = true;
|
createGroupModal.value = true;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastMessageHandler(text){
|
function lastMessageHandler(text){
|
||||||
|
if (!text) return '';
|
||||||
try{
|
try{
|
||||||
const data = JSON.parse(text);
|
// 新 API content 可能是字符串或结构化对象 { fallback, body: { text, ... }, ... }
|
||||||
if(data.text){
|
if (typeof text === 'object') {
|
||||||
return data.text;
|
return text.body?.text || text.fallback || '';
|
||||||
}else{
|
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
}catch(e){
|
const data = JSON.parse(text);
|
||||||
|
if (typeof data === 'object' && data !== null) {
|
||||||
|
return data.body?.text || data.fallback || data.text || text;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}catch{
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,20 +198,35 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.showMsg {
|
.status-bar {
|
||||||
/* width: 10px; */
|
height: 24px;
|
||||||
height: 20px;
|
|
||||||
background: #e3f98d;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
/* text-align: center; */
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: red;
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #856404;
|
||||||
|
background: #fff9e6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.status-bar.is-notify {
|
||||||
|
color: #d46b08;
|
||||||
|
background: #fff7e6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.status-bar i { line-height: 0; }
|
||||||
|
.bar-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.bar-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
|
||||||
/* 修复:搜索框美化 */
|
/* 修复:搜索框美化 */
|
||||||
@@ -228,6 +258,20 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.scroll-area { flex: 1; overflow-y: auto; }
|
.scroll-area { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* 空状态占位 */
|
||||||
|
.empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #bbb;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-placeholder i { color: #ccc; margin-bottom: 12px; line-height: 0; }
|
||||||
|
.empty-text { font-size: 14px; color: #999; margin: 0; }
|
||||||
|
.empty-sub { font-size: 12px; color: #bbb; margin: 6px 0 0; }
|
||||||
|
|
||||||
/* 3. 聊天主面板修复 */
|
/* 3. 聊天主面板修复 */
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -299,7 +343,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 头像样式统一 */
|
/* 头像样式统一 */
|
||||||
:deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }
|
:deep(.avatar-std) { width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0; }
|
||||||
.avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
.avatar-chat { width: 38px; height: 38px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||||
|
|
||||||
/* 未读气泡 */
|
/* 未读气泡 */
|
||||||
|
|||||||
@@ -23,27 +23,27 @@
|
|||||||
|
|
||||||
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
|
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
|
||||||
<!-- <img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard"
|
<!-- <img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard"
|
||||||
:src="(m.senderId == myInfo.id ? myInfo?.avatar : m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar)"
|
:src="(m.senderId == myInfo.id ? myInfo?.avatar : m.chatType == CHAT_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar)"
|
||||||
class="avatar-chat" /> -->
|
class="avatar-chat" /> -->
|
||||||
<AsyncImage :raw-url="m.senderId == myInfo.id
|
<AsyncImage :raw-url="m.senderId == myInfo.id
|
||||||
? myInfo?.avatar
|
? myInfo?.avatar
|
||||||
: m.chatType == MESSAGE_TYPE.GROUP
|
: m.chatType == CHAT_TYPE.GROUP
|
||||||
? m.senderAvatar
|
? (getSenderAvatar(m.senderId) || m.senderAvatar)
|
||||||
: conversationInfo?.targetAvatar
|
: conversationInfo?.targetAvatar
|
||||||
" @mouseenter="(e) => handleHoverCard(e, m)" class="avatar-chat" @mouseleave="closeHoverCard" />
|
" @click.stop="(e) => handleHoverCard(e, m)" class="avatar-chat" />
|
||||||
|
|
||||||
<div class="msg-content">
|
<div class="msg-content">
|
||||||
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{
|
<div class="group-sendername" v-if="m.chatType == CHAT_TYPE.GROUP && m.senderId != myInfo.id">{{
|
||||||
m.senderName }}</div>
|
m.senderName }}</div>
|
||||||
<div :class="['bubble', m.type == 'Text' ? 'text-bubble' : '']"
|
<div :class="['bubble', m.msgType == MSG_TYPE.Text ? 'text-bubble' : '']"
|
||||||
@contextmenu.prevent="(e) => handleRightClick(e, m)">
|
@contextmenu.prevent="(e) => handleRightClick(e, m)">
|
||||||
<div v-if="m.type === 'Text'">{{ m.content }}</div>
|
<div v-if="m.isWithdrawn" class="withdrawn-msg"><i>消息已撤回</i></div>
|
||||||
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
|
<div v-else-if="m.msgType == MSG_TYPE.Text">
|
||||||
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
|
{{ typeof m.content === 'object' ? (m.content?.body?.text || m.content?.fallback) : m.content }}
|
||||||
<!-- <img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
|
</div>
|
||||||
alt="图片消息" @click="imagePreview(m)"> -->
|
<div v-else-if="m.msgType === MSG_TYPE.Image" class="image-msg-container" :style="getImageStyle(m)">
|
||||||
<AsyncImage class="image-msg-content" :noAvatar="true"
|
<AsyncImage class="image-msg-content" :noAvatar="true"
|
||||||
:rawUrl="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb" alt="图片消息"
|
:rawUrl="m.isImgLoading || m.isLoading ? m.localUrl : (m.url || m.thumb || m.localUrl)" alt="图片消息"
|
||||||
@click="imagePreview($event, m)" />
|
@click="imagePreview($event, m)" />
|
||||||
|
|
||||||
<div v-if="m.isImgLoading || m.isError" class="image-overlay">
|
<div v-if="m.isImgLoading || m.isError" class="image-overlay">
|
||||||
@@ -58,18 +58,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<i v-if="m.isError" class="error-icon"
|
<i v-if="m.isError" class="error-icon clickable" title="点击重发"
|
||||||
|
@click.stop="handleRetry(m)"
|
||||||
v-html="feather.icons['alert-circle'].toSvg({ width: 24, height: 24 })"></i>
|
v-html="feather.icons['alert-circle'].toSvg({ width: 24, height: 24 })"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VideoMsg v-else-if="m.type === FILE_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.content.thumb"
|
<VideoMsg v-else-if="m.msgType === MSG_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.thumb"
|
||||||
:duration="m.content.duration" :w="m.content.w" :h="m.content.h" :uploading="m.isImgLoading"
|
:duration="m.duration" :w="m.width" :h="m.height" :uploading="m.isImgLoading"
|
||||||
:progress="+m.progress" @play="playHandler(m)" />
|
:progress="+m.progress" @play="playHandler(m)" />
|
||||||
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice" :url="m.localUrl ?? m.content.url"
|
<VoiceMsg v-else-if="m.msgType === MSG_TYPE.Voice" :url="m.localUrl ?? m.url"
|
||||||
:duration="m.content.duration" :isRead="true" :isSelf="m.senderId == myInfo.id" />
|
:duration="m.duration" :isRead="true" :isSelf="m.senderId == myInfo.id" />
|
||||||
|
<button v-else-if="m.msgType === MSG_TYPE.File" class="file-message" type="button"
|
||||||
|
@click.stop="downloadFileMessage(m)">
|
||||||
|
<i v-html="feather.icons['file-text'].toSvg({ width: 30, height: 30 })"></i>
|
||||||
|
<span class="file-meta">
|
||||||
|
<strong>{{ m.fileName || m.content?.body?.fileName || '文件' }}</strong>
|
||||||
|
<small>{{ formatFileSize(m.fileSize ?? m.content?.body?.size) }}</small>
|
||||||
|
</span>
|
||||||
|
<i v-html="feather.icons['download'].toSvg({ width: 18, height: 18 })"></i>
|
||||||
|
</button>
|
||||||
<div class="status" v-if="m.senderId == myInfo.id">
|
<div class="status" v-if="m.senderId == myInfo.id">
|
||||||
<i v-if="m.isError" style="color: red;"
|
<i v-if="m.isError" class="error-icon clickable" title="点击重发"
|
||||||
v-html="feather.icons['alert-circle'].toSvg({ width: 18, height: 18 })"></i>
|
@click.stop="handleRetry(m)"
|
||||||
|
v-html="feather.icons['alert-circle'].toSvg({ width: 16, height: 16 })"></i>
|
||||||
<i v-if="m.isLoading" class="loaderIcon"
|
<i v-if="m.isLoading" class="loaderIcon"
|
||||||
v-html="feather.icons['loader'].toSvg({ width: 18, height: 18 })"></i>
|
v-html="feather.icons['loader'].toSvg({ width: 18, height: 18 })"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,14 +92,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="chat-footer">
|
<footer class="chat-footer">
|
||||||
|
<!-- 引用预览条 -->
|
||||||
|
<div v-if="quoteMessage" class="quote-bar">
|
||||||
|
<span class="quote-label">回复 {{ quoteMessage.senderName }}:</span>
|
||||||
|
<span class="quote-text">{{ typeof quoteMessage.content === 'object' ? (quoteMessage.content?.body?.text || quoteMessage.content?.fallback) : String(quoteMessage.content || '').slice(0, 80) }}</span>
|
||||||
|
<button class="quote-close" @click="cancelQuote">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- emoji 面板 -->
|
||||||
|
<div v-if="showEmoji" class="emoji-panel">
|
||||||
|
<button v-for="e in EMOJIS" :key="e" class="emoji-btn" @click="insertEmoji(e)">{{ e }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索面板 -->
|
||||||
|
<div v-if="searchOpen" class="search-panel">
|
||||||
|
<input v-model="searchKeyword" class="search-input" placeholder="搜索聊天记录..." @keyup.enter="doSearch" />
|
||||||
|
<div class="search-results">
|
||||||
|
<div v-for="r in searchResults" :key="r.id || r.sequenceId" class="search-item" @click="scrollToMsg(r)">
|
||||||
|
<span class="search-time">{{ formatDate(r.creationTime || r.timeStamp) }}</span>
|
||||||
|
<span class="search-text">{{ typeof r.content === 'object' ? (r.content?.body?.text || r.content?.fallback || '') : String(r.content || '').slice(0, 100) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="searchResults.length === 0 && searchKeyword" class="search-empty">无匹配消息</div>
|
||||||
|
</div>
|
||||||
|
<button class="search-close" @click="toggleSearch">关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="tool-btn" @click="toggleEmoji"
|
<button class="tool-btn" @click="showEmoji = !showEmoji"
|
||||||
v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })">
|
v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })">
|
||||||
</button>
|
</button>
|
||||||
<label class="tool-btn">
|
<label class="tool-btn">
|
||||||
<i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i>
|
<i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i>
|
||||||
<input type="file" hidden @change="handleFile($event.target.files)" />
|
<input type="file" hidden @change="handleFile($event.target.files)" />
|
||||||
</label>
|
</label>
|
||||||
|
<button class="tool-btn" @click="toggleSearch"
|
||||||
|
v-html="feather.icons['search'].toSvg({ width: 25, height: 25 })">
|
||||||
|
</button>
|
||||||
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord"
|
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord"
|
||||||
v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({ width: 25, height: 25 })">
|
v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({ width: 25, height: 25 })">
|
||||||
</button>
|
</button>
|
||||||
@@ -99,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null"
|
<InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null"
|
||||||
:groupData="conversationInfo" />
|
:groupData="conversationInfo" @close="handleConversationClosed" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -107,21 +146,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, nextTick, onMounted, watch, onUnmounted, reactive } from 'vue';
|
import { ref, nextTick, onMounted, watch, onUnmounted, reactive } from 'vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import defaultAvatar from '@/assets/default_avatar.png';
|
|
||||||
import { formatDate } from '@/utils/formatDate';
|
import { formatDate } from '@/utils/formatDate';
|
||||||
|
import { debounce } from '@/utils/debounce';
|
||||||
import { useChatStore } from '@/stores/chat';
|
import { useChatStore } from '@/stores/chat';
|
||||||
import { generateSessionId } from '@/utils/sessionIdTools';
|
import { generateSessionId } from '@/utils/sessionIdTools';
|
||||||
import { useSignalRStore } from '@/stores/signalr';
|
|
||||||
import { useConversationStore } from '@/stores/conversation';
|
import { useConversationStore } from '@/stores/conversation';
|
||||||
import feather from 'feather-icons';
|
import feather from 'feather-icons';
|
||||||
import { MESSAGE_TYPE } from '@/constants/MessageType';
|
import { CHAT_TYPE, MSG_TYPE } from '@/constants/MessageType';
|
||||||
import HistoryLoading from '@/components/messages/HistoryLoading.vue';
|
import HistoryLoading from '@/components/messages/HistoryLoading.vue';
|
||||||
import UserHoverCard from '@/components/user/UserHoverCard.vue';
|
import UserHoverCard from '@/components/user/UserHoverCard.vue';
|
||||||
import ContextMenu from '@/components/ContextMenu.vue';
|
import ContextMenu from '@/components/ContextMenu.vue';
|
||||||
import { useSendMessageHandler } from './hooks/useSendMessageHandler';
|
import { useSendMessageHandler } from './hooks/useSendMessageHandler';
|
||||||
import { previewImages } from 'hevue-img-preview/v3'
|
import { previewImages } from 'hevue-img-preview/v3'
|
||||||
import { useMessage } from '@/components/messages/useAlert';
|
import { useMessage } from '@/components/messages/useAlert';
|
||||||
import { FILE_TYPE, getMessageType } from '@/constants/fileTypeDefine';
|
import { getMessageType } from '@/constants/fileTypeDefine';
|
||||||
import { generateImageThumbnailBlob, getVideoDuration, getVideoThumbnailBlob, loadImage } from '@/utils/imageTools';
|
import { generateImageThumbnailBlob, getVideoDuration, getVideoThumbnailBlob, loadImage } from '@/utils/imageTools';
|
||||||
import { ImageInfo, VideoInfo, VoiceInfo } from '@/constants/fileTypeInfo';
|
import { ImageInfo, VideoInfo, VoiceInfo } from '@/constants/fileTypeInfo';
|
||||||
import VideoMsg from '@/components/messages/VideoMsg.vue';
|
import VideoMsg from '@/components/messages/VideoMsg.vue';
|
||||||
@@ -129,10 +167,13 @@ import VoiceMsg from '@/components/messages/VoiceMsg.vue';
|
|||||||
import WindowControls from '../../../components/WindowControls.vue';
|
import WindowControls from '../../../components/WindowControls.vue';
|
||||||
import InfoSidebar from '../../../components/messages/InfoSidebar.vue';
|
import InfoSidebar from '../../../components/messages/InfoSidebar.vue';
|
||||||
import { isElectron } from '../../../utils/electronHelper';
|
import { isElectron } from '../../../utils/electronHelper';
|
||||||
import { groupService } from '../../../services/group';
|
|
||||||
import VideoPreview from '../../../components/electron/VideoPreview.vue';
|
import VideoPreview from '../../../components/electron/VideoPreview.vue';
|
||||||
import { useRightClickHandler } from './hooks/useRightClickHandler';
|
import { useRightClickHandler } from './hooks/useRightClickHandler';
|
||||||
import AsyncImage from '../../../components/AsyncImage.vue';
|
import AsyncImage from '../../../components/AsyncImage.vue';
|
||||||
|
import { messageService } from '@/services/message';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
import { uploadService } from '@/services/upload/uploadService';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -145,13 +186,13 @@ const props = defineProps({
|
|||||||
const infoSideBarShow = ref(false);
|
const infoSideBarShow = ref(false);
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const signalRStore = useSignalRStore();
|
|
||||||
const conversationStore = useConversationStore();
|
const conversationStore = useConversationStore();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const { sendMessage, sendFileMessage, sendTextMessage } = useSendMessageHandler();
|
const router = useRouter();
|
||||||
|
const { sendFileMessage, sendTextMessage, retryMessage } = useSendMessageHandler();
|
||||||
|
|
||||||
const input = ref(''); // 输入框内容
|
const input = ref('');
|
||||||
const historyRef = ref(null); // 绑定 DOM 用于滚动
|
const historyRef = ref(null);
|
||||||
const loadingRef = ref(null)
|
const loadingRef = ref(null)
|
||||||
const userHoverCardRef = ref(null);
|
const userHoverCardRef = ref(null);
|
||||||
const menuRef = ref(null);
|
const menuRef = ref(null);
|
||||||
@@ -160,18 +201,85 @@ const myInfo = useAuthStore().userInfo;
|
|||||||
const conversationInfo = ref(null)
|
const conversationInfo = ref(null)
|
||||||
|
|
||||||
let groupData = reactive({});
|
let groupData = reactive({});
|
||||||
|
// --- 消息加载 & 录音状态 ---
|
||||||
// --- 消息数据 ---
|
|
||||||
const messages = ref([]);
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const isFinished = ref(false);
|
const isFinished = ref(false);
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
let observer = null;
|
let observer = null;
|
||||||
const isRecord = ref(false);
|
const isRecord = ref(false);
|
||||||
let mediaRecorder = null;
|
let mediaRecorder = null;
|
||||||
let audioChunks = []; // 用于存储录音的数据片段
|
let audioChunks = [];
|
||||||
const videoUrl = ref(null);
|
const videoUrl = ref(null);
|
||||||
const videoOpen = ref(false)
|
const videoOpen = ref(false);
|
||||||
|
const resolvedObjectUrls = new Set();
|
||||||
|
const resolvingFileIds = new Set();
|
||||||
|
|
||||||
|
|
||||||
|
// --- emoji / quote / search ---
|
||||||
|
const showEmoji = ref(false);
|
||||||
|
const quoteMessage = ref(null);
|
||||||
|
const searchOpen = ref(false);
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const searchResults = ref([]);
|
||||||
|
const searchLoading = ref(false);
|
||||||
|
|
||||||
|
// common emoji list
|
||||||
|
const EMOJIS = ['😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎','😍','😘','🥰','😗','😙','😚','🙂','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😯','😪','😫','😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','☹️','🙁','😖','😞','😟','😤','😢','😭','😦','😧','😨','😩','🤯','😬','😰','😱','🥵','🥶','😳','🤪','😵','😡','😠','🤬','👍','👎','👏','🙌','🤝','💪','❤️','🧡','💛','💚','💙','💜','🖤','🤍','🎉','🎊','🔥','⭐','✅','❌']
|
||||||
|
|
||||||
|
const insertEmoji = (emoji) => {
|
||||||
|
input.value += emoji
|
||||||
|
showEmoji.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- quote ---
|
||||||
|
const startQuote = (msg) => {
|
||||||
|
quoteMessage.value = msg
|
||||||
|
}
|
||||||
|
const cancelQuote = () => { quoteMessage.value = null }
|
||||||
|
|
||||||
|
// --- message search ---
|
||||||
|
const toggleSearch = () => {
|
||||||
|
searchOpen.value = !searchOpen.value
|
||||||
|
if (!searchOpen.value) {
|
||||||
|
searchKeyword.value = ''
|
||||||
|
searchResults.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const doSearch = async () => {
|
||||||
|
if (!searchKeyword.value.trim()) { searchResults.value = []; return }
|
||||||
|
searchLoading.value = true
|
||||||
|
const kw = searchKeyword.value.toLowerCase()
|
||||||
|
searchResults.value = chatStore.messages.filter(m => {
|
||||||
|
const txt = typeof m.content === 'object' ? (m.content?.body?.text || m.content?.fallback || '') : (m.content || '')
|
||||||
|
return txt.toLowerCase().includes(kw)
|
||||||
|
})
|
||||||
|
searchLoading.value = false
|
||||||
|
}
|
||||||
|
const scrollToMsg = (msg) => {
|
||||||
|
const el = document.getElementById('msg-' + (msg.id || msg.sequenceId))
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- message actions ---
|
||||||
|
const handleWithdraw = async (msg) => {
|
||||||
|
const msgId = msg.id || msg.clientMsgId
|
||||||
|
if (!msgId) { message.error('无法撤回此消息'); return }
|
||||||
|
try {
|
||||||
|
const res = await messageService.withdraw(msgId)
|
||||||
|
if (res.code === 0) {
|
||||||
|
msg.isWithdrawn = true
|
||||||
|
message.success('已撤回')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '撤回失败')
|
||||||
|
}
|
||||||
|
} catch { message.error('撤回失败') }
|
||||||
|
}
|
||||||
|
const handleDeleteMsg = (msg) => {
|
||||||
|
chatStore.deleteMessage(msg)
|
||||||
|
}
|
||||||
|
const handleForward = () => {
|
||||||
|
message.info('请选择转发联系人(功能开发中)')
|
||||||
|
}
|
||||||
|
|
||||||
const infoShowHandler = async () => {
|
const infoShowHandler = async () => {
|
||||||
if (infoSideBarShow.value) {
|
if (infoSideBarShow.value) {
|
||||||
@@ -182,14 +290,20 @@ const infoShowHandler = async () => {
|
|||||||
infoSideBarShow.value = true
|
infoSideBarShow.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleConversationClosed = async () => {
|
||||||
|
infoSideBarShow.value = false;
|
||||||
|
await router.push('/messages/index');
|
||||||
|
}
|
||||||
|
|
||||||
const getImageStyle = (content) => {
|
|
||||||
const maxWidth = 200; // 最大宽度
|
|
||||||
const maxHeight = 200; // 最大高度
|
|
||||||
const minSize = 60; // 最小尺寸,防止变成一个点
|
|
||||||
|
|
||||||
let w = content.W || maxWidth;
|
const getImageStyle = (m) => {
|
||||||
let h = content.H || maxHeight;
|
const maxWidth = 200;
|
||||||
|
const maxHeight = 200;
|
||||||
|
const minSize = 60;
|
||||||
|
|
||||||
|
// 新 API: 媒体尺寸在消息顶层字段 width/height
|
||||||
|
let w = m.width || m.content?.body?.width || maxWidth;
|
||||||
|
let h = m.height || m.content?.body?.height || maxHeight;
|
||||||
|
|
||||||
const ratio = w / h;
|
const ratio = w / h;
|
||||||
|
|
||||||
@@ -212,7 +326,7 @@ const getImageStyle = (content) => {
|
|||||||
const imagePreview = (e, m) => {
|
const imagePreview = (e, m) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const imageList = chatStore.messages
|
const imageList = chatStore.messages
|
||||||
.filter(x => x.type == 'Image')
|
.filter(x => x.msgType == MSG_TYPE.Image)
|
||||||
;
|
;
|
||||||
const index = imageList.indexOf(m);
|
const index = imageList.indexOf(m);
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
@@ -223,7 +337,7 @@ const imagePreview = (e, m) => {
|
|||||||
window.api.window.newWindow('imgpre', safeData);
|
window.api.window.newWindow('imgpre', safeData);
|
||||||
} else {
|
} else {
|
||||||
previewImages({
|
previewImages({
|
||||||
imgList: imageList.map(m => m.content.url),
|
imgList: imageList.map(m => m.url || (m.content?.body?.url) || m.localUrl),
|
||||||
nowImgIndex: index
|
nowImgIndex: index
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -271,7 +385,7 @@ const stopRecord = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playHandler = (m) => {
|
const playHandler = (m) => {
|
||||||
videoUrl.value = m.content.url
|
videoUrl.value = m.url || (m.content?.body?.url) || m.localUrl
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
window.api.window.newWindow('videopre', videoUrl.value)
|
window.api.window.newWindow('videopre', videoUrl.value)
|
||||||
return
|
return
|
||||||
@@ -283,6 +397,59 @@ const videoClose = () => {
|
|||||||
videoOpen.value = false
|
videoOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvePrivateMedia = async (m) => {
|
||||||
|
if (![MSG_TYPE.Image, MSG_TYPE.Video, MSG_TYPE.Voice].includes(m.msgType)) return;
|
||||||
|
const fileId = m.fileId || m.content?.body?.fileId;
|
||||||
|
const hasCompleteLocalMedia = m.msgType !== MSG_TYPE.Video && m.localUrl;
|
||||||
|
if (!fileId || m.url || hasCompleteLocalMedia || resolvingFileIds.has(fileId)) return;
|
||||||
|
resolvingFileIds.add(fileId);
|
||||||
|
try {
|
||||||
|
const blob = await uploadService.downloadFile(fileId);
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
resolvedObjectUrls.add(objectUrl);
|
||||||
|
m.url = objectUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('媒体文件读取失败:', error);
|
||||||
|
} finally {
|
||||||
|
resolvingFileIds.delete(fileId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (size) => {
|
||||||
|
const bytes = Number(size || 0);
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFileMessage = async (m) => {
|
||||||
|
const fileId = m.fileId || m.content?.body?.fileId;
|
||||||
|
if (!fileId) {
|
||||||
|
message.error('文件标识缺失,无法下载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await uploadService.downloadFile(fileId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = m.fileName || m.content?.body?.fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
} catch {
|
||||||
|
message.error('文件下载失败或无访问权限');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 群聊中根据 senderId 查找成员头像
|
||||||
|
const getSenderAvatar = (senderId) => {
|
||||||
|
const member = groupData?.members?.find(gm => gm.userId == senderId);
|
||||||
|
return member?.avatar || null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadHistoryMsg = async () => {
|
const loadHistoryMsg = async () => {
|
||||||
// 1. 如果正在加载,或者已经彻底没数据了,才拦截
|
// 1. 如果正在加载,或者已经彻底没数据了,才拦截
|
||||||
if (isLoading.value || isFinished.value) return;
|
if (isLoading.value || isFinished.value) return;
|
||||||
@@ -312,30 +479,63 @@ const loadHistoryMsg = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHoverCard = (e, m) => {
|
const handleHoverCard = async (e, m) => {
|
||||||
const userInfo = {
|
let name = m.senderName || '';
|
||||||
name: m.senderName,
|
let avatar = m.senderAvatar || null;
|
||||||
avatar: m.senderAvatar,
|
let userName = '';
|
||||||
id: m.senderId
|
if (m.senderId == myInfo.id) {
|
||||||
|
name = myInfo.nickName || myInfo.userName || name;
|
||||||
|
avatar = avatar || myInfo.avatar;
|
||||||
|
userName = myInfo.userName || '';
|
||||||
|
} else {
|
||||||
|
// 先从本地信息补全,查不到再调接口
|
||||||
|
const member = groupData?.members?.find(gm => gm.userId == m.senderId);
|
||||||
|
if (member) {
|
||||||
|
name = name || member.groupNickName || member.userId;
|
||||||
|
avatar = avatar || member.avatar;
|
||||||
|
} else if (conversationInfo.value?.targetId == m.senderId) {
|
||||||
|
name = name || conversationInfo.value.targetName || '';
|
||||||
|
avatar = avatar || conversationInfo.value.targetAvatar;
|
||||||
}
|
}
|
||||||
|
if (!name || name == m.senderId) {
|
||||||
|
name = name || m.senderId;
|
||||||
|
}
|
||||||
|
// 查用户名
|
||||||
|
try {
|
||||||
|
const { data } = await userService.findUser(m.senderId);
|
||||||
|
if (data) {
|
||||||
|
userName = data.userName || '';
|
||||||
|
name = name || data.nickName || data.userName || name;
|
||||||
|
avatar = avatar || data.avatar;
|
||||||
|
}
|
||||||
|
} catch { /* 查不到就忽略 */ }
|
||||||
|
}
|
||||||
|
const userInfo = { name, avatar, id: m.senderId, userName }
|
||||||
userHoverCardRef.value.show(e.target, userInfo);
|
userHoverCardRef.value.show(e.target, userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeHoverCard = () => {
|
|
||||||
userHoverCardRef.value.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRightClick = (e, m) => {
|
const handleRightClick = (e, m) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menuRef.value.show(e, useRightClickHandler(e, m));
|
const menu = useRightClickHandler(m, {
|
||||||
|
onQuote: startQuote,
|
||||||
|
onWithdraw: handleWithdraw,
|
||||||
|
onDelete: handleDeleteMsg,
|
||||||
|
onForward: handleForward,
|
||||||
|
})
|
||||||
|
menuRef.value.show(e, menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debouncedMarkRead = debounce((id) => messageService.markRead(id), 500);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => chatStore.messages,
|
() => chatStore.messages,
|
||||||
async (newVal) => {
|
async () => {
|
||||||
|
await Promise.all(chatStore.messages.map(resolvePrivateMedia));
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
conversationStore.conversations.find(x => x.id == conversationInfo.value.id).unreadCount = 0;
|
const conv = conversationStore.conversations.find(x => x.id == conversationInfo.value.id);
|
||||||
signalRStore.clearUnreadCount(conversationInfo.value.id);
|
if (conv) conv.unreadCount = 0;
|
||||||
|
if (conversationInfo.value?.id) debouncedMarkRead(conversationInfo.value.id);
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
@@ -351,10 +551,16 @@ const scrollToBottom = async () => {
|
|||||||
// 发送文本
|
// 发送文本
|
||||||
async function sendText() {
|
async function sendText() {
|
||||||
if (!input.value.trim()) return;
|
if (!input.value.trim()) return;
|
||||||
// 根据 C# MessageBaseDto 构造的示例对象
|
const qid = quoteMessage.value?.id || null
|
||||||
const content = input.value;
|
const content = input.value;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
await sendTextMessage(content, conversationInfo);
|
cancelQuote()
|
||||||
|
await sendTextMessage(content, conversationInfo, qid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重发失败消息
|
||||||
|
const handleRetry = (m) => {
|
||||||
|
retryMessage(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通话模拟
|
// 通话模拟
|
||||||
@@ -369,23 +575,30 @@ async function handleFile(files) {
|
|||||||
let localUrl = null;
|
let localUrl = null;
|
||||||
let img = null;
|
let img = null;
|
||||||
switch (getMessageType(file.type)) {
|
switch (getMessageType(file.type)) {
|
||||||
case FILE_TYPE.Image:
|
case MSG_TYPE.Image:
|
||||||
localUrl = URL.createObjectURL(file);
|
localUrl = URL.createObjectURL(file);
|
||||||
img = await loadImage(localUrl);
|
img = await loadImage(localUrl);
|
||||||
info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200));
|
info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case FILE_TYPE.Video: {
|
case MSG_TYPE.Video: {
|
||||||
|
try {
|
||||||
const imgBlob = await getVideoThumbnailBlob(file);
|
const imgBlob = await getVideoThumbnailBlob(file);
|
||||||
localUrl = URL.createObjectURL(imgBlob);
|
localUrl = URL.createObjectURL(imgBlob);
|
||||||
img = await loadImage(localUrl);
|
img = await loadImage(localUrl);
|
||||||
|
|
||||||
info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
|
info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('视频解析失败:', e);
|
||||||
|
info = new VideoInfo(file.type, '[视频]', 0, 0, null, 0);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case FILE_TYPE.Voice: {
|
case MSG_TYPE.Voice: {
|
||||||
localUrl = URL.createObjectURL(file);
|
localUrl = URL.createObjectURL(file);
|
||||||
|
info = new VoiceInfo(file.type, '[语音消息]', 0);
|
||||||
|
try {
|
||||||
info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file));
|
info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file));
|
||||||
|
} catch { /* 无法获取时长,用 0 */ }
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,10 +607,6 @@ async function handleFile(files) {
|
|||||||
await sendFileMessage(file, conversationInfo, info, localUrl);
|
await sendFileMessage(file, conversationInfo, info, localUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEmoji() {
|
|
||||||
console.log('打开表情面板');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConversation(conversationId) {
|
async function loadConversation(conversationId) {
|
||||||
/*
|
/*
|
||||||
const res = await messageService.getConversationById(conversationId);
|
const res = await messageService.getConversationById(conversationId);
|
||||||
@@ -406,14 +615,14 @@ async function loadConversation(conversationId) {
|
|||||||
if (conversationStore.conversations.length == 0) {
|
if (conversationStore.conversations.length == 0) {
|
||||||
await conversationStore.loadUserConversations();
|
await conversationStore.loadUserConversations();
|
||||||
}
|
}
|
||||||
conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId));
|
conversationInfo.value = conversationStore.conversations.find(x => x.id == conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const initChat = async (newId) => {
|
const initChat = async (newId) => {
|
||||||
await loadConversation(newId);
|
await loadConversation(newId);
|
||||||
if (conversationInfo.value) {
|
if (conversationInfo.value) {
|
||||||
const sessionid = generateSessionId(
|
const sessionid = generateSessionId(
|
||||||
conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP)
|
conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == CHAT_TYPE.GROUP)
|
||||||
await chatStore.swtichSession(sessionid, newId);
|
await chatStore.swtichSession(sessionid, newId);
|
||||||
isFinished.value = false;
|
isFinished.value = false;
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -431,14 +640,14 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试找到当前会话
|
// 尝试找到当前会话
|
||||||
const session = conversationStore.conversations.find(x => x.id == Number(props.id));
|
const session = conversationStore.conversations.find(x => x.id == props.id);
|
||||||
|
|
||||||
// 返回 [ID, 状态],用了 ?. 即使 session 是 undefined 也只会返回 undefined,不会报错
|
// 返回 [ID, 状态],用了 ?. 即使 session 是 undefined 也只会返回 undefined,不会报错
|
||||||
return [props.id, session?.isInitialized];
|
return [props.id, session?.isInitialized];
|
||||||
},
|
},
|
||||||
|
|
||||||
// 2. 回调逻辑
|
// 2. 回调逻辑
|
||||||
async ([newId, isInited], [oldId, oldInited]) => {
|
async ([newId, isInited]) => {
|
||||||
// 基础防守:如果 ID 无效,直接跳过
|
// 基础防守:如果 ID 无效,直接跳过
|
||||||
if (!newId) return;
|
if (!newId) return;
|
||||||
|
|
||||||
@@ -460,16 +669,16 @@ watch(
|
|||||||
//const currentMax = chatStore.maxSequenceId;
|
//const currentMax = chatStore.maxSequenceId;
|
||||||
|
|
||||||
// 2. 去服务器拉取增量数据
|
// 2. 去服务器拉取增量数据
|
||||||
const msgList = await chatStore.fetchNewMsgFromServier(newId);
|
const msgList = await chatStore.fetchNewMsgFromServier(newId, chatStore.maxSequenceId);
|
||||||
|
|
||||||
const session = conversationStore.conversations.find(x => x.id == Number(newId));
|
const session = conversationStore.conversations.find(x => x.id == newId);
|
||||||
if (msgList && msgList.length > 0) {
|
if (msgList && msgList.length > 0) {
|
||||||
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
|
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
|
||||||
const locaMaxSequenceId = chatStore.maxSequenceId;
|
const locaMaxSequenceId = chatStore.maxSequenceId;
|
||||||
if (locaMaxSequenceId < (minSequenceId - 1)) {
|
if (locaMaxSequenceId < (minSequenceId - 1)) {
|
||||||
chatStore.messages = [];;
|
chatStore.messages = [];;
|
||||||
}
|
}
|
||||||
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true);
|
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == CHAT_TYPE.GROUP), true);
|
||||||
}
|
}
|
||||||
// 3. 如果有新消息,存入 Store
|
// 3. 如果有新消息,存入 Store
|
||||||
|
|
||||||
@@ -527,6 +736,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (observer) observer.disconnect();
|
if (observer) observer.disconnect();
|
||||||
|
resolvedObjectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
resolvedObjectUrls.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -773,6 +984,26 @@ onUnmounted(() => {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-message {
|
||||||
|
width: 280px;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-message:hover { background: #f8f8f8; }
|
||||||
|
.file-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.file-meta strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-meta small { color: #999; }
|
||||||
|
|
||||||
.mine .text-bubble {
|
.mine .text-bubble {
|
||||||
background: #95ec69;
|
background: #95ec69;
|
||||||
}
|
}
|
||||||
@@ -781,12 +1012,7 @@ onUnmounted(() => {
|
|||||||
/* background: #95ec69; */
|
/* background: #95ec69; */
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.avatar-chat) {
|
:deep(.avatar-chat) { width: 38px; height: 38px; border-radius: 4px; flex-shrink: 0; }
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-time {
|
.msg-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -827,4 +1053,82 @@ textarea {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- emoji 面板 --- */
|
||||||
|
.emoji-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.emoji-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.emoji-btn:hover { background: #e8e8e8; }
|
||||||
|
|
||||||
|
/* --- 引用预览条 --- */
|
||||||
|
.quote-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-top: 1px solid #d0dff5;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.quote-label { color: #007aff; font-weight: 500; white-space: nowrap; }
|
||||||
|
.quote-text { flex: 1; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.quote-close { background: none; border: none; color: #999; cursor: pointer; font-size: 16px; }
|
||||||
|
|
||||||
|
/* --- 搜索面板 --- */
|
||||||
|
.search-panel {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
max-height: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.search-results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.search-item {
|
||||||
|
padding: 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.search-item:hover { background: #f0f7ff; }
|
||||||
|
.search-time { color: #999; white-space: nowrap; }
|
||||||
|
.search-text { color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.search-empty { text-align: center; color: #ccc; padding: 10px; font-size: 13px; }
|
||||||
|
.search-close { margin-top: 4px; padding: 4px; background: #f0f0f0; border: none; cursor: pointer; border-radius: 4px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* --- 撤回消息 --- */
|
||||||
|
.withdrawn-msg { color: #999; font-style: italic; padding: 4px 6px; }
|
||||||
|
|
||||||
|
.error-icon.clickable { cursor: pointer; opacity: 0.8; transition: opacity 0.15s; }
|
||||||
|
.error-icon.clickable:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+22
-35
@@ -1,41 +1,28 @@
|
|||||||
import { FILE_TYPE } from '../../../../constants/fileTypeDefine'
|
import { MSG_TYPE } from '@/constants/MessageType'
|
||||||
|
|
||||||
export function useRightClickHandler(e, m) {
|
const buildMenu = (message, opts = {}) => {
|
||||||
const textRightItem = [
|
const { onQuote, onForward, onDelete, onWithdraw, onCopy } = opts
|
||||||
{
|
const items = []
|
||||||
label: '复制',
|
|
||||||
action: async () => {
|
|
||||||
await navigator.clipboard.writeText(e.target.innerText)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '引用',
|
|
||||||
action: () => console.log('进入私聊')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '转发',
|
|
||||||
action: () => {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '删除',
|
|
||||||
type: 'danger',
|
|
||||||
action: () => alert('删除成功')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const imgRightItem = [
|
const isText = message.msgType === MSG_TYPE.Text
|
||||||
{
|
const isImage = message.msgType === MSG_TYPE.Image
|
||||||
label: '复制',
|
|
||||||
action: () => {
|
if (isText) {
|
||||||
console.log(e.target)
|
items.push({ label: '复制', action: () => navigator.clipboard.writeText(message.content?.body?.text || message.content?.fallback || message.content || '') })
|
||||||
}
|
}
|
||||||
|
if (isImage) {
|
||||||
|
items.push({ label: '复制图片', action: () => onCopy?.(message) })
|
||||||
}
|
}
|
||||||
]
|
if (isText) {
|
||||||
switch (m.type) {
|
items.push({ label: '引用', action: () => onQuote?.(message) })
|
||||||
case FILE_TYPE.TEXT:
|
|
||||||
return textRightItem
|
|
||||||
case FILE_TYPE.Image:
|
|
||||||
case FILE_TYPE.Video:
|
|
||||||
return imgRightItem;
|
|
||||||
}
|
}
|
||||||
|
items.push({ label: '转发', action: () => onForward?.(message) })
|
||||||
|
items.push({ label: '撤回', action: () => onWithdraw?.(message) })
|
||||||
|
items.push({ label: '删除', type: 'danger', action: () => onDelete?.(message) })
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRightClickHandler = (message, opts) => {
|
||||||
|
return buildMenu(message, opts)
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-51
@@ -1,89 +1,168 @@
|
|||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { generateSessionId } from "@/utils/sessionIdTools";
|
import { generateSessionId } from "@/utils/sessionIdTools";
|
||||||
import { MESSAGE_TYPE } from "@/constants/MessageType";
|
import { CHAT_TYPE, MSG_TYPE } from "@/constants/MessageType";
|
||||||
import { messageService } from "@/services/message";
|
import { messageService } from "@/services/message";
|
||||||
import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus";
|
import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus";
|
||||||
import { uploadFile } from "@/services/upload/uploader";
|
import { uploadFile } from "@/services/upload/uploader";
|
||||||
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
||||||
import { getMessageType } from "@/constants/fileTypeDefine";
|
import { getMessageType } from "@/constants/fileTypeDefine";
|
||||||
import { uploadService } from "@/services/upload/uploadService";
|
import { uploadService } from "@/services/upload/uploadService";
|
||||||
import { getFileHash } from "@/utils/uploadTools";
|
|
||||||
|
|
||||||
export function useSendMessageHandler() {
|
export function useSendMessageHandler() {
|
||||||
|
// 发送者恒等于当前登录用户,避免会话数据缺 userId 导致消息归属错误
|
||||||
|
const myId = useAuthStore().userInfo?.id;
|
||||||
|
|
||||||
const sendMessage = async (msg) => {
|
const sendMessage = async (msg) => {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
//设置消息为加载状态
|
|
||||||
const msgServer = { ...msg }
|
|
||||||
msg.isLoading = true;
|
msg.isLoading = true;
|
||||||
//将临时消息推送到消息列表(存库,方便后续重试)
|
const isGroupChat = msg.chatType == CHAT_TYPE.GROUP;
|
||||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
await chatStore.pushAndSortMessagesAsync(
|
||||||
//从列表取出消息
|
[msg],
|
||||||
let updateMsg = msg;
|
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await messageService.sendMessage(msgServer);
|
const res = await messageService.sendMessage(msg);
|
||||||
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
|
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
updateMsg.isError = true;
|
msg.isError = true;
|
||||||
} else {
|
} else {
|
||||||
//发送成功将后端生成的sequenceId更新
|
// 用服务端数据覆盖(保留本地 clientMsgId 等字段用于去重/重发)
|
||||||
updateMsg = res.data;
|
const serverData = res.data;
|
||||||
|
Object.assign(msg, serverData);
|
||||||
|
msg.isError = false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
updateMsg.isError = true;
|
msg.isError = true;
|
||||||
} finally {
|
} finally {
|
||||||
updateMsg.isLoading = false;
|
|
||||||
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
|
||||||
|
|
||||||
msg.isLoading = false;
|
msg.isLoading = false;
|
||||||
|
await chatStore.pushAndSortMessagesAsync(
|
||||||
|
[msg],
|
||||||
|
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendTextMessage = async (text, conversationInfo) => {
|
const sendTextMessage = async (text, conversationInfo, quoteMessageId = null) => {
|
||||||
const msg = {
|
const msg = {
|
||||||
type: 'Text', // 消息类型,例如 'Text', 'Image', 'File'
|
clientMsgId: self.crypto.randomUUID(),
|
||||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
chatType: conversationInfo.value.chatType,
|
||||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
targetId: conversationInfo.value.targetId,
|
||||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
msgType: MSG_TYPE.Text,
|
||||||
content: text,
|
senderId: myId,
|
||||||
timeStamp: new Date(), // 对应 DateTime
|
sequenceId: Date.now(),
|
||||||
msgId: self.crypto.randomUUID()
|
text, // 顶层 text 供 /message/send(MessageSendRequest 校验 text 非空)
|
||||||
|
// 统一用 content 结构,本地消息与服务端返回格式一致
|
||||||
|
content: { fallback: text, body: { text }, ext: {}, quote: null },
|
||||||
};
|
};
|
||||||
//更新当前会话最新消息
|
if (quoteMessageId) msg.quoteMessageId = quoteMessageId;
|
||||||
conversationInfo.value.lastMessage = msg.content;
|
conversationInfo.value.lastMessage = text;
|
||||||
await sendMessage(msg);
|
await sendMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重发失败消息
|
||||||
|
const retryMessage = async (msg) => {
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
msg.isError = false
|
||||||
|
msg.isLoading = true
|
||||||
|
const isGroupChat = msg.chatType == CHAT_TYPE.GROUP
|
||||||
|
|
||||||
|
// 兼容旧缓存中的纯文本 content,并保留新版结构化消息字段。
|
||||||
|
const text = typeof msg.content === 'object'
|
||||||
|
? (msg.content?.body?.text || msg.content?.fallback || '')
|
||||||
|
: (msg.content || '')
|
||||||
|
const apiMsg = {
|
||||||
|
clientMsgId: msg.clientMsgId || msg.msgId,
|
||||||
|
targetId: msg.targetId,
|
||||||
|
chatType: msg.chatType,
|
||||||
|
msgType: msg.msgType,
|
||||||
|
text,
|
||||||
|
url: msg.url,
|
||||||
|
thumb: msg.thumb,
|
||||||
|
width: msg.width,
|
||||||
|
height: msg.height,
|
||||||
|
duration: msg.duration,
|
||||||
|
fileId: msg.fileId,
|
||||||
|
fileName: msg.fileName,
|
||||||
|
fileSize: msg.fileSize,
|
||||||
|
fileFormat: msg.fileFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
await chatStore.pushAndSortMessagesAsync(
|
||||||
|
[msg],
|
||||||
|
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const res = await messageService.sendMessage(apiMsg)
|
||||||
|
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
|
||||||
|
msg.isError = true
|
||||||
|
} else {
|
||||||
|
Object.assign(msg, res.data)
|
||||||
|
msg.isError = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
msg.isError = true
|
||||||
|
} finally {
|
||||||
|
msg.isLoading = false
|
||||||
|
await chatStore.pushAndSortMessagesAsync(
|
||||||
|
[msg],
|
||||||
|
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendFileMessage = async (file, conversationInfo, info, localUrl) => {
|
const sendFileMessage = async (file, conversationInfo, info, localUrl) => {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
const isGroupChat = conversationInfo.value.chatType == CHAT_TYPE.GROUP;
|
||||||
|
const msgType = getMessageType(file.type);
|
||||||
|
const msgId = self.crypto.randomUUID();
|
||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
type: getMessageType(file.type), // 消息类型,例如 'Text', 'Image', 'File'
|
clientMsgId: msgId,
|
||||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
chatType: conversationInfo.value.chatType,
|
||||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
targetId: conversationInfo.value.targetId,
|
||||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
msgType: msgType,
|
||||||
content: '',
|
senderId: myId,
|
||||||
timeStamp: new Date(), // 对应 DateTime
|
sequenceId: Date.now(),
|
||||||
msgId: self.crypto.randomUUID(),
|
|
||||||
localUrl: localUrl,
|
localUrl: localUrl,
|
||||||
progress: 0
|
progress: 0,
|
||||||
|
isLoading: false,
|
||||||
|
isImgLoading: true,
|
||||||
};
|
};
|
||||||
//更新当前会话最新消息
|
|
||||||
conversationInfo.value.lastMessage = info.text;
|
conversationInfo.value.lastMessage = info.text || '[文件]';
|
||||||
msg.isImgLoading = true;
|
msg.isImgLoading = true;
|
||||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
await chatStore.pushAndSortMessagesAsync(
|
||||||
|
[msg],
|
||||||
|
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
if (info.thumb) {
|
// 缩略图直传
|
||||||
const hash = await getFileHash(info.thumb);
|
if (info.thumb instanceof Blob) {
|
||||||
try {
|
try {
|
||||||
const { data } = await uploadService.uploadSmallFile(info.thumb, hash);
|
const thumbFile = info.thumb instanceof File
|
||||||
info.thumb = data.objectName;
|
? info.thumb
|
||||||
|
: new File([info.thumb], `thumb_${msgId}.jpg`, { type: info.thumb.type || 'image/jpeg' });
|
||||||
|
const thumbRes = await uploadService.uploadSmallFile(thumbFile, true);
|
||||||
|
info.thumb = thumbRes.data?.url || thumbRes.data?.id || '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error('缩略图上传失败:', e);
|
||||||
msg.isError = true;
|
info.thumb = '';
|
||||||
msg.isLoading = false;
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
const conversationId = chatStore.activeConversationId;
|
||||||
|
|
||||||
await uploadFile(file, {
|
try {
|
||||||
|
const uploadedFile = await uploadFile(file, {
|
||||||
|
conversationId,
|
||||||
|
chatType: conversationInfo.value.chatType,
|
||||||
|
targetId: conversationInfo.value.targetId,
|
||||||
onProgress: async (e) => {
|
onProgress: async (e) => {
|
||||||
if (!e.status) return;
|
if (!e.status) return;
|
||||||
switch (e.status) {
|
switch (e.status) {
|
||||||
@@ -91,19 +170,44 @@ export function useSendMessageHandler() {
|
|||||||
case UPLOAD_STATUS.UPLOADING:
|
case UPLOAD_STATUS.UPLOADING:
|
||||||
msg.progress = e.progress;
|
msg.progress = e.progress;
|
||||||
break;
|
break;
|
||||||
|
case UPLOAD_STATUS.UPLOADED:
|
||||||
|
msg.isImgLoading = false;
|
||||||
|
break;
|
||||||
case UPLOAD_STATUS.COMPLETE:
|
case UPLOAD_STATUS.COMPLETE:
|
||||||
msg.progress = 100;
|
msg.progress = 100;
|
||||||
msg.isImgLoading = false;
|
msg.isImgLoading = false;
|
||||||
info.fileId = e.taskId;
|
|
||||||
msg.content = JSON.stringify(info);
|
|
||||||
await sendMessage(msg);
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
msg.progress = 100;
|
||||||
|
msg.isImgLoading = false;
|
||||||
|
msg.fileId = uploadedFile.id;
|
||||||
|
msg.url = uploadedFile.url || '';
|
||||||
|
msg.thumb = info.thumb || '';
|
||||||
|
if (info.w) msg.width = info.w;
|
||||||
|
if (info.h) msg.height = info.h;
|
||||||
|
if (info.duration) msg.duration = Math.round(info.duration);
|
||||||
|
if (msgType === MSG_TYPE.File) {
|
||||||
|
msg.fileName = file.name;
|
||||||
|
msg.fileSize = file.size;
|
||||||
|
msg.fileFormat = file.type || file.name.split('.').pop() || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
await sendMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('文件上传失败:', e);
|
||||||
|
msg.isError = true;
|
||||||
|
msg.isLoading = false;
|
||||||
|
msg.isImgLoading = false;
|
||||||
|
await chatStore.pushAndSortMessagesAsync(
|
||||||
|
[msg],
|
||||||
|
generateSessionId(msg.senderId, msg.targetId, isGroupChat),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sendMessage, sendFileMessage, sendTextMessage };
|
return { sendMessage, sendFileMessage, sendTextMessage, retryMessage };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const twoFactor = ref(false)
|
const twoFactor = ref(false)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<span class="description">选择您喜欢的主题颜色</span>
|
<span class="description">选择您喜欢的主题颜色</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select v-model="settings.theme">
|
<select :value="settingsStore.generalOptions.theme" @change="setTheme($event.target.value)">
|
||||||
<option value="light">浅色模式</option>
|
<option value="light">浅色模式</option>
|
||||||
<option value="dark">深色模式</option>
|
<option value="dark">深色模式</option>
|
||||||
<option value="system">跟随系统</option>
|
<option value="system">跟随系统</option>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<span class="description">设置应用显示的界面语言</span>
|
<span class="description">设置应用显示的界面语言</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select v-model="settings.language">
|
<select :value="settingsStore.generalOptions.language" @change="settingsStore.setGeneralOptions({ language: $event.target.value })">
|
||||||
<option value="zh-CN">简体中文</option>
|
<option value="zh-CN">简体中文</option>
|
||||||
<option value="en-US">English</option>
|
<option value="en-US">English</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<span class="description">在电脑启动时自动运行应用</span>
|
<span class="description">在电脑启动时自动运行应用</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" v-model="settings.autoStart">
|
<input type="checkbox" :checked="settingsStore.generalOptions.autoStart" @change="toggleAutoStart">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
<label class="radio-label">
|
<label class="radio-label">
|
||||||
<input type="radio" value="tray" v-model="settings.closeBehavior"> 最小化到托盘
|
<input type="radio" value="tray" :checked="settingsStore.generalOptions.closeBehavior === 'tray'" @change="settingsStore.setGeneralOptions({ closeBehavior: 'tray' })"> 最小化到托盘
|
||||||
</label>
|
</label>
|
||||||
<label class="radio-label">
|
<label class="radio-label">
|
||||||
<input type="radio" value="quit" v-model="settings.closeBehavior"> 直接退出应用
|
<input type="radio" value="quit" :checked="settingsStore.generalOptions.closeBehavior === 'quit'" @change="settingsStore.setGeneralOptions({ closeBehavior: 'quit' })"> 直接退出应用
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,35 +89,106 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useSettingsStore } from '../../stores/settings'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useMessage } from '../../components/messages/useAlert'
|
||||||
|
import { dbPromise } from '../../utils/db/baseDb'
|
||||||
|
|
||||||
const cacheSize = ref(124.5)
|
const cacheSize = ref(0)
|
||||||
const isClearing = ref(false)
|
const isClearing = ref(false)
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
const settings = reactive({
|
// -- 主题 --
|
||||||
theme: 'system',
|
const applyTheme = (theme) => {
|
||||||
language: 'zh-CN',
|
const root = document.documentElement
|
||||||
autoStart: true,
|
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
closeBehavior: 'tray'
|
root.style.setProperty('--bg', isDark ? '#1e1e1e' : '#fff')
|
||||||
})
|
root.style.setProperty('--text', isDark ? '#ddd' : '#333')
|
||||||
|
// body 层
|
||||||
|
document.body.style.background = isDark ? '#1a1a1a' : ''
|
||||||
|
document.body.style.color = isDark ? '#ccc' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTheme = (val) => {
|
||||||
|
settingsStore.setGeneralOptions({ theme: val })
|
||||||
|
applyTheme(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 开机自启动(Electron) --
|
||||||
|
const toggleAutoStart = () => {
|
||||||
|
const next = !settingsStore.generalOptions.autoStart
|
||||||
|
settingsStore.setGeneralOptions({ autoStart: next })
|
||||||
|
|
||||||
|
// Electron 环境通过 IPC 写入系统启动项
|
||||||
|
if (window.electron?.ipcRenderer) {
|
||||||
|
try { window.electron.ipcRenderer.send('setting-autoStart', next) } catch { /* Electron IPC is optional here. */ }
|
||||||
|
} else if (window.api) {
|
||||||
|
// 如果 preload 暴露了,走这里
|
||||||
|
try { window.api.setAutoStart?.(next) } catch { /* Alternate preload API is optional here. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 缓存 --
|
||||||
|
const calcCacheSize = async () => {
|
||||||
|
try {
|
||||||
|
const db = await dbPromise
|
||||||
|
const stores = ['messages', 'conversations', 'contacts', 'groupRequests']
|
||||||
|
let total = 0
|
||||||
|
for (const storeName of stores) {
|
||||||
|
if (db.objectStoreNames.contains(storeName)) {
|
||||||
|
const items = await db.getAll(storeName)
|
||||||
|
total += new Blob([JSON.stringify(items)]).size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 加上磁盘缓存大小
|
||||||
|
if (window.api?.cache?.diskSize) {
|
||||||
|
const res = await window.api.cache.diskSize()
|
||||||
|
if (res.success) total += res.size
|
||||||
|
}
|
||||||
|
cacheSize.value = +(total / (1024 * 1024)).toFixed(2)
|
||||||
|
} catch { cacheSize.value = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCache = async () => {
|
||||||
|
isClearing.value = true
|
||||||
|
try {
|
||||||
|
const db = await dbPromise
|
||||||
|
const stores = ['messages', 'conversations', 'contacts', 'groupRequests']
|
||||||
|
for (const storeName of stores) {
|
||||||
|
try { await db.clear(storeName) } catch { /* Continue clearing the remaining cache stores. */ }
|
||||||
|
}
|
||||||
|
// 清除 localStorage 会话缓存(置顶/免打扰标记)
|
||||||
|
const keys = Object.keys(localStorage)
|
||||||
|
for (const k of keys) {
|
||||||
|
if (k.startsWith('conv_')) localStorage.removeItem(k)
|
||||||
|
}
|
||||||
|
// 清除磁盘文件缓存
|
||||||
|
if (window.api?.cache?.clearDisk) {
|
||||||
|
await window.api.cache.clearDisk()
|
||||||
|
}
|
||||||
|
cacheSize.value = 0
|
||||||
|
message.success('缓存清理成功')
|
||||||
|
} catch {
|
||||||
|
message.error('缓存清理失败')
|
||||||
|
} finally {
|
||||||
|
isClearing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/auth/login')
|
router.push('/auth/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearCache = () => {
|
onMounted(() => {
|
||||||
isClearing.value = true
|
applyTheme(settingsStore.generalOptions.theme)
|
||||||
setTimeout(() => {
|
calcCacheSize()
|
||||||
cacheSize.ref = 0
|
})
|
||||||
isClearing.value = false
|
|
||||||
alert('缓存清理成功')
|
|
||||||
}, 1500)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -171,7 +242,6 @@ const clearCache = () => {
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 下拉选择框样式 */
|
|
||||||
.select-wrapper select {
|
.select-wrapper select {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: 1px solid #dcdfe6;
|
border: 1px solid #dcdfe6;
|
||||||
@@ -182,7 +252,6 @@ const clearCache = () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 单选框组样式 */
|
|
||||||
.radio-group {
|
.radio-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -198,7 +267,6 @@ const clearCache = () => {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮样式 */
|
|
||||||
.ghost-btn {
|
.ghost-btn {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #dcdfe6;
|
border: 1px solid #dcdfe6;
|
||||||
@@ -224,7 +292,6 @@ const clearCache = () => {
|
|||||||
color: #007bff;
|
color: #007bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 复用 Switch 开关样式 */
|
|
||||||
.switch {
|
.switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<span class="description">当有新版本可用时提醒我</span>
|
<span class="description">当有新版本可用时提醒我</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" v-model="settings.systemUpdate">
|
<input type="checkbox" :checked="settingsStore.notificationOptions.systemUpdate" @change="toggle('systemUpdate')">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">新消息提醒</span>
|
<span class="label">新消息提醒</span>
|
||||||
<span class="description">收到好友或群组新消息时播放提示音</span>
|
<span class="description">收到好友或群组新消息时播放提示音并显示通知</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" v-model="settings.newMsg">
|
<input type="checkbox" :checked="settingsStore.notificationOptions.newMsg" @change="toggle('newMsg')">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<span class="description">在屏幕右下角显示通知气泡</span>
|
<span class="description">在屏幕右下角显示通知气泡</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" v-model="settings.desktopPopup">
|
<input type="checkbox" :checked="settingsStore.notificationOptions.desktopPopup" @change="toggle('desktopPopup')">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<span class="description">每周接收一次系统周报</span>
|
<span class="description">每周接收一次系统周报</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" v-model="settings.emailDigest">
|
<input type="checkbox" :checked="settingsStore.notificationOptions.emailDigest" @change="toggle('emailDigest')">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,30 +63,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useSettingsStore } from '../../stores/settings'
|
||||||
|
import { useMessage } from '../../components/messages/useAlert'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const message = useMessage()
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
|
||||||
const settings = reactive({
|
// 即时切换,但只保存开关状态
|
||||||
systemUpdate: true,
|
const toggle = (key) => {
|
||||||
newMsg: true,
|
settingsStore.setNotificationOptions({ [key]: !settingsStore.notificationOptions[key] })
|
||||||
desktopPopup: false,
|
// 新消息关闭时重置浏览器通知权限感知(下次再开时重新请求)
|
||||||
emailDigest: false
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
// 模拟请求
|
// settingsStore 已经即时写入 localStorage,这里只是给用户反馈
|
||||||
await new Promise(r => setTimeout(r, 800))
|
await new Promise(r => setTimeout(r, 600))
|
||||||
isSaving.value = false
|
isSaving.value = false
|
||||||
alert('通知设置已更新')
|
message.success('通知设置已保存')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-content {
|
.settings-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 520px; /* 稍微宽一点,方便文字展示 */
|
max-width: 520px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
@@ -133,7 +136,6 @@ const saveSettings = async () => {
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 纯 CSS 实现的开关组件 */
|
|
||||||
.switch {
|
.switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -169,14 +171,13 @@ const saveSettings = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .slider {
|
input:checked + .slider {
|
||||||
background-color: #007bff; /* 匹配你截图中的主蓝色 */
|
background-color: #007bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .slider:before {
|
input:checked + .slider:before {
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮样式保持一致 */
|
|
||||||
.action-bar {
|
.action-bar {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import feather from 'feather-icons'
|
import feather from 'feather-icons'
|
||||||
import SettingContent from './SettingContent.vue'
|
import SettingContent from './SettingContent.vue'
|
||||||
|
|
||||||
@@ -36,14 +36,6 @@ const menuItems = [
|
|||||||
|
|
||||||
const currentMenuName = computed(() => menuItems.find(i => i.id === activeTab.value)?.name)
|
const currentMenuName = computed(() => menuItems.find(i => i.id === activeTab.value)?.name)
|
||||||
|
|
||||||
const notificationSettings = reactive({
|
|
||||||
'声音提醒': true,
|
|
||||||
'桌面弹窗': true,
|
|
||||||
'仅在免打扰外提醒': false
|
|
||||||
})
|
|
||||||
|
|
||||||
const save = () => alert('设置已生效')
|
|
||||||
const reset = () => location.reload()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { userService } from '../../services/userService'
|
|||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
|
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
|
||||||
import { useMessage } from '../../components/messages/useAlert'
|
import { useMessage } from '../../components/messages/useAlert'
|
||||||
import { getFileHash } from '../../utils/uploadTools'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -64,10 +63,10 @@ const triggerUpload = () => fileRef.value.click()
|
|||||||
|
|
||||||
const onFileChange = async (e) => {
|
const onFileChange = async (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
const hash = await getFileHash(file)
|
const res = await uploadService.uploadSmallFile(file, true)
|
||||||
const res = await uploadService.uploadSmallFile(file, hash)
|
|
||||||
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
|
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
|
||||||
message.error(res.message)
|
message.error(res.message)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
userInfo.avatar = res.data.url
|
userInfo.avatar = res.data.url
|
||||||
message.success('头像上传成功')
|
message.success('头像上传成功')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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']
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user