比较提交
没有共同的提交。c11e8f6d710dc5cc059e01adf4a610170e214269 和 55826db8c4e2fabe92adf679489d7e56a66e50cf 的历史完全不同。
c11e8f6d71
...
55826db8c4
@ -1,10 +0,0 @@
|
||||
.git
|
||||
.idea
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
docs-site/docs/.vitepress/dist
|
||||
**/.DS_Store
|
||||
**/*.log
|
||||
*.iml
|
||||
@ -1,4 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /workspace
|
||||
|
||||
@ -8,10 +7,7 @@ COPY tenant-platform ./tenant-platform
|
||||
COPY ops-platform ./ops-platform
|
||||
COPY docs-site ./docs-site
|
||||
|
||||
ENV YARN_CACHE_FOLDER=/var/cache/yarn
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/yarn,sharing=locked \
|
||||
yarn install --frozen-lockfile
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
ARG TENANT_APP_BASE=/
|
||||
ARG OPS_APP_BASE=/ops/
|
||||
|
||||
4
Jenkinsfile
vendored
4
Jenkinsfile
vendored
@ -15,7 +15,6 @@ pipeline {
|
||||
PROD_USER = 'ubuntu'
|
||||
COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml'
|
||||
IMAGE_NAME = 'web'
|
||||
DOCKER_BUILDKIT = '1'
|
||||
}
|
||||
|
||||
stages {
|
||||
@ -30,8 +29,7 @@ pipeline {
|
||||
def fullImage = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${IMAGE_NAME}:${params.IMAGE_TAG}"
|
||||
bat """
|
||||
docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p %ACR_PASS%
|
||||
docker pull ${fullImage} || exit 0
|
||||
docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${fullImage} -t ${fullImage} .
|
||||
docker build -t ${fullImage} .
|
||||
docker push ${fullImage}
|
||||
docker rmi ${fullImage}
|
||||
"""
|
||||
|
||||
@ -1,280 +1,249 @@
|
||||
# Server API 参考
|
||||
|
||||
XuqmGroup 的服务端能力分成三类:
|
||||
|
||||
- `IM 服务`:账号、消息、群组、好友、会话、黑名单、管理端、回调
|
||||
- `Push 服务`:设备注册、离线推送、诊断、管理端
|
||||
- `Update 服务`:应用版本、RN Bundle、应用商店提审、发布配置、操作日志
|
||||
|
||||
**Base URL**:`https://dev.xuqinmin.com`
|
||||
|
||||
所有接口都通过 Nginx 反代,无需关心内部端口。
|
||||
|
||||
---
|
||||
所有 API 通过 Nginx 反代,无需区分内部端口。
|
||||
|
||||
## 认证
|
||||
|
||||
### IM 服务
|
||||
除 `/api/im/auth/login` 外,所有 IM 接口需在请求头携带 JWT Token:
|
||||
|
||||
- `POST /api/im/auth/login` 之前的所有请求,客户端和服务端均需携带 `Authorization: Bearer <im_jwt>`
|
||||
- `im_jwt` 通过 `POST /api/im/auth/login` 颁发
|
||||
- 登录请求需要 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Push / Update / Tenant 相关服务
|
||||
|
||||
- 业务侧调用通常使用 `appKey`
|
||||
- 管理端接口需要租户或运营平台的 JWT
|
||||
- 内部接口统一使用 `X-Internal-Token`
|
||||
Token 由 `/api/im/auth/login` 接口签发。当前 IM 登录不做过期功能,只校验 `userId + UserSig` 是否匹配。
|
||||
|
||||
---
|
||||
|
||||
## IM 服务
|
||||
## IM 服务(/api/im/)
|
||||
|
||||
### 账号与登录
|
||||
### 登录 · 获取 Token
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
```
|
||||
POST /api/im/auth/login
|
||||
```
|
||||
|
||||
**Query 参数**
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/im/auth/login` | IM 登录,返回 JWT Token |
|
||||
| `GET` | `/api/im/accounts/{userId}` | 获取账号资料 |
|
||||
| `PUT` | `/api/im/accounts/{userId}` | 更新账号资料 |
|
||||
| `GET` | `/api/im/accounts/search` | 搜索账号 |
|
||||
| `POST` | `/api/im/accounts/import` | 导入单个账号 |
|
||||
| `POST` | `/api/im/accounts/import/batch` | 批量导入账号 |
|
||||
| `DELETE` | `/api/im/accounts/{userId}` | 删除账号 |
|
||||
| `GET` | `/api/im/accounts/{userId}/exists` | 检查账号是否存在 |
|
||||
| `appId` | 是 | 应用 ID |
|
||||
| `userId` | 是 | 用户 ID |
|
||||
|
||||
### 消息
|
||||
**请求头**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/im/messages/send` | 发送消息 |
|
||||
| `POST` | `/api/im/messages/{id}/revoke` | 撤回消息 |
|
||||
| `PUT` | `/api/im/messages/{id}` | 编辑消息 |
|
||||
| `GET` | `/api/im/messages/history/{toId}` | 单聊历史 |
|
||||
| `GET` | `/api/im/messages/group-history/{groupId}` | 群聊历史 |
|
||||
| `GET` | `/api/im/messages/search` | 按关键字搜索消息 |
|
||||
| `GET` | `/api/im/messages/offline/count` | 离线消息数 |
|
||||
| `POST` | `/api/im/messages/offline` | 同步离线消息 |
|
||||
| 头 | 必填 | 说明 |
|
||||
|----|------|------|
|
||||
| `X-App-Timestamp` | 是 | 当前时间戳(毫秒) |
|
||||
| `X-App-Nonce` | 是 | 随机字符串 |
|
||||
| `X-App-Signature` | 是 | HmacSHA256 签名 |
|
||||
|
||||
### 会话
|
||||
**签名 Payload**(已简化,不再包含 nickname/avatar):
|
||||
```
|
||||
{appId}\n{userId}\n{timestamp}\n{nonce}
|
||||
```
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/im/conversations` | 会话列表 |
|
||||
| `PUT` | `/api/im/conversations/{targetId}/pinned` | 置顶会话 |
|
||||
| `PUT` | `/api/im/conversations/{targetId}/muted` | 免打扰 |
|
||||
| `PUT` | `/api/im/conversations/{targetId}/read` | 标记已读 |
|
||||
| `PUT` | `/api/im/conversations/{targetId}/draft` | 设置草稿 |
|
||||
| `PUT` | `/api/im/conversations/{targetId}/hidden` | 隐藏会话 |
|
||||
| `PUT` | `/api/im/conversations/{targetId}/group` | 设置会话分组 |
|
||||
| `GET` | `/api/im/conversation-groups` | 会话分组列表 |
|
||||
| `GET` | `/api/im/conversation-groups/{groupName}` | 指定分组内容 |
|
||||
| `DELETE` | `/api/im/conversations/{targetId}` | 删除会话 |
|
||||
**响应**
|
||||
|
||||
### 好友与黑名单
|
||||
```json
|
||||
{ "token": "eyJ..." }
|
||||
```
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/im/friends` | 好友列表 |
|
||||
| `POST` | `/api/im/friends` | 添加好友 |
|
||||
| `POST` | `/api/im/friends/batch` | 批量添加好友 |
|
||||
| `DELETE` | `/api/im/friends/{friendId}` | 删除好友 |
|
||||
| `DELETE` | `/api/im/friends` | 删除全部好友 |
|
||||
| `POST` | `/api/im/friends/batch/remove` | 批量删除好友 |
|
||||
| `PUT` | `/api/im/friends/{friendId}/group` | 设置好友分组 |
|
||||
| `GET` | `/api/im/friends/groups` | 好友分组列表 |
|
||||
| `GET` | `/api/im/friends/groups/{groupName}` | 指定分组好友 |
|
||||
| `POST` | `/api/im/friends/check` | 校验好友关系 |
|
||||
| `GET` | `/api/im/blacklist` | 黑名单列表 |
|
||||
| `POST` | `/api/im/blacklist` | 添加黑名单 |
|
||||
| `DELETE` | `/api/im/blacklist` | 删除黑名单 |
|
||||
| `GET` | `/api/im/blacklist/check` | 校验黑名单关系 |
|
||||
|
||||
### 群组
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/im/groups` | 群列表 |
|
||||
| `POST` | `/api/im/groups` | 创建群组 |
|
||||
| `GET` | `/api/im/groups/public` | 公开群列表 |
|
||||
| `GET` | `/api/im/groups/search` | 搜索群 |
|
||||
| `GET` | `/api/im/groups/{groupId}` | 群详情 |
|
||||
| `GET` | `/api/im/groups/{groupId}/members` | 群成员 |
|
||||
| `GET` | `/api/im/groups/{groupId}/members/search` | 搜索群成员 |
|
||||
| `PUT` | `/api/im/groups/{groupId}` | 更新群信息 |
|
||||
| `POST` | `/api/im/groups/{groupId}/members` | 添加群成员 |
|
||||
| `POST` | `/api/im/groups/{groupId}/members/batch` | 批量添加群成员 |
|
||||
| `DELETE` | `/api/im/groups/{groupId}/members/{targetUserId}` | 删除群成员 |
|
||||
| `POST` | `/api/im/groups/{groupId}/members/batch/remove` | 批量删除群成员 |
|
||||
| `POST` | `/api/im/groups/{groupId}/roles` | 设置群角色 |
|
||||
| `POST` | `/api/im/groups/{groupId}/owner` | 转让群主 |
|
||||
| `PUT` | `/api/im/groups/{groupId}/attributes` | 更新群属性 |
|
||||
| `POST` | `/api/im/groups/{groupId}/attributes/delete` | 删除群属性 |
|
||||
| `POST` | `/api/im/groups/{groupId}/mute` | 群成员禁言 |
|
||||
| `DELETE` | `/api/im/groups/{groupId}` | 解散群 |
|
||||
| `POST` | `/api/im/groups/{groupId}/join-requests` | 发送加群申请 |
|
||||
| `GET` | `/api/im/groups/{groupId}/join-requests` | 查询加群申请 |
|
||||
| `POST` | `/api/im/groups/{groupId}/join-requests/{requestId}/accept` | 接受加群申请 |
|
||||
| `POST` | `/api/im/groups/{groupId}/join-requests/{requestId}/reject` | 拒绝加群申请 |
|
||||
| `POST` | `/api/im/groups/{groupId}/join-requests/batch/accept` | 批量接受加群申请 |
|
||||
| `POST` | `/api/im/groups/{groupId}/join-requests/batch/reject` | 批量拒绝加群申请 |
|
||||
|
||||
### 管理端
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/im/admin/users/state` | 查询用户在线状态 |
|
||||
| `POST` | `/api/im/admin/users/kick` | 踢下线用户 |
|
||||
| `POST` | `/api/im/admin/messages/batch-send` | 批量发消息 |
|
||||
| `POST` | `/api/im/admin/messages/read` | 管理端标记已读 |
|
||||
| `POST` | `/api/im/admin/messages/import` | 导入消息 |
|
||||
| `GET` | `/api/im/admin/groups/{groupId}/owner` | 查询群主 |
|
||||
| `PUT` | `/api/im/admin/groups/{groupId}/owner` | 管理端转让群主 |
|
||||
| `PUT` | `/api/im/admin/groups/{groupId}/attributes` | 管理端更新群属性 |
|
||||
| `POST` | `/api/im/admin/groups/{groupId}/attributes/delete` | 管理端删除群属性 |
|
||||
| `GET` | `/api/im/admin/groups/{groupId}/read-receipts` | 群已读统计 |
|
||||
| `GET` | `/api/im/admin/webhooks` | Webhook 列表 |
|
||||
| `POST` | `/api/im/admin/webhooks` | 创建 Webhook |
|
||||
| `PUT` | `/api/im/admin/webhooks/{id}` | 更新 Webhook |
|
||||
| `DELETE` | `/api/im/admin/webhooks/{id}` | 删除 Webhook |
|
||||
| `GET` | `/api/im/admin/webhook-deliveries` | Webhook 投递记录 |
|
||||
| `GET` | `/api/im/admin/webhook-alerts` | Webhook 告警 |
|
||||
| `POST` | `/api/im/admin/webhook-alerts/{id}/acknowledge` | 告警确认 |
|
||||
| `GET` | `/api/im/admin/webhooks/{id}/health` | Webhook 健康状态 |
|
||||
| `GET` | `/api/im/admin/keyword-filters` | 敏感词列表 |
|
||||
| `POST` | `/api/im/admin/keyword-filters` | 新增敏感词 |
|
||||
| `PUT` | `/api/im/admin/keyword-filters/{id}` | 更新敏感词 |
|
||||
| `DELETE` | `/api/im/admin/keyword-filters/{id}` | 删除敏感词 |
|
||||
| `GET` | `/api/im/admin/global-mute` | 全局禁言状态 |
|
||||
| `PUT` | `/api/im/admin/global-mute` | 更新全局禁言 |
|
||||
| `GET` | `/api/im/admin/operation-logs` | IM 操作日志 |
|
||||
|
||||
### 内部接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/im/internal/presence/resolve-token` | 解析 IM Token |
|
||||
| `GET` | `/api/im/internal/presence/users/{userId}` | 查询用户在线状态 |
|
||||
> 重复登录会覆盖当前会话;SDK 侧不做生命周期检测或旧登录兼容。
|
||||
|
||||
---
|
||||
|
||||
## Push 服务
|
||||
### 发送消息
|
||||
|
||||
### 公开接口
|
||||
```
|
||||
POST /api/im/messages/send
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/push/register` | 设备注册推送 token |
|
||||
| `DELETE` | `/api/push/unregister` | 设备取消注册 |
|
||||
| `POST` | `/api/push/send` | 发送推送 |
|
||||
| `POST` | `/api/push/receive-push` | 推送回执/接收 |
|
||||
|
||||
### 管理端
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/push/admin/user-status` | 查询用户推送状态 |
|
||||
| `GET` | `/api/push/admin/device-logs` | 设备日志 |
|
||||
| `POST` | `/api/push/admin/test-offline` | 离线推送测试 |
|
||||
|
||||
### 内部接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/push/internal/notify` | 内部通知转发 |
|
||||
| `GET` | `/api/push/internal/diagnostics/search` | 按 token 查询诊断 |
|
||||
| `GET` | `/api/push/internal/device-logs` | 内部设备日志 |
|
||||
| `POST` | `/api/push/internal/test-offline` | 内部离线测试 |
|
||||
|
||||
---
|
||||
|
||||
## Update 服务
|
||||
|
||||
### 应用版本
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/v1/updates/app/check` | 检查应用更新 |
|
||||
| `POST` | `/api/v1/updates/app/upload` | 上传应用版本 |
|
||||
| `GET|POST` | `/api/v1/updates/app/inspect` | 检查上传内容 |
|
||||
| `POST` | `/api/v1/updates/app/{id}/publish` | 发布应用版本 |
|
||||
| `POST` | `/api/v1/updates/app/{id}/unpublish` | 取消发布 |
|
||||
| `POST` | `/api/v1/updates/app/{id}/gray` | 灰度发布 |
|
||||
| `GET` | `/api/v1/updates/app/list` | 版本列表 |
|
||||
|
||||
### RN Bundle
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/v1/rn/update/check` | 检查 RN 更新 |
|
||||
| `POST` | `/api/v1/rn/upload` | 上传 RN Bundle |
|
||||
| `GET|POST` | `/api/v1/rn/inspect` | 检查 RN 上传内容 |
|
||||
| `GET` | `/api/v1/rn/list` | RN 列表 |
|
||||
| `POST` | `/api/v1/rn/{id}/publish` | 发布 RN Bundle |
|
||||
| `POST` | `/api/v1/rn/{id}/unpublish` | 取消发布 RN Bundle |
|
||||
| `POST` | `/api/v1/rn/{id}/gray` | RN 灰度 |
|
||||
|
||||
### 应用商店提审
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/v1/updates/publish/config` | 获取发布配置 |
|
||||
| `PUT` | `/api/v1/updates/publish/config` | 更新发布配置 |
|
||||
| `GET` | `/api/v1/updates/gray/members` | 灰度成员列表 |
|
||||
| `POST` | `/api/v1/updates/gray/members/sync` | 同步灰度成员 |
|
||||
| `POST` | `/api/v1/updates/gray/members/import` | 导入灰度成员 |
|
||||
| `GET` | `/api/v1/updates/store/configs` | 获取应用市场配置 |
|
||||
| `PUT` | `/api/v1/updates/store/configs/{storeType}` | 更新应用市场配置 |
|
||||
| `DELETE` | `/api/v1/updates/store/configs/{storeType}` | 删除应用市场配置 |
|
||||
| `GET` | `/api/v1/updates/store/credentials` | 获取市场凭据摘要 |
|
||||
| `POST` | `/api/v1/updates/store/app/{versionId}/submit` | 预提交应用市场 |
|
||||
| `POST` | `/api/v1/updates/store/app/{versionId}/execute-submit` | 执行提审 |
|
||||
| `POST` | `/api/v1/updates/store/app/{versionId}/review` | 回写审核结果 |
|
||||
|
||||
### 运营日志与文件
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/v1/updates/ops/logs` | 更新操作日志 |
|
||||
| `GET` | `/api/v1/updates/files/apk/{filename}` | APK 文件访问 |
|
||||
| `GET` | `/api/v1/rn/files/{appKey}/{platform}/{moduleId}` | RN Bundle 文件访问 |
|
||||
| `POST` | `/api/v1/updates/unified/upload` | 统一发布包上传 |
|
||||
|
||||
---
|
||||
|
||||
## 数据模型约定
|
||||
|
||||
### 应用标识
|
||||
|
||||
- 所有业务侧判断应用身份使用 `appKey`
|
||||
- `appId` 仅保留在第三方厂商账号字段里,不作为应用身份
|
||||
|
||||
### 常见响应
|
||||
**请求体**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"status": "0",
|
||||
"data": { },
|
||||
"message": "success"
|
||||
"toId": "user_002",
|
||||
"chatType": "SINGLE",
|
||||
"msgType": "TEXT",
|
||||
"content": "Hello!"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误码
|
||||
`chatType`:`SINGLE` | `GROUP`
|
||||
|
||||
| HTTP 状态 | code | 含义 |
|
||||
|-----------|------|------|
|
||||
| `400` | `400` | 参数错误 |
|
||||
| `401` | `401` | 鉴权失败 |
|
||||
| `403` | `403` | 无权限 |
|
||||
| `404` | `404` | 资源不存在 |
|
||||
| `500` | `500` | 服务端内部错误 |
|
||||
`msgType`:`TEXT` | `IMAGE` | `VIDEO` | `AUDIO` | `FILE` | `CUSTOM` | `LOCATION` | `NOTIFY` | `RICH_TEXT` | `CALL_AUDIO` | `CALL_VIDEO` | `FORWARD`
|
||||
|
||||
**响应**:`ImMessage` 对象
|
||||
|
||||
---
|
||||
|
||||
## 集成建议
|
||||
### 撤回消息
|
||||
|
||||
- 服务端业务代码统一使用 Java / Go / Python Server SDK 调用上述 REST 接口
|
||||
- 客户端统一使用 `appKey` 作为应用上下文
|
||||
- 实时刷新场景建议使用 IM SDK 订阅服务端事件
|
||||
- 更新、Push、IM 的后台能力可以独立接入,也可以组合使用
|
||||
```
|
||||
PUT /api/im/messages/{messageId}/revoke
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**:更新后的 `ImMessage`(`status: "REVOKED"`,`msgType: "REVOKED"`)
|
||||
|
||||
---
|
||||
|
||||
### 消息历史(单聊)
|
||||
|
||||
```
|
||||
GET /api/im/messages/history?appId=&userId=&toId=&page=0&size=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**:分页 `ImMessage` 列表
|
||||
|
||||
---
|
||||
|
||||
### 创建群组
|
||||
|
||||
```
|
||||
POST /api/im/groups
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"appId": "ak_demo_chat",
|
||||
"name": "开发群",
|
||||
"memberIds": ["user_001", "user_002"]
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:`ImGroup` 对象
|
||||
|
||||
---
|
||||
|
||||
### 获取群列表
|
||||
|
||||
```
|
||||
GET /api/im/groups?appId=ak_demo_chat
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 添加群成员
|
||||
|
||||
```
|
||||
POST /api/im/groups/{groupId}/members
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "userId": "user_003" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 移除群成员
|
||||
|
||||
```
|
||||
DELETE /api/im/groups/{groupId}/members/{targetUserId}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 会话扩展
|
||||
|
||||
```
|
||||
PUT /api/im/conversations/{targetId}/hidden?appId=&chatType=SINGLE&hidden=true
|
||||
PUT /api/im/conversations/{targetId}/group?appId=&chatType=SINGLE&groupName=重要客户
|
||||
GET /api/im/conversation-groups?appId=
|
||||
GET /api/im/conversation-groups/{groupName}?appId=
|
||||
```
|
||||
|
||||
### 好友扩展
|
||||
|
||||
```
|
||||
DELETE /api/im/friends?appId=
|
||||
PUT /api/im/friends/{friendId}/group?appId=&groupName=同事
|
||||
GET /api/im/friends/groups?appId=
|
||||
GET /api/im/friends/groups/{groupName}?appId=
|
||||
```
|
||||
|
||||
### 黑名单校验
|
||||
|
||||
```
|
||||
GET /api/im/blacklist/check?appId=&targetUserId=user_002
|
||||
```
|
||||
|
||||
### 群扩展
|
||||
|
||||
```
|
||||
POST /api/im/groups/{groupId}/owner
|
||||
PUT /api/im/groups/{groupId}/attributes
|
||||
POST /api/im/groups/{groupId}/attributes/delete
|
||||
```
|
||||
|
||||
管理端补充:
|
||||
|
||||
```
|
||||
POST /api/im/admin/groups/{groupId}/owner?appId=
|
||||
PUT /api/im/admin/groups/{groupId}/attributes?appId=
|
||||
POST /api/im/admin/groups/{groupId}/attributes/delete?appId=
|
||||
POST /api/im/admin/groups/{groupId}/read-receipts?appId=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### ImMessage
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"appId": "ak_demo_chat",
|
||||
"fromUserId": "user_001",
|
||||
"toId": "user_002",
|
||||
"chatType": "SINGLE",
|
||||
"msgType": "TEXT",
|
||||
"content": "Hello!",
|
||||
"status": "SENT",
|
||||
"mentionedUserIds": [],
|
||||
"createdAt": "2026-04-24T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### ImGroup
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "group_uuid",
|
||||
"appId": "ak_demo_chat",
|
||||
"name": "开发群",
|
||||
"creatorId": "user_001",
|
||||
"memberIds": ["user_001", "user_002"],
|
||||
"adminIds": ["user_001"],
|
||||
"extAttributes": "{\"department\":\"sales\"}",
|
||||
"createdAt": "2026-04-24T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
|
||||
| HTTP 状态 | code | 说明 |
|
||||
|-----------|------|------|
|
||||
| 400 | 400 | 请求参数错误 |
|
||||
| 401 | 401 | Token 无效或签名验证失败 |
|
||||
| 403 | 403 | 无权限操作(如撤回他人消息)|
|
||||
| 404 | 404 | 资源不存在 |
|
||||
| 500 | 500 | 服务器内部错误 |
|
||||
|
||||
错误响应格式:
|
||||
|
||||
```json
|
||||
{ "code": 403, "message": "只能撤回自己发送的消息" }
|
||||
```
|
||||
|
||||
@ -1,18 +1,13 @@
|
||||
# Go Server SDK
|
||||
|
||||
XuqmGroup 服务端 Go SDK,按照腾讯云服务端 API 的分类方式封装了:
|
||||
|
||||
- IM 账号、消息、群组、好友、会话、黑名单
|
||||
- 管理端 Webhook、统计、操作日志
|
||||
- Push 注册与推送
|
||||
- Update 版本管理、RN Bundle、应用商店提审
|
||||
XuqmGroup 服务端 Go SDK,提供 IM 消息发送、群管理、Push 推送等能力。
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go get github.com/xuqmgroup/xuqmgroup-server-sdk-go
|
||||
go get xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go
|
||||
```
|
||||
|
||||
---
|
||||
@ -20,40 +15,19 @@ go get github.com/xuqmgroup/xuqmgroup-server-sdk-go
|
||||
## 初始化
|
||||
|
||||
```go
|
||||
import xuqmsdk "github.com/xuqmgroup/xuqmgroup-server-sdk-go"
|
||||
import xuqmsdk "xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go"
|
||||
|
||||
client, err := xuqmsdk.NewClient(xuqmsdk.ClientConfig{
|
||||
client := xuqmsdk.NewClient(xuqmsdk.Config{
|
||||
BaseURL: "https://dev.xuqinmin.com",
|
||||
AppKey: "your_app_key",
|
||||
AppID: "your_app_id",
|
||||
AppSecret: "your_app_secret",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
> 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。
|
||||
|
||||
---
|
||||
|
||||
## 能力分类
|
||||
|
||||
| 分类 | 主要方法 |
|
||||
|------|----------|
|
||||
| 登录 | `Login` |
|
||||
| 账号 | `ImportAccount`, `ImportAccounts`, `DeleteAccount`, `GetProfile`, `UpdateProfile`, `SearchAccounts`, `CheckAccount` |
|
||||
| 消息 | `SendMessage`, `RevokeMessage`, `EditMessage`, `FetchHistory`, `FetchGroupHistory`, `SearchMessages` |
|
||||
| 会话 | `ListConversations`, `SetConversationPinned`, `SetConversationMuted`, `MarkRead`, `SetDraft`, `SetConversationHidden`, `SetConversationGroup`, `DeleteConversation` |
|
||||
| 好友 | `ListFriends`, `AddFriend`, `AddFriends`, `RemoveFriend`, `RemoveFriends`, `RemoveAllFriends`, `SetFriendGroup`, `ListFriendGroups`, `ListFriendsByGroup`, `CheckFriends` |
|
||||
| 黑名单 | `ListBlacklist`, `AddBlacklist`, `RemoveBlacklist`, `CheckBlacklist` |
|
||||
| 群组 | `ListGroups`, `ListPublicGroups`, `SearchGroups`, `GetGroup`, `ListGroupMembers`, `SearchGroupMembers`, `CreateGroup`, `UpdateGroup`, `AddGroupMember`, `AddGroupMembers`, `RemoveGroupMember`, `RemoveGroupMembers`, `SetGroupRole`, `TransferGroupOwner`, `LeaveGroup`, `UpdateGroupAttributes`, `RemoveGroupAttributes`, `MuteGroupMember`, `DismissGroup`, `SendGroupJoinRequest`, `ListGroupJoinRequests`, `AcceptGroupJoinRequest`, `RejectGroupJoinRequest`, `AcceptGroupJoinRequests`, `RejectGroupJoinRequests` |
|
||||
| 管理端 | `QueryUserState`, `KickUsers`, `BatchSendMessage`, `AdminSetMsgRead`, `ImportMessages`, `AdminTransferGroupOwner`, `AdminUpdateGroupAttributes`, `AdminRemoveGroupAttributes`, `AdminGroupReadReceipts` |
|
||||
| Webhook | `ListWebhooks`, `CreateWebhook`, `UpdateWebhook`, `DeleteWebhook`, `VerifyCallbackSignature`, `ParseCallbackEnvelope` |
|
||||
| Push | `RegisterPushToken`, `SendPush` |
|
||||
| Update | `CheckAppUpdate`, `UploadAppVersion`, `PublishAppVersion`, `UnpublishAppVersion`, `GrayAppVersion`, `ListAppVersions`, `CheckRnUpdate`, `UploadRnBundle`, `PublishRnBundle`, `UnpublishRnBundle`, `ListRnBundles` |
|
||||
|
||||
---
|
||||
|
||||
## 发送消息
|
||||
|
||||
```go
|
||||
@ -91,15 +65,22 @@ err := client.RevokeMessage("message_id")
|
||||
## 群管理
|
||||
|
||||
```go
|
||||
group, err := client.CreateGroup("项目讨论", []string{"user_001", "user_002"}, "NORMAL")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// 创建群组
|
||||
group, err := client.CreateGroup(xuqmsdk.CreateGroupRequest{
|
||||
Name: "项目讨论",
|
||||
MemberIDs: []string{"user_001", "user_002"},
|
||||
})
|
||||
|
||||
// 添加群成员
|
||||
err = client.AddGroupMember("group_xxx", "user_003")
|
||||
|
||||
// 移除群成员
|
||||
err = client.RemoveGroupMember("group_xxx", "user_003")
|
||||
|
||||
// 获取群列表
|
||||
groups, err := client.ListGroups()
|
||||
|
||||
// 获取群成员
|
||||
members, err := client.ListGroupMembers("group_xxx")
|
||||
```
|
||||
|
||||
@ -108,7 +89,12 @@ members, err := client.ListGroupMembers("group_xxx")
|
||||
## Push 推送
|
||||
|
||||
```go
|
||||
err := client.SendPush("user_001", "新消息", "你有一条未读消息", `{"chatId":"user_002"}`)
|
||||
err := client.SendPush(xuqmsdk.SendPushRequest{
|
||||
UserID: "user_001",
|
||||
Title: "新消息",
|
||||
Body: "你有一条未读消息",
|
||||
Payload: map[string]string{"chatId": "user_002"},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
@ -120,10 +106,11 @@ SDK 返回的错误实现了标准 `error` 接口。业务方可根据错误类
|
||||
```go
|
||||
msg, err := client.SendMessage(req)
|
||||
if err != nil {
|
||||
// 可断言为 *xuqmsdk.APIError 获取 HTTP 状态码和详细错误信息
|
||||
if apiErr, ok := err.(*xuqmsdk.APIError); ok {
|
||||
log.Printf("API 错误: status=%d, code=%d, message=%s", apiErr.Status, apiErr.Code, apiErr.Message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[→ Server API 文档](/server/api)
|
||||
[→ Server API 文档 →](/server/api)
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
# Java Server SDK
|
||||
|
||||
XuqmGroup 服务端 Java SDK,按腾讯云服务端 API 的思路封装了以下能力:
|
||||
|
||||
- IM 账号与登录
|
||||
- 消息发送、编辑、撤回、历史查询
|
||||
- 好友、黑名单、会话、群组
|
||||
- Webhook、管理端操作、统计
|
||||
- Push 注册与发送
|
||||
- Update 版本检查、上传、发布、灰度、应用商店提审
|
||||
XuqmGroup 服务端 Java SDK,提供 IM 消息发送、群管理、Push 推送等能力。
|
||||
|
||||
---
|
||||
|
||||
@ -17,14 +10,14 @@ Maven 在 `pom.xml` 中添加:
|
||||
|
||||
```xml
|
||||
<repository>
|
||||
<id>xuqm-nexus</id>
|
||||
<url>https://nexus.xuqinmin.com/repository/maven-public/</url>
|
||||
<id>nexus-xuqm</id>
|
||||
<url>https://nexus.xuqinmin.com/repository/android/</url>
|
||||
</repository>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xuqm</groupId>
|
||||
<artifactId>im-sdk</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<artifactId>xuqm-server-sdk</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@ -32,11 +25,11 @@ Gradle:
|
||||
|
||||
```kotlin
|
||||
repositories {
|
||||
maven { url = uri("https://nexus.xuqinmin.com/repository/maven-public/") }
|
||||
maven { url = uri("https://nexus.xuqinmin.com/repository/android/") }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.xuqm:im-sdk:0.1.0-SNAPSHOT")
|
||||
implementation("com.xuqm:xuqm-server-sdk:0.1.0")
|
||||
}
|
||||
```
|
||||
|
||||
@ -45,13 +38,14 @@ dependencies {
|
||||
## 初始化
|
||||
|
||||
```java
|
||||
import com.xuqm.im.sdk.XuqmImServerSdk;
|
||||
import com.xuqm.sdk.server.XuqmServerClient;
|
||||
import com.xuqm.sdk.server.XuqmServerConfig;
|
||||
|
||||
XuqmImServerSdk client = XuqmImServerSdk.builder()
|
||||
XuqmServerClient client = new XuqmServerClient(XuqmServerConfig.builder()
|
||||
.baseUrl("https://dev.xuqinmin.com")
|
||||
.appKey("your_app_key")
|
||||
.appId("your_app_id")
|
||||
.appSecret("your_app_secret")
|
||||
.build();
|
||||
.build());
|
||||
```
|
||||
|
||||
> 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。
|
||||
@ -61,18 +55,15 @@ XuqmImServerSdk client = XuqmImServerSdk.builder()
|
||||
## 发送消息
|
||||
|
||||
```java
|
||||
import com.xuqm.im.sdk.XuqmImServerSdk.ImMessage;
|
||||
import com.xuqm.im.sdk.XuqmImServerSdk.SendMessageRequest;
|
||||
import com.xuqm.sdk.server.model.SendMessageRequest;
|
||||
import com.xuqm.sdk.server.model.ImMessage;
|
||||
|
||||
ImMessage msg = client.sendMessage(new SendMessageRequest(
|
||||
"user_002",
|
||||
"SINGLE",
|
||||
"TEXT",
|
||||
"Hello from Java SDK!",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
ImMessage msg = client.sendMessage(SendMessageRequest.builder()
|
||||
.toId("user_002")
|
||||
.chatType("SINGLE")
|
||||
.msgType("TEXT")
|
||||
.content("Hello from Java SDK!")
|
||||
.build());
|
||||
|
||||
System.out.println("消息已发送: " + msg.getId());
|
||||
```
|
||||
@ -96,32 +87,28 @@ client.revokeMessage("message_id");
|
||||
|
||||
---
|
||||
|
||||
## 能力分类
|
||||
|
||||
| 分类 | 主要方法 |
|
||||
|------|----------|
|
||||
| IM 账号 | `login`, `importAccount`, `importAccounts`, `deleteAccount`, `getProfile`, `updateProfile`, `searchAccounts` |
|
||||
| 消息 | `sendMessage`, `revokeMessage`, `editMessage`, `fetchHistory`, `fetchGroupHistory`, `searchMessages` |
|
||||
| 会话 | `listConversations`, `setConversationPinned`, `setConversationMuted`, `markRead`, `setDraft`, `setConversationHidden`, `setConversationGroup`, `deleteConversation` |
|
||||
| 好友 | `listFriends`, `addFriend`, `addFriends`, `removeFriend`, `removeFriends`, `removeAllFriends`, `setFriendGroup`, `listFriendGroups`, `listFriendsByGroup`, `checkFriends` |
|
||||
| 黑名单 | `listBlacklist`, `addBlacklist`, `removeBlacklist`, `checkBlacklist` |
|
||||
| 群组 | `listGroups`, `createGroup`, `updateGroup`, `addGroupMember`, `addGroupMembers`, `removeGroupMember`, `removeGroupMembers`, `setGroupRole`, `transferGroupOwner`, `leaveGroup`, `updateGroupAttributes`, `removeGroupAttributes`, `muteGroupMember`, `dismissGroup`, `sendGroupJoinRequest`, `listGroupJoinRequests`, `acceptGroupJoinRequest`, `rejectGroupJoinRequest`, `acceptGroupJoinRequests`, `rejectGroupJoinRequests` |
|
||||
| 管理端 | `queryUserState`, `kickUsers`, `batchSendMessage`, `adminSetMsgRead`, `importMessages`, `adminTransferGroupOwner`, `adminUpdateGroupAttributes`, `adminRemoveGroupAttributes`, `adminGroupReadReceipts` |
|
||||
| Webhook | `listWebhooks`, `createWebhook`, `updateWebhook`, `deleteWebhook`, `verifyCallbackSignature`, `parseCallbackEnvelope`, 各类 `parse*CallbackPayload` |
|
||||
| Push | `registerPushToken`, `sendPush` |
|
||||
| Update | `checkAppUpdate`, `uploadAppVersion`, `publishAppVersion`, `unpublishAppVersion`, `grayAppVersion`, `listAppVersions`, `checkRnUpdate`, `uploadRnBundle`, `publishRnBundle`, `unpublishRnBundle`, `listRnBundles` |
|
||||
|
||||
## 群管理示例
|
||||
## 群管理
|
||||
|
||||
```java
|
||||
import com.xuqm.im.sdk.XuqmImServerSdk.GroupView;
|
||||
import com.xuqm.sdk.server.model.*;
|
||||
|
||||
GroupView group = client.createGroup("项目讨论", List.of("user_001", "user_002"), "NORMAL");
|
||||
// 创建群组
|
||||
ImGroup group = client.createGroup(CreateGroupRequest.builder()
|
||||
.name("项目讨论")
|
||||
.memberIds(List.of("user_001", "user_002"))
|
||||
.build());
|
||||
|
||||
// 添加群成员
|
||||
client.addGroupMember("group_xxx", "user_003");
|
||||
|
||||
// 移除群成员
|
||||
client.removeGroupMember("group_xxx", "user_003");
|
||||
|
||||
List<GroupView> groups = client.listGroups();
|
||||
// 获取群列表
|
||||
List<ImGroup> groups = client.listGroups();
|
||||
|
||||
// 获取群成员
|
||||
List<ImUser> members = client.listGroupMembers("group_xxx");
|
||||
```
|
||||
|
||||
---
|
||||
@ -144,11 +131,11 @@ client.sendPush(SendPushRequest.builder()
|
||||
## 错误处理
|
||||
|
||||
```java
|
||||
import com.xuqm.im.sdk.XuqmImServerSdk.ImSdkException;
|
||||
import com.xuqm.sdk.server.exception.XuqmApiException;
|
||||
|
||||
try {
|
||||
ImMessage msg = client.sendMessage(request);
|
||||
} catch (ImSdkException e) {
|
||||
} catch (XuqmApiException e) {
|
||||
System.err.printf("API 错误: status=%d, code=%d, message=%s%n",
|
||||
e.getHttpStatus(), e.getCode(), e.getMessage());
|
||||
}
|
||||
|
||||
@ -1,18 +1,13 @@
|
||||
# Python Server SDK
|
||||
|
||||
XuqmGroup 服务端 Python SDK,按照腾讯云服务端 API 的分类方式封装了:
|
||||
|
||||
- IM 账号、消息、群组、好友、会话、黑名单
|
||||
- 管理端 Webhook、统计、操作日志
|
||||
- Push 注册与推送
|
||||
- Update 版本管理、RN Bundle、应用商店提审
|
||||
XuqmGroup 服务端 Python SDK,提供 IM 消息发送、群管理、Push 推送等能力。
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pip install xuqmgroup-server-sdk-python
|
||||
pip install xuqm-im-server-sdk
|
||||
```
|
||||
|
||||
---
|
||||
@ -24,7 +19,7 @@ from xuqm_im_server_sdk import XuqmImServerSdk
|
||||
|
||||
sdk = XuqmImServerSdk(
|
||||
base_url="https://dev.xuqinmin.com",
|
||||
app_key="your_app_key",
|
||||
app_id="your_app_id",
|
||||
app_secret="your_app_secret",
|
||||
)
|
||||
```
|
||||
@ -33,24 +28,6 @@ sdk = XuqmImServerSdk(
|
||||
|
||||
---
|
||||
|
||||
## 能力分类
|
||||
|
||||
| 分类 | 主要方法 |
|
||||
|------|----------|
|
||||
| 登录 | `login` |
|
||||
| 账号 | `import_account`, `import_accounts`, `delete_account`, `get_profile`, `update_profile`, `search_accounts`, `check_account` |
|
||||
| 消息 | `send_message`, `revoke_message`, `edit_message`, `fetch_history`, `fetch_group_history`, `search_messages` |
|
||||
| 会话 | `list_conversations`, `set_conversation_pinned`, `set_conversation_muted`, `mark_read`, `set_draft`, `set_conversation_hidden`, `set_conversation_group`, `delete_conversation` |
|
||||
| 好友 | `list_friends`, `add_friend`, `add_friends`, `remove_friend`, `remove_friends`, `remove_all_friends`, `set_friend_group`, `list_friend_groups`, `list_friends_by_group`, `check_friends` |
|
||||
| 黑名单 | `list_blacklist`, `add_blacklist`, `remove_blacklist`, `check_blacklist` |
|
||||
| 群组 | `list_groups`, `list_public_groups`, `search_groups`, `get_group`, `list_group_members`, `search_group_members`, `create_group`, `update_group`, `add_group_member`, `add_group_members`, `remove_group_member`, `remove_group_members`, `set_group_role`, `transfer_group_owner`, `leave_group`, `update_group_attributes`, `remove_group_attributes`, `mute_group_member`, `dismiss_group`, `send_group_join_request`, `list_group_join_requests`, `accept_group_join_request`, `reject_group_join_request`, `accept_group_join_requests`, `reject_group_join_requests` |
|
||||
| 管理端 | `query_user_state`, `kick_users`, `batch_send_message`, `admin_set_msg_read`, `import_messages`, `admin_transfer_group_owner`, `admin_update_group_attributes`, `admin_remove_group_attributes`, `admin_group_read_receipts` |
|
||||
| Webhook | `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook`, `verify_callback_signature`, `parse_callback_envelope` |
|
||||
| Push | `register_push_token`, `send_push` |
|
||||
| Update | `check_app_update`, `upload_app_version`, `publish_app_version`, `unpublish_app_version`, `gray_app_version`, `list_app_versions`, `check_rn_update`, `upload_rn_bundle`, `publish_rn_bundle`, `unpublish_rn_bundle`, `list_rn_bundles` |
|
||||
|
||||
---
|
||||
|
||||
## 发送消息
|
||||
|
||||
```python
|
||||
@ -85,15 +62,22 @@ sdk.revoke_message("message_id")
|
||||
## 群管理
|
||||
|
||||
```python
|
||||
# 创建群组
|
||||
group = sdk.create_group(
|
||||
name="项目讨论",
|
||||
member_ids=["user_001", "user_002"],
|
||||
)
|
||||
|
||||
# 添加群成员
|
||||
sdk.add_group_member("group_xxx", "user_003")
|
||||
|
||||
# 移除群成员
|
||||
sdk.remove_group_member("group_xxx", "user_003")
|
||||
|
||||
# 获取群列表
|
||||
groups = sdk.list_groups()
|
||||
|
||||
# 获取群成员
|
||||
members = sdk.list_group_members("group_xxx")
|
||||
```
|
||||
|
||||
@ -125,4 +109,4 @@ except XuqmAPIError as e:
|
||||
print(f"API 错误: status={e.status}, code={e.code}, message={e.message}")
|
||||
```
|
||||
|
||||
[→ Server API 文档](/server/api)
|
||||
[→ Server API 文档 →](/server/api)
|
||||
|
||||
@ -93,7 +93,7 @@ SEND
|
||||
destination:/app/chat.send
|
||||
content-type:application/json
|
||||
|
||||
{"appKey":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"}
|
||||
{"appId":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"}
|
||||
\x00
|
||||
```
|
||||
|
||||
@ -104,7 +104,7 @@ SEND
|
||||
destination:/app/chat.revoke
|
||||
content-type:application/json
|
||||
|
||||
{"appKey":"ak_demo_chat","messageId":"..."}
|
||||
{"appId":"ak_demo_chat","messageId":"..."}
|
||||
\x00
|
||||
```
|
||||
|
||||
@ -115,7 +115,7 @@ SEND
|
||||
destination:/app/chat.sync
|
||||
content-type:application/json
|
||||
|
||||
{"appKey":"ak_demo_chat"}
|
||||
{"appId":"ak_demo_chat"}
|
||||
\x00
|
||||
```
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"@xuqm/vue3-sdk": "0.2.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"pinia": "^3.0.1",
|
||||
|
||||
@ -61,87 +61,53 @@ export interface UpdateServiceConfig {
|
||||
defaultMarketUrl?: string
|
||||
}
|
||||
|
||||
export type PushVendorKey = 'huawei' | 'xiaomi' | 'oppo' | 'vivo' | 'honor' | 'harmony' | 'apns'
|
||||
export type PushImportance = 'MIN' | 'LOW' | 'DEFAULT' | 'HIGH' | 'MAX'
|
||||
export type PushPriority = 'LOW' | 'DEFAULT' | 'HIGH'
|
||||
export type PushInterruptionLevel = '' | 'passive' | 'active' | 'time-sensitive' | 'critical'
|
||||
|
||||
export interface XiaomiPushVendorConfig {
|
||||
export interface PushVendorConfig {
|
||||
appId?: string
|
||||
appKey?: string
|
||||
appSecret?: string
|
||||
}
|
||||
|
||||
export interface HuaweiPushVendorConfig {
|
||||
appId?: string
|
||||
appSecret?: string
|
||||
}
|
||||
|
||||
export interface OppoPushVendorConfig {
|
||||
appId?: string
|
||||
appKey?: string
|
||||
masterSecret?: string
|
||||
}
|
||||
|
||||
export interface VivoPushVendorConfig {
|
||||
appId?: string
|
||||
appKey?: string
|
||||
appSecret?: string
|
||||
}
|
||||
|
||||
export interface HonorPushVendorConfig {
|
||||
appId?: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
}
|
||||
|
||||
export interface HarmonyPushVendorConfig {
|
||||
appId?: string
|
||||
appSecret?: string
|
||||
}
|
||||
|
||||
export interface ApnsPushVendorConfig {
|
||||
teamId?: string
|
||||
keyId?: string
|
||||
bundleId?: string
|
||||
privateKey?: string
|
||||
keyPath?: string
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
export interface PushVendorsConfig {
|
||||
huawei?: HuaweiPushVendorConfig
|
||||
xiaomi?: XiaomiPushVendorConfig
|
||||
oppo?: OppoPushVendorConfig
|
||||
vivo?: VivoPushVendorConfig
|
||||
honor?: HonorPushVendorConfig
|
||||
harmony?: HarmonyPushVendorConfig
|
||||
apns?: ApnsPushVendorConfig
|
||||
}
|
||||
|
||||
export interface PushProfileConfig {
|
||||
profileKey: string
|
||||
vendor: PushVendorKey
|
||||
routeType: string
|
||||
enabled: boolean
|
||||
serviceAccountJson?: string
|
||||
channelId?: string
|
||||
category?: string
|
||||
importance?: PushImportance
|
||||
priority?: PushPriority
|
||||
threadIdentifier?: string
|
||||
interruptionLevel?: PushInterruptionLevel
|
||||
badge?: boolean
|
||||
sound?: boolean
|
||||
vibration?: boolean
|
||||
notifyType?: number
|
||||
version?: number
|
||||
remark?: string
|
||||
receiptId?: string
|
||||
}
|
||||
|
||||
export interface PushServiceConfig {
|
||||
schemaVersion?: number
|
||||
updatedAt?: string
|
||||
vendors?: PushVendorsConfig
|
||||
profiles?: PushProfileConfig[]
|
||||
huawei?: PushVendorConfig
|
||||
xiaomi?: PushVendorConfig
|
||||
oppo?: PushVendorConfig
|
||||
vivo?: PushVendorConfig
|
||||
honor?: PushVendorConfig
|
||||
harmony?: PushVendorConfig
|
||||
apns?: PushVendorConfig
|
||||
fcm?: PushVendorConfig
|
||||
channels?: PushNotificationChannelConfig[]
|
||||
routing?: Record<string, PushNotificationRouteConfig>
|
||||
}
|
||||
|
||||
export interface PushNotificationChannelConfig {
|
||||
key: string
|
||||
channelId: string
|
||||
version: number
|
||||
name: string
|
||||
description?: string
|
||||
importance: 'MIN' | 'LOW' | 'DEFAULT' | 'HIGH' | 'MAX'
|
||||
sound: boolean
|
||||
vibration: boolean
|
||||
badge: boolean
|
||||
}
|
||||
|
||||
export interface PushNotificationRouteConfig {
|
||||
channel: string
|
||||
category: string
|
||||
priority: 'LOW' | 'DEFAULT' | 'HIGH'
|
||||
}
|
||||
|
||||
export const appApi = {
|
||||
|
||||
@ -80,7 +80,7 @@ updateClient.interceptors.response.use(
|
||||
)
|
||||
|
||||
export type StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE' | 'GOOGLE_PLAY' | 'HARMONY_APP' | 'REVIEW_WEBHOOK'
|
||||
export type StoreReviewState = 'PENDING' | 'SUBMITTING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
|
||||
export type StoreReviewState = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
|
||||
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
|
||||
export type GrayMode = 'PERCENT' | 'MEMBERS'
|
||||
export type GraySelectionSource = 'LOCAL' | 'CALLBACK'
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { init, login, ImClient, type ImMessage } from '@xuqm/vue3-sdk'
|
||||
import client from '@/api/client'
|
||||
|
||||
export interface StoreReviewRefreshEvent {
|
||||
event: string
|
||||
appKey: string
|
||||
versionId?: string
|
||||
storeType?: string
|
||||
reviewState?: string
|
||||
reviewReason?: string
|
||||
stage?: string
|
||||
batchId?: string
|
||||
publishStatus?: string
|
||||
source?: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
interface PlatformEventTokenResponse {
|
||||
userId: string
|
||||
token: string
|
||||
}
|
||||
|
||||
let imClient: ImClient | null = null
|
||||
let activeAppKey = ''
|
||||
|
||||
function sdkBaseUrl() {
|
||||
return import.meta.env.VITE_IM_API_BASE_URL ?? ''
|
||||
}
|
||||
|
||||
function sdkWsUrl() {
|
||||
return import.meta.env.VITE_IM_WS_URL ?? ''
|
||||
}
|
||||
|
||||
function parseEvent(message: ImMessage): StoreReviewRefreshEvent | null {
|
||||
if (!['NOTIFY', 'CUSTOM'].includes(message.msgType)) return null
|
||||
try {
|
||||
const payload = JSON.parse(message.content) as Partial<StoreReviewRefreshEvent>
|
||||
if (!payload || payload.event !== 'store_review_update') return null
|
||||
if (!payload.appKey) return null
|
||||
return payload as StoreReviewRefreshEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectStoreReviewRealtime(appKey: string, onEvent: (event: StoreReviewRefreshEvent) => void) {
|
||||
if (!appKey) return
|
||||
disconnectStoreReviewRealtime()
|
||||
activeAppKey = appKey
|
||||
|
||||
const res = await client.get<{ data: PlatformEventTokenResponse }>('/im/platform-events/token', {
|
||||
params: { appKey },
|
||||
})
|
||||
const token = res.data.data
|
||||
init({
|
||||
appKey,
|
||||
baseUrl: sdkBaseUrl(),
|
||||
wsUrl: sdkWsUrl(),
|
||||
debug: import.meta.env.DEV,
|
||||
})
|
||||
login(token.userId, token.token)
|
||||
|
||||
const clientInstance = new ImClient()
|
||||
clientInstance.on('message', (message) => {
|
||||
const event = parseEvent(message)
|
||||
if (!event || event.appKey !== activeAppKey) return
|
||||
onEvent(event)
|
||||
})
|
||||
clientInstance.on('error', (error) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[tenant-platform][IM] store review realtime error', error)
|
||||
}
|
||||
})
|
||||
clientInstance.connect()
|
||||
imClient = clientInstance
|
||||
}
|
||||
|
||||
export function disconnectStoreReviewRealtime() {
|
||||
if (imClient) {
|
||||
imClient.disconnect()
|
||||
imClient = null
|
||||
}
|
||||
activeAppKey = ''
|
||||
}
|
||||
|
||||
export function notifyStoreReviewRefresh(appKey: string) {
|
||||
if (appKey && appKey === activeAppKey) {
|
||||
ElMessage.info('检测到审核状态更新,正在刷新...')
|
||||
}
|
||||
}
|
||||
@ -698,7 +698,7 @@
|
||||
/>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="调用方式">POST 到你配置的回调地址,`Content-Type: application/json`。</el-descriptions-item>
|
||||
<el-descriptions-item label="请求头">`X-App-Key`、`X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`。</el-descriptions-item>
|
||||
<el-descriptions-item label="请求头">`X-App-Id`、`X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`。</el-descriptions-item>
|
||||
<el-descriptions-item label="请求体">统一 envelope:`callbackId`、`callbackType`、`callbackEvent`、`requestTime`、`payload`、`appKey`。</el-descriptions-item>
|
||||
<el-descriptions-item label="签名">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`。</el-descriptions-item>
|
||||
<el-descriptions-item label="失败处理">回调发送失败只记录日志,不会中断消息发送、撤回等主流程。</el-descriptions-item>
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
/>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="调用方式">服务端以 `POST` 方式推送到你配置的回调地址。</el-descriptions-item>
|
||||
<el-descriptions-item label="签名头">`X-App-Key`、`X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`。</el-descriptions-item>
|
||||
<el-descriptions-item label="签名头">`X-App-Id`、`X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`。</el-descriptions-item>
|
||||
<el-descriptions-item label="验签公式">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`。</el-descriptions-item>
|
||||
<el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重。</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@ -244,3 +244,4 @@ onMounted(async () => {
|
||||
await Promise.all([loadApp(), loadWebhooks()])
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -24,171 +24,131 @@
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
title="新推送模型按 vendors + profiles 管理。厂商凭据只负责连厂商,profiles 负责按场景配置 Channel ID、Category、Importance、角标、声音、振动等。"
|
||||
title="这里维护各厂商推送凭据。推送服务开通后,离线推送才会按厂商路由发送。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom:16px"
|
||||
/>
|
||||
|
||||
<el-card style="margin-bottom:16px">
|
||||
<template #header>厂商凭据</template>
|
||||
<div class="vendor-grid">
|
||||
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
|
||||
<template #header>{{ vendor.label }}</template>
|
||||
<div class="vendor-hint">{{ vendor.hint }}</div>
|
||||
<el-form :label-position="isMobile ? 'top' : 'right'" label-width="110px">
|
||||
<el-form-item v-for="field in vendor.fields" :key="field.key" :label="field.label">
|
||||
<el-input
|
||||
v-if="field.type === 'textarea'"
|
||||
v-model="vendorModel(vendor.key)[field.key]"
|
||||
type="textarea"
|
||||
:rows="field.rows ?? 4"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="vendorModel(vendor.key)[field.key]"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="vendorModel(vendor.key)[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="profiles-header">
|
||||
<span>平台 Profiles</span>
|
||||
<div class="profiles-actions">
|
||||
<el-button size="small" @click="addProfile()">新增通用 profile</el-button>
|
||||
<el-button size="small" @click="addProfile('xiaomi')">新增小米</el-button>
|
||||
<el-button size="small" @click="addProfile('huawei')">新增华为</el-button>
|
||||
<el-button size="small" @click="addProfile('honor')">新增荣耀</el-button>
|
||||
<el-button size="small" @click="addProfile('oppo')">新增 OPPO</el-button>
|
||||
<el-button size="small" @click="addProfile('vivo')">新增 vivo</el-button>
|
||||
<el-button size="small" @click="addProfile('harmony')">新增鸿蒙</el-button>
|
||||
<el-button size="small" @click="addProfile('apns')">新增 iOS</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #header>厂商配置</template>
|
||||
<el-divider content-position="left">通知通道</el-divider>
|
||||
<el-alert
|
||||
description="routeType 为空时表示默认兜底 profile。你可以先建 3 条,后续随时补充剩余的 2 条,或者删除不再使用的 profile。"
|
||||
description="通知通道定义 Android 通知渠道,以及 iOS/鸿蒙 对应的分类配置。业务键(Key)为业务层自定义的唯一标识,代码内部用于引用此通道,例如 im_message;Channel ID 需与 App manifest 中声明的 NotificationChannel ID 完全一致;版本号在渠道参数变更时自增,客户端将以新版本号重新注册渠道。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
:show-icon="false"
|
||||
style="margin-bottom:12px"
|
||||
/>
|
||||
|
||||
<el-table :data="pushConfig.profiles" border style="margin-bottom:16px" class="profiles-table">
|
||||
<el-table-column label="启用" width="78" align="center">
|
||||
<el-table :data="pushConfig.channels" border style="margin-bottom:16px">
|
||||
<el-table-column label="业务键" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="厂商" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.vendor" filterable>
|
||||
<el-option v-for="vendor in vendorOptions" :key="vendor.value" :label="vendor.label" :value="vendor.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="场景 RouteType" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.routeType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="例如 IM_MESSAGE / SYSTEM_NOTICE"
|
||||
>
|
||||
<el-option v-for="route in routeTypeOptions" :key="route" :label="route" :value="route" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Profile Key" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.profileKey" placeholder="例如 xiaomi_im_message_v1" />
|
||||
<el-input v-model="row.key" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Channel ID" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.channelId" placeholder="厂商 Channel ID / 通知通道 ID" />
|
||||
<el-input v-model="row.channelId" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Category" min-width="160">
|
||||
<el-table-column label="版本" width="82">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.category" placeholder="MESSAGE / SYSTEM / ..." />
|
||||
<el-input-number v-model="row.version" :min="1" controls-position="right" style="width:72px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重要性" width="120">
|
||||
<el-table-column label="名称" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重要性" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.importance">
|
||||
<el-option v-for="item in importanceOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="优先级" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.priority">
|
||||
<el-option v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
<el-table-column label="声音" width="80" align="center">
|
||||
<template #default="{ row }"><el-switch v-model="row.sound" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角标" width="78" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.badge" />
|
||||
</template>
|
||||
<el-table-column label="振动" width="80" align="center">
|
||||
<template #default="{ row }"><el-switch v-model="row.vibration" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="声音" width="78" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.sound" />
|
||||
</template>
|
||||
<el-table-column label="角标" width="80" align="center">
|
||||
<template #default="{ row }"><el-switch v-model="row.badge" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="振动" width="78" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.vibration" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Thread ID" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.threadIdentifier" placeholder="iOS thread-id" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="中断级别" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.interruptionLevel" clearable placeholder="默认">
|
||||
<el-option v-for="item in interruptionOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="NotifyType" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.notifyType" :min="0" :max="10" controls-position="right" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="92">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.version" :min="1" controls-position="right" style="width:74px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" placeholder="可选说明" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="84" fixed="right">
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="removeProfile($index)">删除</el-button>
|
||||
<el-button link type="danger" @click="removeChannel($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-button style="margin-bottom:20px" @click="addChannel">新增通道</el-button>
|
||||
|
||||
<el-divider content-position="left">业务分类路由</el-divider>
|
||||
<el-alert
|
||||
description="业务分类路由将系统内置的推送类型(如 IM 消息、好友请求、系统通知)映射到对应通道和优先级,决定推送到达时的展示行为。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
:show-icon="false"
|
||||
style="margin-bottom:12px"
|
||||
/>
|
||||
<el-table :data="routeRows" border style="margin-bottom:20px">
|
||||
<el-table-column label="业务分类" width="160">
|
||||
<template #default="{ row }">{{ row.type }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通道" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.route.channel">
|
||||
<el-option v-for="channel in pushConfig.channels" :key="channel.key" :label="channel.name || channel.key" :value="channel.key" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Category" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.route.category" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="优先级" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.route.priority">
|
||||
<el-option label="低" value="LOW" />
|
||||
<el-option label="默认" value="DEFAULT" />
|
||||
<el-option label="高" value="HIGH" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider content-position="left">厂商凭据</el-divider>
|
||||
<div class="vendor-grid">
|
||||
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
|
||||
<template #header>{{ vendor.label }}</template>
|
||||
<div class="vendor-hint">{{ vendor.hint }}</div>
|
||||
<el-form :label-position="isMobile ? 'top' : 'right'" label-width="120px">
|
||||
<el-form-item v-for="field in vendor.fields" :key="field.key" :label="field.label">
|
||||
<el-input
|
||||
v-if="field.type === 'textarea'"
|
||||
v-model="pushConfig[vendor.key][field.key]"
|
||||
type="textarea"
|
||||
:rows="field.rows ?? 4"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="pushConfig[vendor.key][field.key]"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="pushConfig[vendor.key][field.key]"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button @click="reloadConfig" :loading="loading">刷新</el-button>
|
||||
@ -201,37 +161,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { CopyDocument } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
appApi,
|
||||
type App,
|
||||
type FeatureService,
|
||||
type ApnsPushVendorConfig,
|
||||
type HarmonyPushVendorConfig,
|
||||
type HonorPushVendorConfig,
|
||||
type HuaweiPushVendorConfig,
|
||||
type OppoPushVendorConfig,
|
||||
type PushImportance,
|
||||
type PushInterruptionLevel,
|
||||
type PushPriority,
|
||||
type PushProfileConfig,
|
||||
type PushNotificationChannelConfig,
|
||||
type PushNotificationRouteConfig,
|
||||
type PushServiceConfig,
|
||||
type PushVendorKey,
|
||||
type XiaomiPushVendorConfig,
|
||||
type VivoPushVendorConfig,
|
||||
} from '@/api/app'
|
||||
|
||||
type PushVendorsState = {
|
||||
huawei: HuaweiPushVendorConfig
|
||||
xiaomi: XiaomiPushVendorConfig
|
||||
oppo: OppoPushVendorConfig
|
||||
vivo: VivoPushVendorConfig
|
||||
honor: HonorPushVendorConfig
|
||||
harmony: HarmonyPushVendorConfig
|
||||
apns: ApnsPushVendorConfig
|
||||
}
|
||||
|
||||
type VendorKey = keyof PushServiceConfig
|
||||
type FieldDef = {
|
||||
key: string
|
||||
label: string
|
||||
@ -239,21 +180,13 @@ type FieldDef = {
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
}
|
||||
|
||||
type VendorDef = {
|
||||
key: PushVendorKey
|
||||
key: VendorKey
|
||||
label: string
|
||||
hint: string
|
||||
fields: FieldDef[]
|
||||
}
|
||||
|
||||
type PushConfigState = {
|
||||
schemaVersion: number
|
||||
updatedAt: string
|
||||
vendors: PushVendorsState
|
||||
profiles: PushProfileConfig[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const app = ref<App | null>(null)
|
||||
const services = ref<FeatureService[]>([])
|
||||
@ -261,31 +194,29 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const selectedPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
||||
|
||||
const platformOptions = [
|
||||
{ label: 'Android', value: 'ANDROID' },
|
||||
{ label: 'iOS', value: 'IOS' },
|
||||
{ label: '鸿蒙', value: 'HARMONY' },
|
||||
]
|
||||
|
||||
const vendorOptions: Array<{ label: string; value: PushVendorKey }> = [
|
||||
{ label: '华为', value: 'huawei' },
|
||||
{ label: '小米', value: 'xiaomi' },
|
||||
{ label: 'OPPO', value: 'oppo' },
|
||||
{ label: 'vivo', value: 'vivo' },
|
||||
{ label: '荣耀', value: 'honor' },
|
||||
{ label: '鸿蒙', value: 'harmony' },
|
||||
{ label: 'iOS', value: 'apns' },
|
||||
]
|
||||
function updateViewport() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
const routeTypeOptions = [
|
||||
'IM_MESSAGE',
|
||||
'FRIEND_REQUEST',
|
||||
'SYSTEM_NOTICE',
|
||||
'SERVICE_NOTICE',
|
||||
'MARKETING',
|
||||
'DEFAULT',
|
||||
]
|
||||
const pushConfig = reactive<Required<PushServiceConfig>>({
|
||||
huawei: { appId: '', appSecret: '', category: '' },
|
||||
xiaomi: { appId: '', appKey: '', appSecret: '', channelId: '' },
|
||||
oppo: { appId: '', appKey: '', masterSecret: '', channelId: '' },
|
||||
vivo: { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' },
|
||||
honor: { appId: '', clientId: '', clientSecret: '' },
|
||||
harmony: { appId: '', appSecret: '' },
|
||||
apns: { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false },
|
||||
fcm: { serviceAccountJson: '' },
|
||||
channels: defaultChannels(),
|
||||
routing: defaultRouting(),
|
||||
} as Required<PushServiceConfig>)
|
||||
const originalChannels = ref<PushNotificationChannelConfig[]>([])
|
||||
|
||||
const importanceOptions = [
|
||||
{ label: '最小', value: 'MIN' },
|
||||
@ -293,83 +224,78 @@ const importanceOptions = [
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
{ label: '高', value: 'HIGH' },
|
||||
{ label: '最高', value: 'MAX' },
|
||||
] as const satisfies ReadonlyArray<{ label: string; value: PushImportance }>
|
||||
] as const
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: '低', value: 'LOW' },
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
{ label: '高', value: 'HIGH' },
|
||||
] as const satisfies ReadonlyArray<{ label: string; value: PushPriority }>
|
||||
const routeTypes = [
|
||||
{ type: 'IM_MESSAGE', channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
|
||||
{ type: 'FRIEND_REQUEST', channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
|
||||
{ type: 'SYSTEM_NOTICE', channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
|
||||
] as const
|
||||
|
||||
const interruptionOptions = [
|
||||
{ label: 'Passive', value: 'passive' },
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Time Sensitive', value: 'time-sensitive' },
|
||||
{ label: 'Critical', value: 'critical' },
|
||||
] as const satisfies ReadonlyArray<{ label: string; value: PushInterruptionLevel }>
|
||||
|
||||
const vendors = reactive<PushVendorsState>(createEmptyVendors())
|
||||
const pushConfig = reactive<PushConfigState>({
|
||||
schemaVersion: 2,
|
||||
updatedAt: '',
|
||||
vendors,
|
||||
profiles: [],
|
||||
})
|
||||
const routeRows = computed(() => routeTypes.map(item => ({
|
||||
type: item.type,
|
||||
route: pushConfig.routing[item.type] ?? ensureRoute(item.type, item.channel, item.category, item.priority),
|
||||
})))
|
||||
|
||||
const vendorDefs: VendorDef[] = [
|
||||
{
|
||||
key: 'huawei',
|
||||
label: '华为 HMS',
|
||||
hint: '填写 AppId / AppSecret;Category 用于消息分类(如 IM)。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
{ key: 'category', label: 'Category', placeholder: 'IM' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'xiaomi',
|
||||
label: '小米 MiPush',
|
||||
hint: '这里仅填写厂商凭据。Channel ID 放在 profiles 中按场景单独配置,支持后续补充或删除。',
|
||||
hint: '填写 AppId / AppKey / AppSecret;Android 包名在应用设置中统一配置。Channel ID 为小米通知通道 ID(如 118060)。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
{ key: 'channelId', label: 'Channel ID', placeholder: '118060' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'huawei',
|
||||
label: '华为 HMS',
|
||||
hint: '仅保留厂商账号信息。Category、Channel ID、重要性等由 profiles 负责。',
|
||||
key: 'oppo',
|
||||
label: 'OPPO 推送',
|
||||
hint: '填写 AppId / AppKey / MasterSecret;Channel ID 为 OPPO 推送通道 ID(如 IM)。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'masterSecret', label: 'MasterSecret' },
|
||||
{ key: 'channelId', label: 'Channel ID', placeholder: 'IM' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vivo',
|
||||
label: 'vivo 推送',
|
||||
hint: '填写 AppId / AppKey / AppSecret;Category 用于消息分类(IM 代表 IM 消息);回执 ID 为 vivo 控制台预注册的消息回执标识。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
{ key: 'category', label: 'Category', placeholder: 'IM' },
|
||||
{ key: 'receiptId', label: '回执 ID', placeholder: '4470' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'honor',
|
||||
label: '荣耀 Push',
|
||||
hint: '荣耀账号凭据。业务场景与展示策略在 profiles 中配置。',
|
||||
label: '荣耀推送',
|
||||
hint: '填写 AppId / ClientId / ClientSecret。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'clientId', label: 'ClientId' },
|
||||
{ key: 'clientSecret', label: 'ClientSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'oppo',
|
||||
label: 'OPPO 推送',
|
||||
hint: '仅保留 AppKey / MasterSecret。通道配置与优先级在 profiles 中维护。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'masterSecret', label: 'MasterSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vivo',
|
||||
label: 'vivo 推送',
|
||||
hint: '厂商凭据与业务分类分离。classification 由 profiles 的 Category 驱动。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'harmony',
|
||||
label: '鸿蒙 Push Kit',
|
||||
hint: '鸿蒙厂商凭据。需要的 Category / Channel ID 放到 profiles 中。',
|
||||
hint: '填写 HarmonyOS Push Kit AppId / AppSecret。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
@ -377,13 +303,13 @@ const vendorDefs: VendorDef[] = [
|
||||
},
|
||||
{
|
||||
key: 'apns',
|
||||
label: 'iOS APNs',
|
||||
hint: 'Team ID / Key ID / Bundle ID / 私钥统一放这里。Badge、Thread ID、Interruption Level 放在 profiles 中。',
|
||||
label: 'APNs(iOS)',
|
||||
hint: '填写 Team ID / Key ID / Bundle ID / p8 文件路径。',
|
||||
fields: [
|
||||
{ key: 'teamId', label: 'Team ID' },
|
||||
{ key: 'keyId', label: 'Key ID' },
|
||||
{ key: 'bundleId', label: 'Bundle ID' },
|
||||
{ key: 'privateKey', label: 'Private Key', type: 'textarea', rows: 6, placeholder: '粘贴 .p8 内容' },
|
||||
{ key: 'keyPath', label: 'p8 文件路径' },
|
||||
{ key: 'sandbox', label: 'Sandbox', type: 'switch' },
|
||||
],
|
||||
},
|
||||
@ -392,166 +318,73 @@ const vendorDefs: VendorDef[] = [
|
||||
const pushEnabled = computed(() => services.value.some(
|
||||
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value && s.enabled,
|
||||
))
|
||||
|
||||
function updateViewport() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
function createEmptyVendors(): PushVendorsState {
|
||||
return {
|
||||
huawei: { appId: '', appSecret: '' },
|
||||
xiaomi: { appId: '', appKey: '', appSecret: '' },
|
||||
oppo: { appId: '', appKey: '', masterSecret: '' },
|
||||
vivo: { appId: '', appKey: '', appSecret: '' },
|
||||
honor: { appId: '', clientId: '', clientSecret: '' },
|
||||
harmony: { appId: '', appSecret: '' },
|
||||
apns: { teamId: '', keyId: '', bundleId: '', privateKey: '', sandbox: false },
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyState(): PushConfigState {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '',
|
||||
vendors: createEmptyVendors(),
|
||||
profiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createProfile(vendor: PushVendorKey = 'xiaomi', routeType = ''): PushProfileConfig {
|
||||
return {
|
||||
profileKey: `${vendor}_${routeType || 'default'}_${Date.now()}`,
|
||||
vendor,
|
||||
routeType,
|
||||
enabled: true,
|
||||
channelId: '',
|
||||
category: '',
|
||||
importance: 'DEFAULT',
|
||||
priority: 'DEFAULT',
|
||||
threadIdentifier: '',
|
||||
interruptionLevel: '',
|
||||
badge: true,
|
||||
sound: true,
|
||||
vibration: true,
|
||||
notifyType: 1,
|
||||
version: 1,
|
||||
remark: '',
|
||||
}
|
||||
}
|
||||
|
||||
function vendorModel(vendor: PushVendorKey): Record<string, any> {
|
||||
return vendors[vendor] as Record<string, any>
|
||||
}
|
||||
|
||||
function replaceState(next: PushConfigState) {
|
||||
Object.assign(pushConfig.vendors.huawei, next.vendors.huawei)
|
||||
Object.assign(pushConfig.vendors.xiaomi, next.vendors.xiaomi)
|
||||
Object.assign(pushConfig.vendors.oppo, next.vendors.oppo)
|
||||
Object.assign(pushConfig.vendors.vivo, next.vendors.vivo)
|
||||
Object.assign(pushConfig.vendors.honor, next.vendors.honor)
|
||||
Object.assign(pushConfig.vendors.harmony, next.vendors.harmony)
|
||||
Object.assign(pushConfig.vendors.apns, next.vendors.apns)
|
||||
pushConfig.schemaVersion = next.schemaVersion
|
||||
pushConfig.updatedAt = next.updatedAt
|
||||
pushConfig.profiles.splice(0, pushConfig.profiles.length, ...next.profiles.map(profile => normalizeProfile(profile)))
|
||||
}
|
||||
|
||||
function normalizeProfile(profile: Partial<PushProfileConfig> & Pick<PushProfileConfig, 'vendor'>): PushProfileConfig {
|
||||
return {
|
||||
profileKey: profile.profileKey?.trim() || `${profile.vendor}_${profile.routeType || 'default'}_${Date.now()}`,
|
||||
vendor: profile.vendor,
|
||||
routeType: profile.routeType?.trim() || '',
|
||||
enabled: profile.enabled ?? true,
|
||||
channelId: profile.channelId?.trim() || '',
|
||||
category: profile.category?.trim() || '',
|
||||
importance: profile.importance || 'DEFAULT',
|
||||
priority: profile.priority || 'DEFAULT',
|
||||
threadIdentifier: profile.threadIdentifier?.trim() || '',
|
||||
interruptionLevel: profile.interruptionLevel || '',
|
||||
badge: profile.badge ?? true,
|
||||
sound: profile.sound ?? true,
|
||||
vibration: profile.vibration ?? true,
|
||||
notifyType: Number.isFinite(profile.notifyType as number) ? Math.max(Number(profile.notifyType ?? 1), 0) : 1,
|
||||
version: Math.max(Number(profile.version ?? 1), 1),
|
||||
remark: profile.remark?.trim() || '',
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfig(raw?: string | null): PushConfigState {
|
||||
if (!raw) {
|
||||
return createEmptyState()
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PushConfigState>
|
||||
return {
|
||||
schemaVersion: parsed.schemaVersion ?? 2,
|
||||
updatedAt: parsed.updatedAt ?? '',
|
||||
vendors: {
|
||||
...createEmptyVendors(),
|
||||
...parsed.vendors,
|
||||
huawei: { ...createEmptyVendors().huawei, ...(parsed.vendors?.huawei ?? {}) },
|
||||
xiaomi: { ...createEmptyVendors().xiaomi, ...(parsed.vendors?.xiaomi ?? {}) },
|
||||
oppo: { ...createEmptyVendors().oppo, ...(parsed.vendors?.oppo ?? {}) },
|
||||
vivo: { ...createEmptyVendors().vivo, ...(parsed.vendors?.vivo ?? {}) },
|
||||
honor: { ...createEmptyVendors().honor, ...(parsed.vendors?.honor ?? {}) },
|
||||
harmony: { ...createEmptyVendors().harmony, ...(parsed.vendors?.harmony ?? {}) },
|
||||
apns: { ...createEmptyVendors().apns, ...(parsed.vendors?.apns ?? {}) },
|
||||
},
|
||||
profiles: Array.isArray(parsed.profiles)
|
||||
? parsed.profiles.map(profile => normalizeProfile(profile as Partial<PushProfileConfig> & Pick<PushProfileConfig, 'vendor'>))
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
return createEmptyState()
|
||||
}
|
||||
}
|
||||
const servicePlatform = computed(() => selectedPlatform.value)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const id = route.params.appKey as string
|
||||
const [appRes, svcRes] = await Promise.all([
|
||||
appApi.get(id),
|
||||
appApi.getServices(id),
|
||||
])
|
||||
app.value = appRes.data.data
|
||||
services.value = svcRes.data.data
|
||||
const firstPushService = services.value.find(s => s.serviceType === 'PUSH')
|
||||
if (firstPushService && !services.value.some(s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value)) {
|
||||
selectedPlatform.value = firstPushService.platform
|
||||
}
|
||||
applySelectedPlatformConfig()
|
||||
} finally {
|
||||
loading.value = false
|
||||
const id = route.params.appKey as string
|
||||
const [appRes, svcRes] = await Promise.all([
|
||||
appApi.get(id),
|
||||
appApi.getServices(id),
|
||||
])
|
||||
app.value = appRes.data.data
|
||||
services.value = svcRes.data.data
|
||||
const firstPushService = services.value.find(s => s.serviceType === 'PUSH')
|
||||
if (firstPushService && !services.value.some(s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value)) {
|
||||
selectedPlatform.value = firstPushService.platform
|
||||
}
|
||||
applySelectedPlatformConfig()
|
||||
}
|
||||
|
||||
function applySelectedPlatformConfig() {
|
||||
const raw = services.value.find(
|
||||
applyConfig(services.value.find(
|
||||
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value,
|
||||
)?.config
|
||||
replaceState(parseConfig(raw))
|
||||
)?.config)
|
||||
}
|
||||
|
||||
function applyConfig(raw?: string | null) {
|
||||
resetPushConfig()
|
||||
const parsed = parseConfig(raw)
|
||||
pushConfig.huawei = { ...pushConfig.huawei, ...parsed.huawei }
|
||||
pushConfig.xiaomi = { ...pushConfig.xiaomi, ...parsed.xiaomi }
|
||||
pushConfig.oppo = { ...pushConfig.oppo, ...parsed.oppo }
|
||||
pushConfig.vivo = { ...pushConfig.vivo, ...parsed.vivo }
|
||||
pushConfig.honor = { ...pushConfig.honor, ...parsed.honor }
|
||||
pushConfig.harmony = { ...pushConfig.harmony, ...parsed.harmony }
|
||||
pushConfig.apns = { ...pushConfig.apns, ...parsed.apns }
|
||||
pushConfig.fcm = { ...pushConfig.fcm, ...parsed.fcm }
|
||||
pushConfig.channels = normalizeChannels(parsed.channels)
|
||||
pushConfig.routing = normalizeRouting(parsed.routing)
|
||||
originalChannels.value = pushConfig.channels.map(channel => ({ ...channel }))
|
||||
}
|
||||
|
||||
function resetPushConfig() {
|
||||
pushConfig.huawei = { appId: '', appSecret: '', category: '' }
|
||||
pushConfig.xiaomi = { appId: '', appKey: '', appSecret: '', channelId: '' }
|
||||
pushConfig.oppo = { appId: '', appKey: '', masterSecret: '', channelId: '' }
|
||||
pushConfig.vivo = { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' }
|
||||
pushConfig.honor = { appId: '', clientId: '', clientSecret: '' }
|
||||
pushConfig.harmony = { appId: '', appSecret: '' }
|
||||
pushConfig.apns = { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false }
|
||||
pushConfig.fcm = { serviceAccountJson: '' }
|
||||
pushConfig.channels = defaultChannels()
|
||||
pushConfig.routing = defaultRouting()
|
||||
}
|
||||
|
||||
function parseConfig(raw?: string | null): PushServiceConfig {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
return JSON.parse(raw) as PushServiceConfig
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!app.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: PushServiceConfig = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: new Date().toISOString(),
|
||||
vendors: {
|
||||
huawei: { ...pushConfig.vendors.huawei },
|
||||
xiaomi: { ...pushConfig.vendors.xiaomi },
|
||||
oppo: { ...pushConfig.vendors.oppo },
|
||||
vivo: { ...pushConfig.vendors.vivo },
|
||||
honor: { ...pushConfig.vendors.honor },
|
||||
harmony: { ...pushConfig.vendors.harmony },
|
||||
apns: { ...pushConfig.vendors.apns },
|
||||
},
|
||||
profiles: pushConfig.profiles.map(profile => normalizeProfile(profile)),
|
||||
}
|
||||
await appApi.updateServiceConfig(app.value.id, selectedPlatform.value, 'PUSH', payload)
|
||||
bumpChangedChannelVersions()
|
||||
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', toPushConfigRequest())
|
||||
ElMessage.success('推送配置已保存')
|
||||
await loadData()
|
||||
} catch {
|
||||
@ -561,12 +394,143 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function addProfile(vendor: PushVendorKey = 'xiaomi') {
|
||||
pushConfig.profiles.push(createProfile(vendor))
|
||||
function toPushConfigRequest(): Record<string, unknown> {
|
||||
return {
|
||||
huaweiAppId: pushConfig.huawei.appId ?? '',
|
||||
huaweiAppSecret: pushConfig.huawei.appSecret ?? '',
|
||||
huaweiCategory: pushConfig.huawei.category ?? '',
|
||||
xiaomiAppId: pushConfig.xiaomi.appId ?? '',
|
||||
xiaomiAppKey: pushConfig.xiaomi.appKey ?? '',
|
||||
xiaomiAppSecret: pushConfig.xiaomi.appSecret ?? '',
|
||||
xiaomiChannelId: pushConfig.xiaomi.channelId ?? '',
|
||||
oppoAppId: pushConfig.oppo.appId ?? '',
|
||||
oppoAppKey: pushConfig.oppo.appKey ?? '',
|
||||
oppoMasterSecret: pushConfig.oppo.masterSecret ?? '',
|
||||
oppoChannelId: pushConfig.oppo.channelId ?? '',
|
||||
vivoAppId: pushConfig.vivo.appId ?? '',
|
||||
vivoAppKey: pushConfig.vivo.appKey ?? '',
|
||||
vivoAppSecret: pushConfig.vivo.appSecret ?? '',
|
||||
vivoCategory: pushConfig.vivo.category ?? '',
|
||||
vivoReceiptId: pushConfig.vivo.receiptId ?? '',
|
||||
honorAppId: pushConfig.honor.appId ?? '',
|
||||
honorClientId: pushConfig.honor.clientId ?? '',
|
||||
honorClientSecret: pushConfig.honor.clientSecret ?? '',
|
||||
harmonyAppId: pushConfig.harmony.appId ?? '',
|
||||
harmonyAppSecret: pushConfig.harmony.appSecret ?? '',
|
||||
apnsTeamId: pushConfig.apns.teamId ?? '',
|
||||
apnsKeyId: pushConfig.apns.keyId ?? '',
|
||||
apnsBundleId: pushConfig.apns.bundleId ?? '',
|
||||
apnsKeyPath: pushConfig.apns.keyPath ?? '',
|
||||
apnsSandbox: pushConfig.apns.sandbox ?? false,
|
||||
fcmServiceAccountJson: pushConfig.fcm.serviceAccountJson ?? '',
|
||||
channels: pushConfig.channels,
|
||||
routing: pushConfig.routing,
|
||||
}
|
||||
}
|
||||
|
||||
function removeProfile(index: number) {
|
||||
pushConfig.profiles.splice(index, 1)
|
||||
function defaultChannels(): PushNotificationChannelConfig[] {
|
||||
return [
|
||||
{
|
||||
key: 'im_message',
|
||||
channelId: 'xuqm_im_message',
|
||||
version: 1,
|
||||
name: '聊天消息',
|
||||
description: '单聊、群聊和好友消息',
|
||||
importance: 'HIGH',
|
||||
sound: true,
|
||||
vibration: true,
|
||||
badge: true,
|
||||
},
|
||||
{
|
||||
key: 'system_notice',
|
||||
channelId: 'xuqm_system_notice',
|
||||
version: 1,
|
||||
name: '系统通知',
|
||||
description: '系统通知和业务提醒',
|
||||
importance: 'DEFAULT',
|
||||
sound: true,
|
||||
vibration: true,
|
||||
badge: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function defaultRouting(): Record<string, PushNotificationRouteConfig> {
|
||||
return {
|
||||
IM_MESSAGE: { channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
|
||||
FRIEND_REQUEST: { channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
|
||||
SYSTEM_NOTICE: { channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChannels(channels?: PushNotificationChannelConfig[]): PushNotificationChannelConfig[] {
|
||||
const source = Array.isArray(channels) && channels.length > 0 ? channels : defaultChannels()
|
||||
return source.map((channel, index) => ({
|
||||
key: channel.key || `channel_${index + 1}`,
|
||||
channelId: channel.channelId || `xuqm_channel_${index + 1}`,
|
||||
version: Math.max(Number(channel.version || 1), 1),
|
||||
name: channel.name || channel.key || `通知通道 ${index + 1}`,
|
||||
description: channel.description ?? '',
|
||||
importance: channel.importance || 'DEFAULT',
|
||||
sound: channel.sound ?? true,
|
||||
vibration: channel.vibration ?? true,
|
||||
badge: channel.badge ?? true,
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeRouting(routing?: Record<string, PushNotificationRouteConfig>): Record<string, PushNotificationRouteConfig> {
|
||||
return { ...defaultRouting(), ...(routing ?? {}) }
|
||||
}
|
||||
|
||||
function ensureRoute(
|
||||
type: string,
|
||||
channel: string,
|
||||
category: string,
|
||||
priority: PushNotificationRouteConfig['priority'],
|
||||
): PushNotificationRouteConfig {
|
||||
const route = { channel, category, priority }
|
||||
pushConfig.routing[type] = route
|
||||
return route
|
||||
}
|
||||
|
||||
function addChannel() {
|
||||
const next = pushConfig.channels.length + 1
|
||||
pushConfig.channels.push({
|
||||
key: `custom_${next}`,
|
||||
channelId: `xuqm_custom_${next}`,
|
||||
version: 1,
|
||||
name: `自定义通道 ${next}`,
|
||||
description: '',
|
||||
importance: 'DEFAULT',
|
||||
sound: true,
|
||||
vibration: true,
|
||||
badge: true,
|
||||
})
|
||||
}
|
||||
|
||||
function removeChannel(index: number) {
|
||||
const [removed] = pushConfig.channels.splice(index, 1)
|
||||
if (!removed) return
|
||||
Object.values(pushConfig.routing).forEach(route => {
|
||||
if (route.channel === removed.key) {
|
||||
route.channel = pushConfig.channels[0]?.key ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function bumpChangedChannelVersions() {
|
||||
const originalByKey = new Map(originalChannels.value.map(channel => [channel.key, channel]))
|
||||
pushConfig.channels.forEach(channel => {
|
||||
const original = originalByKey.get(channel.key)
|
||||
if (!original) return
|
||||
const immutableChanged = original.importance !== channel.importance
|
||||
|| original.sound !== channel.sound
|
||||
|| original.vibration !== channel.vibration
|
||||
|| original.badge !== channel.badge
|
||||
if (immutableChanged && channel.version <= original.version) {
|
||||
channel.version = original.version + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onTogglePushService(enable: boolean) {
|
||||
@ -580,7 +544,7 @@ async function onTogglePushService(enable: boolean) {
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
if (!app.value) return
|
||||
await appApi.toggleService(app.value.id, selectedPlatform.value, 'PUSH', false)
|
||||
await appApi.toggleService(app.value.id, servicePlatform.value, 'PUSH', false)
|
||||
ElMessage.success('已关闭')
|
||||
await loadData()
|
||||
}
|
||||
@ -600,20 +564,11 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.mono { font-family: monospace; font-size: 12px; }
|
||||
.hint { font-size: 12px; color: #909399; }
|
||||
.info-card :deep(.el-descriptions__body) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.push-switch-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
@ -621,38 +576,20 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vendor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vendor-card {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.vendor-hint {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profiles-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profiles-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
@ -660,12 +597,6 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.profiles-table :deep(.el-select),
|
||||
.profiles-table :deep(.el-input),
|
||||
.profiles-table :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.vendor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@ -676,8 +607,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toolbar :deep(.el-button),
|
||||
.profiles-actions :deep(.el-button) {
|
||||
.toolbar :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div>
|
||||
<div v-if="isServicesPortal" class="portal-bar">
|
||||
<span class="portal-bar-title">版本管理</span>
|
||||
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
|
||||
@ -46,36 +41,27 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="应用商店" width="220" show-overflow-tooltip>
|
||||
<template #default="{row}">
|
||||
<div v-if="parseStoreReview(row.storeReviewStatus).length" class="store-review-cell">
|
||||
<div class="store-review-tags">
|
||||
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
|
||||
<el-tooltip
|
||||
v-if="item.state === 'REJECTED' && item.reason"
|
||||
:content="item.reason"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag
|
||||
:type="reviewTagType(item.state)"
|
||||
size="small"
|
||||
style="margin:2px"
|
||||
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
||||
</el-tooltip>
|
||||
<template v-if="parseStoreReview(row.storeReviewStatus).length">
|
||||
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
|
||||
<el-tooltip
|
||||
v-if="item.state === 'REJECTED' && item.reason"
|
||||
:content="item.reason"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag
|
||||
v-else
|
||||
:type="reviewTagType(item.state)"
|
||||
size="small"
|
||||
style="margin:2px"
|
||||
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
||||
</template>
|
||||
</div>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
class="store-review-detail-btn"
|
||||
@click="openStoreReviewDetail(row)"
|
||||
>查看详情</el-button>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<el-tag
|
||||
v-else
|
||||
:type="reviewTagType(item.state)"
|
||||
size="small"
|
||||
style="margin:2px"
|
||||
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -494,81 +480,6 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Store Review Detail Dialog -->
|
||||
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="dialogWidth">
|
||||
<div v-if="storeReviewDetailVersion">
|
||||
<el-descriptions :column="1" border style="margin-bottom:16px">
|
||||
<el-descriptions-item label="版本">
|
||||
{{ storeReviewDetailVersion.versionName }} · {{ storeReviewDetailVersion.versionCode }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发布状态">
|
||||
<el-tag :type="statusTagType(storeReviewDetailVersion)" size="small">
|
||||
{{ statusLabel(storeReviewDetailVersion) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="市场提交目标">
|
||||
<span v-if="parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).length">
|
||||
{{ parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).map(storeLabel).join('、') }}
|
||||
</span>
|
||||
<span v-else class="text-muted">未配置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上传时间">
|
||||
{{ formatTime(storeReviewDetailVersion.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table :data="storeReviewDetailItems" border stripe>
|
||||
<el-table-column prop="store" label="市场" width="150">
|
||||
<template #default="{row}">{{ storeLabel(row.store) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="state" label="状态" width="120">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="reviewTagType(row.state)" size="small">
|
||||
{{ reviewLabel(row.state) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stage" label="阶段" width="120">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.stage" type="info" size="small">{{ row.stage }}</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submittedAt" label="提交时间" width="180">
|
||||
<template #default="{row}">
|
||||
<span>{{ row.submittedAt ? formatTime(row.submittedAt) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batchId" label="批次 ID" width="220" show-overflow-tooltip>
|
||||
<template #default="{row}">
|
||||
<span>{{ row.batchId || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="失败原因 / 说明" min-width="260" show-overflow-tooltip>
|
||||
<template #default="{row}">
|
||||
<el-text v-if="row.reason" :type="row.state === 'REJECTED' ? 'danger' : 'default'">
|
||||
{{ row.reason }}
|
||||
</el-text>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-alert
|
||||
v-if="storeReviewDetailItems.some(item => item.state === 'REJECTED')"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-top:16px"
|
||||
title="存在审核失败的市场,请查看上方失败原因。"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-else description="暂无商店状态数据" />
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="showStoreReviewDetail = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Store Credential Config Dialog -->
|
||||
<el-dialog
|
||||
v-model="showStoreConfig"
|
||||
@ -746,20 +657,12 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Global Drag Drop Overlay -->
|
||||
<div v-if="isDraggingOver" class="drag-overlay">
|
||||
<div class="drag-overlay-content">
|
||||
<el-icon size="64"><UploadFilled /></el-icon>
|
||||
<p class="drag-overlay-title">释放文件以上传</p>
|
||||
<p class="drag-overlay-hint">支持 .apk、.bundle、.js</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
@ -778,7 +681,6 @@ import {
|
||||
type StoreConfig,
|
||||
type StoreType,
|
||||
} from '@/api/update'
|
||||
import { connectStoreReviewRealtime, disconnectStoreReviewRealtime } from '@/services/storeReviewRealtime'
|
||||
import huaweiGuideImage from '@/assets/update-store/huawei/01.png'
|
||||
import miGuideImage from '@/assets/update-store/mi/01.png'
|
||||
import oppoGuideImage from '@/assets/update-store/oppo/01.png'
|
||||
@ -795,54 +697,6 @@ const pageTitle = computed(() => app.value?.name ?? appKey)
|
||||
const isMobile = ref(false)
|
||||
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '920px'))
|
||||
|
||||
const isDraggingOver = ref(false)
|
||||
let dragCounter = 0
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragCounter++
|
||||
isDraggingOver.value = true
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
dragCounter--
|
||||
if (dragCounter <= 0) {
|
||||
isDraggingOver.value = false
|
||||
dragCounter = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
isDraggingOver.value = false
|
||||
dragCounter = 0
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (!files.length) return
|
||||
|
||||
const file = files[0]
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||
|
||||
if (ext === '.apk') {
|
||||
openUploadAppDialog()
|
||||
await nextTick()
|
||||
await onAppPackageChange({ raw: file })
|
||||
} else if (ext === '.bundle' || ext === '.js') {
|
||||
showUploadRn.value = true
|
||||
await nextTick()
|
||||
await onRnBundleChange({ raw: file })
|
||||
} else {
|
||||
ElMessage.warning(`不支持的文件类型:${file.name},请拖入 .apk、.bundle 或 .js 文件`)
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = ref('app')
|
||||
const storeTab = ref<'configs' | 'guide'>('configs')
|
||||
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
||||
@ -888,7 +742,6 @@ const operationLogs = ref<{
|
||||
createdAt: string
|
||||
}[]>([])
|
||||
const loadingOperationLogs = ref(false)
|
||||
let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
|
||||
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
|
||||
@ -1282,10 +1135,6 @@ const selectedStores = ref<StoreType[]>([])
|
||||
const submitStoreMode = ref<PublishMode>('MANUAL')
|
||||
const submitStoreScheduledAt = ref('')
|
||||
|
||||
const showStoreReviewDetail = ref(false)
|
||||
const storeReviewDetailVersion = ref<AppVersion | null>(null)
|
||||
const storeReviewDetailItems = ref<{ store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[]>([])
|
||||
|
||||
function openSubmitStoreDialog(row: AppVersion) {
|
||||
submitStoreVersion.value = row
|
||||
selectedStores.value = enabledStores.value.map(s => s.type)
|
||||
@ -1294,22 +1143,6 @@ function openSubmitStoreDialog(row: AppVersion) {
|
||||
showSubmitStore.value = true
|
||||
}
|
||||
|
||||
function parseStoreTargets(json?: string) {
|
||||
if (!json) return []
|
||||
try {
|
||||
const value = JSON.parse(json)
|
||||
return Array.isArray(value) ? value.map(item => String(item)) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function openStoreReviewDetail(row: AppVersion) {
|
||||
storeReviewDetailVersion.value = row
|
||||
storeReviewDetailItems.value = parseStoreReview(row.storeReviewStatus)
|
||||
showStoreReviewDetail.value = true
|
||||
}
|
||||
|
||||
async function confirmSubmitToStores() {
|
||||
if (!submitStoreVersion.value || !selectedStores.value.length) {
|
||||
ElMessage.warning('请选择至少一个可用市场')
|
||||
@ -1828,11 +1661,11 @@ function storeLabel(type: string) {
|
||||
}
|
||||
|
||||
function reviewLabel(state: string): string {
|
||||
return { PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
|
||||
return { PENDING: '待提交', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
|
||||
}
|
||||
|
||||
function reviewTagType(state: string): string {
|
||||
return { PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
|
||||
return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
|
||||
}
|
||||
|
||||
function operationResourceLabel(resourceType: string) {
|
||||
@ -1852,15 +1685,6 @@ function operationActionLabel(action: string) {
|
||||
SAVE_DRAFT: '保存草稿',
|
||||
UNPUBLISH: '下架',
|
||||
STORE_SUBMIT: '提交市场',
|
||||
STORE_SUBMIT_REQUEST: '提交市场请求',
|
||||
STORE_SUBMIT_BATCH_START: '市场提交开始',
|
||||
STORE_SUBMIT_BATCH_END: '市场提交结束',
|
||||
STORE_SUBMIT_BATCH_FAILED: '市场提交失败',
|
||||
STORE_SUBMIT_BATCH_SKIPPED: '市场提交跳过',
|
||||
STORE_SUBMIT_STORE_START: '市场提交开始',
|
||||
STORE_SUBMIT_STORE_STAGE: '市场提交阶段',
|
||||
STORE_SUBMIT_STORE_SUCCESS: '市场提交成功',
|
||||
STORE_SUBMIT_STORE_FAILED: '市场提交失败',
|
||||
STORE_REVIEW: '审核回写',
|
||||
GRAY_UPDATE: '灰度配置',
|
||||
AUTO_PUBLISH: '自动发布',
|
||||
@ -1898,17 +1722,7 @@ async function loadOperationLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStoreReviewReload() {
|
||||
if (storeReviewReloadTimer) {
|
||||
clearTimeout(storeReviewReloadTimer)
|
||||
}
|
||||
storeReviewReloadTimer = setTimeout(() => {
|
||||
void loadAppVersions()
|
||||
void loadOperationLogs()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function parseStoreReview(json?: string): { store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[] {
|
||||
function parseStoreReview(json?: string): { store: string; state: string; reason?: string }[] {
|
||||
if (!json) return []
|
||||
try {
|
||||
const m = JSON.parse(json) as Record<string, unknown>
|
||||
@ -1922,10 +1736,6 @@ function parseStoreReview(json?: string): { store: string; state: string; reason
|
||||
store,
|
||||
state: String(item.state ?? ''),
|
||||
reason: String(item.reason ?? ''),
|
||||
stage: String(item.stage ?? ''),
|
||||
submittedAt: String(item.submittedAt ?? ''),
|
||||
updatedAt: String(item.updatedAt ?? ''),
|
||||
batchId: String(item.batchId ?? ''),
|
||||
}
|
||||
}
|
||||
return { store, state: String(value ?? ''), reason: '' }
|
||||
@ -1975,22 +1785,10 @@ onMounted(() => {
|
||||
loadStoreConfigs()
|
||||
loadPublishConfig()
|
||||
loadOperationLogs()
|
||||
void connectStoreReviewRealtime(appKey, () => {
|
||||
scheduleStoreReviewReload()
|
||||
}).catch((error) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[tenant-platform] store review realtime unavailable', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateViewport)
|
||||
disconnectStoreReviewRealtime()
|
||||
if (storeReviewReloadTimer) {
|
||||
clearTimeout(storeReviewReloadTimer)
|
||||
storeReviewReloadTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -2215,24 +2013,6 @@ onBeforeUnmount(() => {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.store-review-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.store-review-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-review-detail-btn {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.store-grid,
|
||||
.guide-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@ -2266,36 +2046,4 @@ onBeforeUnmount(() => {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drag-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.drag-overlay-content {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 16px;
|
||||
padding: 48px 64px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.drag-overlay-title {
|
||||
margin-top: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.drag-overlay-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1172,11 +1172,6 @@
|
||||
dependencies:
|
||||
vue "^3.5.13"
|
||||
|
||||
"@xuqm/vue3-sdk@0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://nexus.xuqinmin.com/repository/npm-hosted/@xuqm/vue3-sdk/-/vue3-sdk-0.2.0.tgz#f527250cb1b3b944920133d694866432709559bc"
|
||||
integrity sha512-JlvaAVkxXpgawVCR1fGDVaeDJeOsk3XKoKLOFpPw3zAokkWL3cvGhXt2RWHiZ89Fwj5+VRFzSeJNx3DesmPDmw==
|
||||
|
||||
abbrev@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz"
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户