比较提交
2 次代码提交
55826db8c4
...
c11e8f6d71
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
c11e8f6d71 | ||
|
|
168bf4662c |
10
.dockerignore
普通文件
10
.dockerignore
普通文件
@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.idea
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
docs-site/docs/.vitepress/dist
|
||||||
|
**/.DS_Store
|
||||||
|
**/*.log
|
||||||
|
*.iml
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
@ -7,7 +8,10 @@ COPY tenant-platform ./tenant-platform
|
|||||||
COPY ops-platform ./ops-platform
|
COPY ops-platform ./ops-platform
|
||||||
COPY docs-site ./docs-site
|
COPY docs-site ./docs-site
|
||||||
|
|
||||||
RUN yarn install --frozen-lockfile
|
ENV YARN_CACHE_FOLDER=/var/cache/yarn
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/var/cache/yarn,sharing=locked \
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
|
||||||
ARG TENANT_APP_BASE=/
|
ARG TENANT_APP_BASE=/
|
||||||
ARG OPS_APP_BASE=/ops/
|
ARG OPS_APP_BASE=/ops/
|
||||||
|
|||||||
4
Jenkinsfile
vendored
4
Jenkinsfile
vendored
@ -15,6 +15,7 @@ pipeline {
|
|||||||
PROD_USER = 'ubuntu'
|
PROD_USER = 'ubuntu'
|
||||||
COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml'
|
COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml'
|
||||||
IMAGE_NAME = 'web'
|
IMAGE_NAME = 'web'
|
||||||
|
DOCKER_BUILDKIT = '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
@ -29,7 +30,8 @@ pipeline {
|
|||||||
def fullImage = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${IMAGE_NAME}:${params.IMAGE_TAG}"
|
def fullImage = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${IMAGE_NAME}:${params.IMAGE_TAG}"
|
||||||
bat """
|
bat """
|
||||||
docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p %ACR_PASS%
|
docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p %ACR_PASS%
|
||||||
docker build -t ${fullImage} .
|
docker pull ${fullImage} || exit 0
|
||||||
|
docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${fullImage} -t ${fullImage} .
|
||||||
docker push ${fullImage}
|
docker push ${fullImage}
|
||||||
docker rmi ${fullImage}
|
docker rmi ${fullImage}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,249 +1,280 @@
|
|||||||
# Server API 参考
|
# Server API 参考
|
||||||
|
|
||||||
|
XuqmGroup 的服务端能力分成三类:
|
||||||
|
|
||||||
|
- `IM 服务`:账号、消息、群组、好友、会话、黑名单、管理端、回调
|
||||||
|
- `Push 服务`:设备注册、离线推送、诊断、管理端
|
||||||
|
- `Update 服务`:应用版本、RN Bundle、应用商店提审、发布配置、操作日志
|
||||||
|
|
||||||
**Base URL**:`https://dev.xuqinmin.com`
|
**Base URL**:`https://dev.xuqinmin.com`
|
||||||
|
|
||||||
所有 API 通过 Nginx 反代,无需区分内部端口。
|
所有接口都通过 Nginx 反代,无需关心内部端口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 认证
|
## 认证
|
||||||
|
|
||||||
除 `/api/im/auth/login` 外,所有 IM 接口需在请求头携带 JWT Token:
|
### IM 服务
|
||||||
|
|
||||||
```
|
- `POST /api/im/auth/login` 之前的所有请求,客户端和服务端均需携带 `Authorization: Bearer <im_jwt>`
|
||||||
Authorization: Bearer <token>
|
- `im_jwt` 通过 `POST /api/im/auth/login` 颁发
|
||||||
```
|
- 登录请求需要 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`
|
||||||
|
|
||||||
Token 由 `/api/im/auth/login` 接口签发。当前 IM 登录不做过期功能,只校验 `userId + UserSig` 是否匹配。
|
### Push / Update / Tenant 相关服务
|
||||||
|
|
||||||
|
- 业务侧调用通常使用 `appKey`
|
||||||
|
- 管理端接口需要租户或运营平台的 JWT
|
||||||
|
- 内部接口统一使用 `X-Internal-Token`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## IM 服务(/api/im/)
|
## IM 服务
|
||||||
|
|
||||||
### 登录 · 获取 Token
|
### 账号与登录
|
||||||
|
|
||||||
```
|
| 方法 | 路径 | 说明 |
|
||||||
POST /api/im/auth/login
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query 参数**
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `appId` | 是 | 应用 ID |
|
| `POST` | `/api/im/auth/login` | IM 登录,返回 JWT Token |
|
||||||
| `userId` | 是 | 用户 ID |
|
| `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` | 检查账号是否存在 |
|
||||||
|
|
||||||
**请求头**
|
### 消息
|
||||||
|
|
||||||
| 头 | 必填 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|----|------|------|
|
|------|------|------|
|
||||||
| `X-App-Timestamp` | 是 | 当前时间戳(毫秒) |
|
| `POST` | `/api/im/messages/send` | 发送消息 |
|
||||||
| `X-App-Nonce` | 是 | 随机字符串 |
|
| `POST` | `/api/im/messages/{id}/revoke` | 撤回消息 |
|
||||||
| `X-App-Signature` | 是 | HmacSHA256 签名 |
|
| `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` | 同步离线消息 |
|
||||||
|
|
||||||
**签名 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..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
> 重复登录会覆盖当前会话;SDK 侧不做生命周期检测或旧登录兼容。
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `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}` | 查询用户在线状态 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 发送消息
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
"toId": "user_002",
|
"code": 200,
|
||||||
"chatType": "SINGLE",
|
"status": "0",
|
||||||
"msgType": "TEXT",
|
"data": { },
|
||||||
"content": "Hello!"
|
"message": "success"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`chatType`:`SINGLE` | `GROUP`
|
### 错误码
|
||||||
|
|
||||||
`msgType`:`TEXT` | `IMAGE` | `VIDEO` | `AUDIO` | `FILE` | `CUSTOM` | `LOCATION` | `NOTIFY` | `RICH_TEXT` | `CALL_AUDIO` | `CALL_VIDEO` | `FORWARD`
|
| HTTP 状态 | code | 含义 |
|
||||||
|
|
||||||
**响应**:`ImMessage` 对象
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 撤回消息
|
|
||||||
|
|
||||||
```
|
|
||||||
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 | 请求参数错误 |
|
| `400` | `400` | 参数错误 |
|
||||||
| 401 | 401 | Token 无效或签名验证失败 |
|
| `401` | `401` | 鉴权失败 |
|
||||||
| 403 | 403 | 无权限操作(如撤回他人消息)|
|
| `403` | `403` | 无权限 |
|
||||||
| 404 | 404 | 资源不存在 |
|
| `404` | `404` | 资源不存在 |
|
||||||
| 500 | 500 | 服务器内部错误 |
|
| `500` | `500` | 服务端内部错误 |
|
||||||
|
|
||||||
错误响应格式:
|
---
|
||||||
|
|
||||||
```json
|
## 集成建议
|
||||||
{ "code": 403, "message": "只能撤回自己发送的消息" }
|
|
||||||
```
|
- 服务端业务代码统一使用 Java / Go / Python Server SDK 调用上述 REST 接口
|
||||||
|
- 客户端统一使用 `appKey` 作为应用上下文
|
||||||
|
- 实时刷新场景建议使用 IM SDK 订阅服务端事件
|
||||||
|
- 更新、Push、IM 的后台能力可以独立接入,也可以组合使用
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
# Go Server SDK
|
# Go Server SDK
|
||||||
|
|
||||||
XuqmGroup 服务端 Go SDK,提供 IM 消息发送、群管理、Push 推送等能力。
|
XuqmGroup 服务端 Go SDK,按照腾讯云服务端 API 的分类方式封装了:
|
||||||
|
|
||||||
|
- IM 账号、消息、群组、好友、会话、黑名单
|
||||||
|
- 管理端 Webhook、统计、操作日志
|
||||||
|
- Push 注册与推送
|
||||||
|
- Update 版本管理、RN Bundle、应用商店提审
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go
|
go get github.com/xuqmgroup/xuqmgroup-server-sdk-go
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -15,19 +20,40 @@ go get xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go
|
|||||||
## 初始化
|
## 初始化
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import xuqmsdk "xuqinmin.com/xuqinmin12/XuqmGroup-ServerSDK-Go"
|
import xuqmsdk "github.com/xuqmgroup/xuqmgroup-server-sdk-go"
|
||||||
|
|
||||||
client := xuqmsdk.NewClient(xuqmsdk.Config{
|
client, err := xuqmsdk.NewClient(xuqmsdk.ClientConfig{
|
||||||
BaseURL: "https://dev.xuqinmin.com",
|
BaseURL: "https://dev.xuqinmin.com",
|
||||||
AppID: "your_app_id",
|
AppKey: "your_app_key",
|
||||||
AppSecret: "your_app_secret",
|
AppSecret: "your_app_secret",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。
|
> 服务端 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
|
```go
|
||||||
@ -65,22 +91,15 @@ err := client.RevokeMessage("message_id")
|
|||||||
## 群管理
|
## 群管理
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 创建群组
|
group, err := client.CreateGroup("项目讨论", []string{"user_001", "user_002"}, "NORMAL")
|
||||||
group, err := client.CreateGroup(xuqmsdk.CreateGroupRequest{
|
if err != nil {
|
||||||
Name: "项目讨论",
|
log.Fatal(err)
|
||||||
MemberIDs: []string{"user_001", "user_002"},
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// 添加群成员
|
|
||||||
err = client.AddGroupMember("group_xxx", "user_003")
|
err = client.AddGroupMember("group_xxx", "user_003")
|
||||||
|
|
||||||
// 移除群成员
|
|
||||||
err = client.RemoveGroupMember("group_xxx", "user_003")
|
err = client.RemoveGroupMember("group_xxx", "user_003")
|
||||||
|
|
||||||
// 获取群列表
|
|
||||||
groups, err := client.ListGroups()
|
groups, err := client.ListGroups()
|
||||||
|
|
||||||
// 获取群成员
|
|
||||||
members, err := client.ListGroupMembers("group_xxx")
|
members, err := client.ListGroupMembers("group_xxx")
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -89,12 +108,7 @@ members, err := client.ListGroupMembers("group_xxx")
|
|||||||
## Push 推送
|
## Push 推送
|
||||||
|
|
||||||
```go
|
```go
|
||||||
err := client.SendPush(xuqmsdk.SendPushRequest{
|
err := client.SendPush("user_001", "新消息", "你有一条未读消息", `{"chatId":"user_002"}`)
|
||||||
UserID: "user_001",
|
|
||||||
Title: "新消息",
|
|
||||||
Body: "你有一条未读消息",
|
|
||||||
Payload: map[string]string{"chatId": "user_002"},
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -106,11 +120,10 @@ SDK 返回的错误实现了标准 `error` 接口。业务方可根据错误类
|
|||||||
```go
|
```go
|
||||||
msg, err := client.SendMessage(req)
|
msg, err := client.SendMessage(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 可断言为 *xuqmsdk.APIError 获取 HTTP 状态码和详细错误信息
|
|
||||||
if apiErr, ok := err.(*xuqmsdk.APIError); ok {
|
if apiErr, ok := err.(*xuqmsdk.APIError); ok {
|
||||||
log.Printf("API 错误: status=%d, code=%d, message=%s", apiErr.Status, apiErr.Code, apiErr.Message)
|
log.Printf("API 错误: status=%d, code=%d, message=%s", apiErr.Status, apiErr.Code, apiErr.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
[→ Server API 文档 →](/server/api)
|
[→ Server API 文档](/server/api)
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
# Java Server SDK
|
# Java Server SDK
|
||||||
|
|
||||||
XuqmGroup 服务端 Java SDK,提供 IM 消息发送、群管理、Push 推送等能力。
|
XuqmGroup 服务端 Java SDK,按腾讯云服务端 API 的思路封装了以下能力:
|
||||||
|
|
||||||
|
- IM 账号与登录
|
||||||
|
- 消息发送、编辑、撤回、历史查询
|
||||||
|
- 好友、黑名单、会话、群组
|
||||||
|
- Webhook、管理端操作、统计
|
||||||
|
- Push 注册与发送
|
||||||
|
- Update 版本检查、上传、发布、灰度、应用商店提审
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -10,14 +17,14 @@ Maven 在 `pom.xml` 中添加:
|
|||||||
|
|
||||||
```xml
|
```xml
|
||||||
<repository>
|
<repository>
|
||||||
<id>nexus-xuqm</id>
|
<id>xuqm-nexus</id>
|
||||||
<url>https://nexus.xuqinmin.com/repository/android/</url>
|
<url>https://nexus.xuqinmin.com/repository/maven-public/</url>
|
||||||
</repository>
|
</repository>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.xuqm</groupId>
|
<groupId>com.xuqm</groupId>
|
||||||
<artifactId>xuqm-server-sdk</artifactId>
|
<artifactId>im-sdk</artifactId>
|
||||||
<version>0.1.0</version>
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -25,11 +32,11 @@ Gradle:
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
repositories {
|
repositories {
|
||||||
maven { url = uri("https://nexus.xuqinmin.com/repository/android/") }
|
maven { url = uri("https://nexus.xuqinmin.com/repository/maven-public/") }
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.xuqm:xuqm-server-sdk:0.1.0")
|
implementation("com.xuqm:im-sdk:0.1.0-SNAPSHOT")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -38,14 +45,13 @@ dependencies {
|
|||||||
## 初始化
|
## 初始化
|
||||||
|
|
||||||
```java
|
```java
|
||||||
import com.xuqm.sdk.server.XuqmServerClient;
|
import com.xuqm.im.sdk.XuqmImServerSdk;
|
||||||
import com.xuqm.sdk.server.XuqmServerConfig;
|
|
||||||
|
|
||||||
XuqmServerClient client = new XuqmServerClient(XuqmServerConfig.builder()
|
XuqmImServerSdk client = XuqmImServerSdk.builder()
|
||||||
.baseUrl("https://dev.xuqinmin.com")
|
.baseUrl("https://dev.xuqinmin.com")
|
||||||
.appId("your_app_id")
|
.appKey("your_app_key")
|
||||||
.appSecret("your_app_secret")
|
.appSecret("your_app_secret")
|
||||||
.build());
|
.build();
|
||||||
```
|
```
|
||||||
|
|
||||||
> 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。
|
> 服务端 SDK 需要 `appSecret` 用于 HMAC 签名,`appSecret` 绝不下发到客户端。
|
||||||
@ -55,15 +61,18 @@ XuqmServerClient client = new XuqmServerClient(XuqmServerConfig.builder()
|
|||||||
## 发送消息
|
## 发送消息
|
||||||
|
|
||||||
```java
|
```java
|
||||||
import com.xuqm.sdk.server.model.SendMessageRequest;
|
import com.xuqm.im.sdk.XuqmImServerSdk.ImMessage;
|
||||||
import com.xuqm.sdk.server.model.ImMessage;
|
import com.xuqm.im.sdk.XuqmImServerSdk.SendMessageRequest;
|
||||||
|
|
||||||
ImMessage msg = client.sendMessage(SendMessageRequest.builder()
|
ImMessage msg = client.sendMessage(new SendMessageRequest(
|
||||||
.toId("user_002")
|
"user_002",
|
||||||
.chatType("SINGLE")
|
"SINGLE",
|
||||||
.msgType("TEXT")
|
"TEXT",
|
||||||
.content("Hello from Java SDK!")
|
"Hello from Java SDK!",
|
||||||
.build());
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
));
|
||||||
|
|
||||||
System.out.println("消息已发送: " + msg.getId());
|
System.out.println("消息已发送: " + msg.getId());
|
||||||
```
|
```
|
||||||
@ -87,28 +96,32 @@ 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
|
```java
|
||||||
import com.xuqm.sdk.server.model.*;
|
import com.xuqm.im.sdk.XuqmImServerSdk.GroupView;
|
||||||
|
|
||||||
// 创建群组
|
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.addGroupMember("group_xxx", "user_003");
|
||||||
|
|
||||||
// 移除群成员
|
|
||||||
client.removeGroupMember("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");
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -131,11 +144,11 @@ client.sendPush(SendPushRequest.builder()
|
|||||||
## 错误处理
|
## 错误处理
|
||||||
|
|
||||||
```java
|
```java
|
||||||
import com.xuqm.sdk.server.exception.XuqmApiException;
|
import com.xuqm.im.sdk.XuqmImServerSdk.ImSdkException;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ImMessage msg = client.sendMessage(request);
|
ImMessage msg = client.sendMessage(request);
|
||||||
} catch (XuqmApiException e) {
|
} catch (ImSdkException e) {
|
||||||
System.err.printf("API 错误: status=%d, code=%d, message=%s%n",
|
System.err.printf("API 错误: status=%d, code=%d, message=%s%n",
|
||||||
e.getHttpStatus(), e.getCode(), e.getMessage());
|
e.getHttpStatus(), e.getCode(), e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
# Python Server SDK
|
# Python Server SDK
|
||||||
|
|
||||||
XuqmGroup 服务端 Python SDK,提供 IM 消息发送、群管理、Push 推送等能力。
|
XuqmGroup 服务端 Python SDK,按照腾讯云服务端 API 的分类方式封装了:
|
||||||
|
|
||||||
|
- IM 账号、消息、群组、好友、会话、黑名单
|
||||||
|
- 管理端 Webhook、统计、操作日志
|
||||||
|
- Push 注册与推送
|
||||||
|
- Update 版本管理、RN Bundle、应用商店提审
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install xuqm-im-server-sdk
|
pip install xuqmgroup-server-sdk-python
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -19,7 +24,7 @@ from xuqm_im_server_sdk import XuqmImServerSdk
|
|||||||
|
|
||||||
sdk = XuqmImServerSdk(
|
sdk = XuqmImServerSdk(
|
||||||
base_url="https://dev.xuqinmin.com",
|
base_url="https://dev.xuqinmin.com",
|
||||||
app_id="your_app_id",
|
app_key="your_app_key",
|
||||||
app_secret="your_app_secret",
|
app_secret="your_app_secret",
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@ -28,6 +33,24 @@ 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
|
```python
|
||||||
@ -62,22 +85,15 @@ sdk.revoke_message("message_id")
|
|||||||
## 群管理
|
## 群管理
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 创建群组
|
|
||||||
group = sdk.create_group(
|
group = sdk.create_group(
|
||||||
name="项目讨论",
|
name="项目讨论",
|
||||||
member_ids=["user_001", "user_002"],
|
member_ids=["user_001", "user_002"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加群成员
|
|
||||||
sdk.add_group_member("group_xxx", "user_003")
|
sdk.add_group_member("group_xxx", "user_003")
|
||||||
|
|
||||||
# 移除群成员
|
|
||||||
sdk.remove_group_member("group_xxx", "user_003")
|
sdk.remove_group_member("group_xxx", "user_003")
|
||||||
|
|
||||||
# 获取群列表
|
|
||||||
groups = sdk.list_groups()
|
groups = sdk.list_groups()
|
||||||
|
|
||||||
# 获取群成员
|
|
||||||
members = sdk.list_group_members("group_xxx")
|
members = sdk.list_group_members("group_xxx")
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -109,4 +125,4 @@ except XuqmAPIError as e:
|
|||||||
print(f"API 错误: status={e.status}, code={e.code}, message={e.message}")
|
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
|
destination:/app/chat.send
|
||||||
content-type:application/json
|
content-type:application/json
|
||||||
|
|
||||||
{"appId":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"}
|
{"appKey":"ak_demo_chat","messageId":"...","toId":"user_002","chatType":"SINGLE","msgType":"TEXT","content":"Hello"}
|
||||||
\x00
|
\x00
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -104,7 +104,7 @@ SEND
|
|||||||
destination:/app/chat.revoke
|
destination:/app/chat.revoke
|
||||||
content-type:application/json
|
content-type:application/json
|
||||||
|
|
||||||
{"appId":"ak_demo_chat","messageId":"..."}
|
{"appKey":"ak_demo_chat","messageId":"..."}
|
||||||
\x00
|
\x00
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -115,7 +115,7 @@ SEND
|
|||||||
destination:/app/chat.sync
|
destination:/app/chat.sync
|
||||||
content-type:application/json
|
content-type:application/json
|
||||||
|
|
||||||
{"appId":"ak_demo_chat"}
|
{"appKey":"ak_demo_chat"}
|
||||||
\x00
|
\x00
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"@xuqm/vue3-sdk": "0.2.0",
|
||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
|
|||||||
@ -61,53 +61,87 @@ export interface UpdateServiceConfig {
|
|||||||
defaultMarketUrl?: string
|
defaultMarketUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PushVendorConfig {
|
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 {
|
||||||
appId?: string
|
appId?: string
|
||||||
appKey?: string
|
appKey?: string
|
||||||
appSecret?: string
|
appSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuaweiPushVendorConfig {
|
||||||
|
appId?: string
|
||||||
|
appSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OppoPushVendorConfig {
|
||||||
|
appId?: string
|
||||||
|
appKey?: string
|
||||||
masterSecret?: string
|
masterSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VivoPushVendorConfig {
|
||||||
|
appId?: string
|
||||||
|
appKey?: string
|
||||||
|
appSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HonorPushVendorConfig {
|
||||||
|
appId?: string
|
||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarmonyPushVendorConfig {
|
||||||
|
appId?: string
|
||||||
|
appSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApnsPushVendorConfig {
|
||||||
teamId?: string
|
teamId?: string
|
||||||
keyId?: string
|
keyId?: string
|
||||||
bundleId?: string
|
bundleId?: string
|
||||||
keyPath?: string
|
privateKey?: string
|
||||||
sandbox?: boolean
|
sandbox?: boolean
|
||||||
serviceAccountJson?: string
|
}
|
||||||
|
|
||||||
|
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
|
||||||
channelId?: string
|
channelId?: string
|
||||||
category?: string
|
category?: string
|
||||||
receiptId?: string
|
importance?: PushImportance
|
||||||
|
priority?: PushPriority
|
||||||
|
threadIdentifier?: string
|
||||||
|
interruptionLevel?: PushInterruptionLevel
|
||||||
|
badge?: boolean
|
||||||
|
sound?: boolean
|
||||||
|
vibration?: boolean
|
||||||
|
notifyType?: number
|
||||||
|
version?: number
|
||||||
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PushServiceConfig {
|
export interface PushServiceConfig {
|
||||||
huawei?: PushVendorConfig
|
schemaVersion?: number
|
||||||
xiaomi?: PushVendorConfig
|
updatedAt?: string
|
||||||
oppo?: PushVendorConfig
|
vendors?: PushVendorsConfig
|
||||||
vivo?: PushVendorConfig
|
profiles?: PushProfileConfig[]
|
||||||
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 = {
|
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 StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE' | 'GOOGLE_PLAY' | 'HARMONY_APP' | 'REVIEW_WEBHOOK'
|
||||||
export type StoreReviewState = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
|
export type StoreReviewState = 'PENDING' | 'SUBMITTING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
|
||||||
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
|
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
|
||||||
export type GrayMode = 'PERCENT' | 'MEMBERS'
|
export type GrayMode = 'PERCENT' | 'MEMBERS'
|
||||||
export type GraySelectionSource = 'LOCAL' | 'CALLBACK'
|
export type GraySelectionSource = 'LOCAL' | 'CALLBACK'
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
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 :column="1" border>
|
||||||
<el-descriptions-item label="调用方式">POST 到你配置的回调地址,`Content-Type: application/json`。</el-descriptions-item>
|
<el-descriptions-item label="调用方式">POST 到你配置的回调地址,`Content-Type: application/json`。</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="请求头">`X-App-Key`、`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="请求体">统一 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="签名">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`。</el-descriptions-item>
|
||||||
<el-descriptions-item label="失败处理">回调发送失败只记录日志,不会中断消息发送、撤回等主流程。</el-descriptions-item>
|
<el-descriptions-item label="失败处理">回调发送失败只记录日志,不会中断消息发送、撤回等主流程。</el-descriptions-item>
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
/>
|
/>
|
||||||
<el-descriptions :column="1" border>
|
<el-descriptions :column="1" border>
|
||||||
<el-descriptions-item label="调用方式">服务端以 `POST` 方式推送到你配置的回调地址。</el-descriptions-item>
|
<el-descriptions-item label="调用方式">服务端以 `POST` 方式推送到你配置的回调地址。</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="签名头">`X-App-Key`、`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="验签公式">`HMAC-SHA256(appSecret, appKey + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`。</el-descriptions-item>
|
||||||
<el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重。</el-descriptions-item>
|
<el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重。</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
@ -244,4 +244,3 @@ onMounted(async () => {
|
|||||||
await Promise.all([loadApp(), loadWebhooks()])
|
await Promise.all([loadApp(), loadWebhooks()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -24,131 +24,171 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
title="这里维护各厂商推送凭据。推送服务开通后,离线推送才会按厂商路由发送。"
|
title="新推送模型按 vendors + profiles 管理。厂商凭据只负责连厂商,profiles 负责按场景配置 Channel ID、Category、Importance、角标、声音、振动等。"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
style="margin-bottom:16px"
|
style="margin-bottom:16px"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-card>
|
<el-card style="margin-bottom:16px">
|
||||||
<template #header>厂商配置</template>
|
<template #header>厂商凭据</template>
|
||||||
<el-divider content-position="left">通知通道</el-divider>
|
|
||||||
<el-alert
|
|
||||||
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.channels" border style="margin-bottom:16px">
|
|
||||||
<el-table-column label="业务键" min-width="130">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<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" />
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="版本" width="82">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-input-number v-model="row.version" :min="1" controls-position="right" style="width:72px" />
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<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="80" align="center">
|
|
||||||
<template #default="{ row }"><el-switch v-model="row.sound" /></template>
|
|
||||||
</el-table-column>
|
|
||||||
<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="80" align="center">
|
|
||||||
<template #default="{ row }"><el-switch v-model="row.badge" /></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="80">
|
|
||||||
<template #default="{ $index }">
|
|
||||||
<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">
|
<div class="vendor-grid">
|
||||||
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
|
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
|
||||||
<template #header>{{ vendor.label }}</template>
|
<template #header>{{ vendor.label }}</template>
|
||||||
<div class="vendor-hint">{{ vendor.hint }}</div>
|
<div class="vendor-hint">{{ vendor.hint }}</div>
|
||||||
<el-form :label-position="isMobile ? 'top' : 'right'" label-width="120px">
|
<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-form-item v-for="field in vendor.fields" :key="field.key" :label="field.label">
|
||||||
<el-input
|
<el-input
|
||||||
v-if="field.type === 'textarea'"
|
v-if="field.type === 'textarea'"
|
||||||
v-model="pushConfig[vendor.key][field.key]"
|
v-model="vendorModel(vendor.key)[field.key]"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="field.rows ?? 4"
|
:rows="field.rows ?? 4"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
/>
|
/>
|
||||||
<el-switch
|
<el-switch
|
||||||
v-else-if="field.type === 'switch'"
|
v-else-if="field.type === 'switch'"
|
||||||
v-model="pushConfig[vendor.key][field.key]"
|
v-model="vendorModel(vendor.key)[field.key]"
|
||||||
/>
|
/>
|
||||||
<el-input
|
<el-input
|
||||||
v-else
|
v-else
|
||||||
v-model="pushConfig[vendor.key][field.key]"
|
v-model="vendorModel(vendor.key)[field.key]"
|
||||||
type="text"
|
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
description="routeType 为空时表示默认兜底 profile。你可以先建 3 条,后续随时补充剩余的 2 条,或者删除不再使用的 profile。"
|
||||||
|
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">
|
||||||
|
<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" />
|
||||||
|
</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" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Category" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.category" placeholder="MESSAGE / SYSTEM / ..." />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="重要性" width="120">
|
||||||
|
<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>
|
||||||
|
<el-table-column label="角标" width="78" 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.sound" />
|
||||||
|
</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">
|
||||||
|
<template #default="{ $index }">
|
||||||
|
<el-button link type="danger" @click="removeProfile($index)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button @click="reloadConfig" :loading="loading">刷新</el-button>
|
<el-button @click="reloadConfig" :loading="loading">刷新</el-button>
|
||||||
@ -161,18 +201,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { CopyDocument } from '@element-plus/icons-vue'
|
import { CopyDocument } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
appApi,
|
appApi,
|
||||||
type App,
|
type App,
|
||||||
type FeatureService,
|
type FeatureService,
|
||||||
type PushNotificationChannelConfig,
|
type ApnsPushVendorConfig,
|
||||||
type PushNotificationRouteConfig,
|
type HarmonyPushVendorConfig,
|
||||||
|
type HonorPushVendorConfig,
|
||||||
|
type HuaweiPushVendorConfig,
|
||||||
|
type OppoPushVendorConfig,
|
||||||
|
type PushImportance,
|
||||||
|
type PushInterruptionLevel,
|
||||||
|
type PushPriority,
|
||||||
|
type PushProfileConfig,
|
||||||
type PushServiceConfig,
|
type PushServiceConfig,
|
||||||
|
type PushVendorKey,
|
||||||
|
type XiaomiPushVendorConfig,
|
||||||
|
type VivoPushVendorConfig,
|
||||||
} from '@/api/app'
|
} from '@/api/app'
|
||||||
|
|
||||||
type VendorKey = keyof PushServiceConfig
|
type PushVendorsState = {
|
||||||
|
huawei: HuaweiPushVendorConfig
|
||||||
|
xiaomi: XiaomiPushVendorConfig
|
||||||
|
oppo: OppoPushVendorConfig
|
||||||
|
vivo: VivoPushVendorConfig
|
||||||
|
honor: HonorPushVendorConfig
|
||||||
|
harmony: HarmonyPushVendorConfig
|
||||||
|
apns: ApnsPushVendorConfig
|
||||||
|
}
|
||||||
|
|
||||||
type FieldDef = {
|
type FieldDef = {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@ -180,13 +239,21 @@ type FieldDef = {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
rows?: number
|
rows?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type VendorDef = {
|
type VendorDef = {
|
||||||
key: VendorKey
|
key: PushVendorKey
|
||||||
label: string
|
label: string
|
||||||
hint: string
|
hint: string
|
||||||
fields: FieldDef[]
|
fields: FieldDef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PushConfigState = {
|
||||||
|
schemaVersion: number
|
||||||
|
updatedAt: string
|
||||||
|
vendors: PushVendorsState
|
||||||
|
profiles: PushProfileConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const app = ref<App | null>(null)
|
const app = ref<App | null>(null)
|
||||||
const services = ref<FeatureService[]>([])
|
const services = ref<FeatureService[]>([])
|
||||||
@ -194,29 +261,31 @@ const loading = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const isMobile = ref(window.innerWidth < 768)
|
const isMobile = ref(window.innerWidth < 768)
|
||||||
const selectedPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
const selectedPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
||||||
|
|
||||||
const platformOptions = [
|
const platformOptions = [
|
||||||
{ label: 'Android', value: 'ANDROID' },
|
{ label: 'Android', value: 'ANDROID' },
|
||||||
{ label: 'iOS', value: 'IOS' },
|
{ label: 'iOS', value: 'IOS' },
|
||||||
{ label: '鸿蒙', value: 'HARMONY' },
|
{ label: '鸿蒙', value: 'HARMONY' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function updateViewport() {
|
const vendorOptions: Array<{ label: string; value: PushVendorKey }> = [
|
||||||
isMobile.value = window.innerWidth < 768
|
{ label: '华为', value: 'huawei' },
|
||||||
}
|
{ label: '小米', value: 'xiaomi' },
|
||||||
|
{ label: 'OPPO', value: 'oppo' },
|
||||||
|
{ label: 'vivo', value: 'vivo' },
|
||||||
|
{ label: '荣耀', value: 'honor' },
|
||||||
|
{ label: '鸿蒙', value: 'harmony' },
|
||||||
|
{ label: 'iOS', value: 'apns' },
|
||||||
|
]
|
||||||
|
|
||||||
const pushConfig = reactive<Required<PushServiceConfig>>({
|
const routeTypeOptions = [
|
||||||
huawei: { appId: '', appSecret: '', category: '' },
|
'IM_MESSAGE',
|
||||||
xiaomi: { appId: '', appKey: '', appSecret: '', channelId: '' },
|
'FRIEND_REQUEST',
|
||||||
oppo: { appId: '', appKey: '', masterSecret: '', channelId: '' },
|
'SYSTEM_NOTICE',
|
||||||
vivo: { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' },
|
'SERVICE_NOTICE',
|
||||||
honor: { appId: '', clientId: '', clientSecret: '' },
|
'MARKETING',
|
||||||
harmony: { appId: '', appSecret: '' },
|
'DEFAULT',
|
||||||
apns: { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false },
|
]
|
||||||
fcm: { serviceAccountJson: '' },
|
|
||||||
channels: defaultChannels(),
|
|
||||||
routing: defaultRouting(),
|
|
||||||
} as Required<PushServiceConfig>)
|
|
||||||
const originalChannels = ref<PushNotificationChannelConfig[]>([])
|
|
||||||
|
|
||||||
const importanceOptions = [
|
const importanceOptions = [
|
||||||
{ label: '最小', value: 'MIN' },
|
{ label: '最小', value: 'MIN' },
|
||||||
@ -224,78 +293,83 @@ const importanceOptions = [
|
|||||||
{ label: '默认', value: 'DEFAULT' },
|
{ label: '默认', value: 'DEFAULT' },
|
||||||
{ label: '高', value: 'HIGH' },
|
{ label: '高', value: 'HIGH' },
|
||||||
{ label: '最高', value: 'MAX' },
|
{ label: '最高', value: 'MAX' },
|
||||||
] as const
|
] as const satisfies ReadonlyArray<{ label: string; value: PushImportance }>
|
||||||
|
|
||||||
const routeTypes = [
|
const priorityOptions = [
|
||||||
{ type: 'IM_MESSAGE', channel: 'im_message', category: 'MESSAGE', priority: 'HIGH' },
|
{ label: '低', value: 'LOW' },
|
||||||
{ type: 'FRIEND_REQUEST', channel: 'im_message', category: 'SOCIAL', priority: 'HIGH' },
|
{ label: '默认', value: 'DEFAULT' },
|
||||||
{ type: 'SYSTEM_NOTICE', channel: 'system_notice', category: 'SYSTEM', priority: 'DEFAULT' },
|
{ label: '高', value: 'HIGH' },
|
||||||
] as const
|
] as const satisfies ReadonlyArray<{ label: string; value: PushPriority }>
|
||||||
|
|
||||||
const routeRows = computed(() => routeTypes.map(item => ({
|
const interruptionOptions = [
|
||||||
type: item.type,
|
{ label: 'Passive', value: 'passive' },
|
||||||
route: pushConfig.routing[item.type] ?? ensureRoute(item.type, item.channel, item.category, item.priority),
|
{ 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 vendorDefs: VendorDef[] = [
|
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',
|
key: 'xiaomi',
|
||||||
label: '小米 MiPush',
|
label: '小米 MiPush',
|
||||||
hint: '填写 AppId / AppKey / AppSecret;Android 包名在应用设置中统一配置。Channel ID 为小米通知通道 ID(如 118060)。',
|
hint: '这里仅填写厂商凭据。Channel ID 放在 profiles 中按场景单独配置,支持后续补充或删除。',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'appId', label: 'AppId' },
|
{ key: 'appId', label: 'AppId' },
|
||||||
{ key: 'appKey', label: 'AppKey' },
|
{ key: 'appKey', label: 'AppKey' },
|
||||||
{ key: 'appSecret', label: 'AppSecret' },
|
{ key: 'appSecret', label: 'AppSecret' },
|
||||||
{ key: 'channelId', label: 'Channel ID', placeholder: '118060' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'oppo',
|
key: 'huawei',
|
||||||
label: 'OPPO 推送',
|
label: '华为 HMS',
|
||||||
hint: '填写 AppId / AppKey / MasterSecret;Channel ID 为 OPPO 推送通道 ID(如 IM)。',
|
hint: '仅保留厂商账号信息。Category、Channel ID、重要性等由 profiles 负责。',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'appId', label: 'AppId' },
|
{ 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: 'appSecret', label: 'AppSecret' },
|
||||||
{ key: 'category', label: 'Category', placeholder: 'IM' },
|
|
||||||
{ key: 'receiptId', label: '回执 ID', placeholder: '4470' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'honor',
|
key: 'honor',
|
||||||
label: '荣耀推送',
|
label: '荣耀 Push',
|
||||||
hint: '填写 AppId / ClientId / ClientSecret。',
|
hint: '荣耀账号凭据。业务场景与展示策略在 profiles 中配置。',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'appId', label: 'AppId' },
|
{ key: 'appId', label: 'AppId' },
|
||||||
{ key: 'clientId', label: 'ClientId' },
|
{ key: 'clientId', label: 'ClientId' },
|
||||||
{ key: 'clientSecret', label: 'ClientSecret' },
|
{ 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',
|
key: 'harmony',
|
||||||
label: '鸿蒙 Push Kit',
|
label: '鸿蒙 Push Kit',
|
||||||
hint: '填写 HarmonyOS Push Kit AppId / AppSecret。',
|
hint: '鸿蒙厂商凭据。需要的 Category / Channel ID 放到 profiles 中。',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'appId', label: 'AppId' },
|
{ key: 'appId', label: 'AppId' },
|
||||||
{ key: 'appSecret', label: 'AppSecret' },
|
{ key: 'appSecret', label: 'AppSecret' },
|
||||||
@ -303,13 +377,13 @@ const vendorDefs: VendorDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'apns',
|
key: 'apns',
|
||||||
label: 'APNs(iOS)',
|
label: 'iOS APNs',
|
||||||
hint: '填写 Team ID / Key ID / Bundle ID / p8 文件路径。',
|
hint: 'Team ID / Key ID / Bundle ID / 私钥统一放这里。Badge、Thread ID、Interruption Level 放在 profiles 中。',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'teamId', label: 'Team ID' },
|
{ key: 'teamId', label: 'Team ID' },
|
||||||
{ key: 'keyId', label: 'Key ID' },
|
{ key: 'keyId', label: 'Key ID' },
|
||||||
{ key: 'bundleId', label: 'Bundle ID' },
|
{ key: 'bundleId', label: 'Bundle ID' },
|
||||||
{ key: 'keyPath', label: 'p8 文件路径' },
|
{ key: 'privateKey', label: 'Private Key', type: 'textarea', rows: 6, placeholder: '粘贴 .p8 内容' },
|
||||||
{ key: 'sandbox', label: 'Sandbox', type: 'switch' },
|
{ key: 'sandbox', label: 'Sandbox', type: 'switch' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -318,73 +392,166 @@ const vendorDefs: VendorDef[] = [
|
|||||||
const pushEnabled = computed(() => services.value.some(
|
const pushEnabled = computed(() => services.value.some(
|
||||||
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value && s.enabled,
|
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value && s.enabled,
|
||||||
))
|
))
|
||||||
const servicePlatform = computed(() => selectedPlatform.value)
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const id = route.params.appKey as string
|
loading.value = true
|
||||||
const [appRes, svcRes] = await Promise.all([
|
try {
|
||||||
appApi.get(id),
|
const id = route.params.appKey as string
|
||||||
appApi.getServices(id),
|
const [appRes, svcRes] = await Promise.all([
|
||||||
])
|
appApi.get(id),
|
||||||
app.value = appRes.data.data
|
appApi.getServices(id),
|
||||||
services.value = svcRes.data.data
|
])
|
||||||
const firstPushService = services.value.find(s => s.serviceType === 'PUSH')
|
app.value = appRes.data.data
|
||||||
if (firstPushService && !services.value.some(s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value)) {
|
services.value = svcRes.data.data
|
||||||
selectedPlatform.value = firstPushService.platform
|
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
|
||||||
}
|
}
|
||||||
applySelectedPlatformConfig()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySelectedPlatformConfig() {
|
function applySelectedPlatformConfig() {
|
||||||
applyConfig(services.value.find(
|
const raw = services.value.find(
|
||||||
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value,
|
s => s.serviceType === 'PUSH' && s.platform === selectedPlatform.value,
|
||||||
)?.config)
|
)?.config
|
||||||
}
|
replaceState(parseConfig(raw))
|
||||||
|
|
||||||
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() {
|
async function saveConfig() {
|
||||||
if (!app.value) return
|
if (!app.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
bumpChangedChannelVersions()
|
const payload: PushServiceConfig = {
|
||||||
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', toPushConfigRequest())
|
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)
|
||||||
ElMessage.success('推送配置已保存')
|
ElMessage.success('推送配置已保存')
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch {
|
} catch {
|
||||||
@ -394,143 +561,12 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPushConfigRequest(): Record<string, unknown> {
|
function addProfile(vendor: PushVendorKey = 'xiaomi') {
|
||||||
return {
|
pushConfig.profiles.push(createProfile(vendor))
|
||||||
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 defaultChannels(): PushNotificationChannelConfig[] {
|
function removeProfile(index: number) {
|
||||||
return [
|
pushConfig.profiles.splice(index, 1)
|
||||||
{
|
|
||||||
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) {
|
async function onTogglePushService(enable: boolean) {
|
||||||
@ -544,7 +580,7 @@ async function onTogglePushService(enable: boolean) {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
if (!app.value) return
|
if (!app.value) return
|
||||||
await appApi.toggleService(app.value.id, servicePlatform.value, 'PUSH', false)
|
await appApi.toggleService(app.value.id, selectedPlatform.value, 'PUSH', false)
|
||||||
ElMessage.success('已关闭')
|
ElMessage.success('已关闭')
|
||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
@ -564,11 +600,20 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.mono { font-family: monospace; font-size: 12px; }
|
.mono {
|
||||||
.hint { font-size: 12px; color: #909399; }
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
.info-card :deep(.el-descriptions__body) {
|
.info-card :deep(.el-descriptions__body) {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.push-switch-row {
|
.push-switch-row {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -576,20 +621,38 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vendor-grid {
|
.vendor-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vendor-card {
|
.vendor-card {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vendor-hint {
|
.vendor-hint {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
line-height: 1.5;
|
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 {
|
.toolbar {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -597,6 +660,12 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
|||||||
flex-wrap: wrap;
|
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) {
|
@media (max-width: 767px) {
|
||||||
.vendor-grid {
|
.vendor-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -607,7 +676,8 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar :deep(.el-button) {
|
.toolbar :deep(.el-button),
|
||||||
|
.profiles-actions :deep(.el-button) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
<div v-if="isServicesPortal" class="portal-bar">
|
<div v-if="isServicesPortal" class="portal-bar">
|
||||||
<span class="portal-bar-title">版本管理</span>
|
<span class="portal-bar-title">版本管理</span>
|
||||||
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
|
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
|
||||||
@ -41,27 +46,36 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="应用商店" width="220" show-overflow-tooltip>
|
<el-table-column label="应用商店" width="220" show-overflow-tooltip>
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<template v-if="parseStoreReview(row.storeReviewStatus).length">
|
<div v-if="parseStoreReview(row.storeReviewStatus).length" class="store-review-cell">
|
||||||
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
|
<div class="store-review-tags">
|
||||||
<el-tooltip
|
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
|
||||||
v-if="item.state === 'REJECTED' && item.reason"
|
<el-tooltip
|
||||||
:content="item.reason"
|
v-if="item.state === 'REJECTED' && item.reason"
|
||||||
placement="top"
|
: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>
|
||||||
<el-tag
|
<el-tag
|
||||||
|
v-else
|
||||||
:type="reviewTagType(item.state)"
|
:type="reviewTagType(item.state)"
|
||||||
size="small"
|
size="small"
|
||||||
style="margin:2px"
|
style="margin:2px"
|
||||||
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
||||||
</el-tooltip>
|
</template>
|
||||||
<el-tag
|
</div>
|
||||||
v-else
|
<el-button
|
||||||
:type="reviewTagType(item.state)"
|
link
|
||||||
size="small"
|
type="primary"
|
||||||
style="margin:2px"
|
size="small"
|
||||||
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
class="store-review-detail-btn"
|
||||||
</template>
|
@click="openStoreReviewDetail(row)"
|
||||||
</template>
|
>查看详情</el-button>
|
||||||
|
</div>
|
||||||
<span v-else class="text-muted">—</span>
|
<span v-else class="text-muted">—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -480,6 +494,81 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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 -->
|
<!-- Store Credential Config Dialog -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showStoreConfig"
|
v-model="showStoreConfig"
|
||||||
@ -657,12 +746,20 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { UploadFilled } from '@element-plus/icons-vue'
|
import { UploadFilled } from '@element-plus/icons-vue'
|
||||||
@ -681,6 +778,7 @@ import {
|
|||||||
type StoreConfig,
|
type StoreConfig,
|
||||||
type StoreType,
|
type StoreType,
|
||||||
} from '@/api/update'
|
} from '@/api/update'
|
||||||
|
import { connectStoreReviewRealtime, disconnectStoreReviewRealtime } from '@/services/storeReviewRealtime'
|
||||||
import huaweiGuideImage from '@/assets/update-store/huawei/01.png'
|
import huaweiGuideImage from '@/assets/update-store/huawei/01.png'
|
||||||
import miGuideImage from '@/assets/update-store/mi/01.png'
|
import miGuideImage from '@/assets/update-store/mi/01.png'
|
||||||
import oppoGuideImage from '@/assets/update-store/oppo/01.png'
|
import oppoGuideImage from '@/assets/update-store/oppo/01.png'
|
||||||
@ -697,6 +795,54 @@ const pageTitle = computed(() => app.value?.name ?? appKey)
|
|||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '920px'))
|
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 activeTab = ref('app')
|
||||||
const storeTab = ref<'configs' | 'guide'>('configs')
|
const storeTab = ref<'configs' | 'guide'>('configs')
|
||||||
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
||||||
@ -742,6 +888,7 @@ const operationLogs = ref<{
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}[]>([])
|
}[]>([])
|
||||||
const loadingOperationLogs = ref(false)
|
const loadingOperationLogs = ref(false)
|
||||||
|
let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
|
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
|
||||||
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
|
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
|
||||||
@ -1135,6 +1282,10 @@ const selectedStores = ref<StoreType[]>([])
|
|||||||
const submitStoreMode = ref<PublishMode>('MANUAL')
|
const submitStoreMode = ref<PublishMode>('MANUAL')
|
||||||
const submitStoreScheduledAt = ref('')
|
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) {
|
function openSubmitStoreDialog(row: AppVersion) {
|
||||||
submitStoreVersion.value = row
|
submitStoreVersion.value = row
|
||||||
selectedStores.value = enabledStores.value.map(s => s.type)
|
selectedStores.value = enabledStores.value.map(s => s.type)
|
||||||
@ -1143,6 +1294,22 @@ function openSubmitStoreDialog(row: AppVersion) {
|
|||||||
showSubmitStore.value = true
|
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() {
|
async function confirmSubmitToStores() {
|
||||||
if (!submitStoreVersion.value || !selectedStores.value.length) {
|
if (!submitStoreVersion.value || !selectedStores.value.length) {
|
||||||
ElMessage.warning('请选择至少一个可用市场')
|
ElMessage.warning('请选择至少一个可用市场')
|
||||||
@ -1661,11 +1828,11 @@ function storeLabel(type: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reviewLabel(state: string): string {
|
function reviewLabel(state: string): string {
|
||||||
return { PENDING: '待提交', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
|
return { PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewTagType(state: string): string {
|
function reviewTagType(state: string): string {
|
||||||
return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
|
return { PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function operationResourceLabel(resourceType: string) {
|
function operationResourceLabel(resourceType: string) {
|
||||||
@ -1685,6 +1852,15 @@ function operationActionLabel(action: string) {
|
|||||||
SAVE_DRAFT: '保存草稿',
|
SAVE_DRAFT: '保存草稿',
|
||||||
UNPUBLISH: '下架',
|
UNPUBLISH: '下架',
|
||||||
STORE_SUBMIT: '提交市场',
|
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: '审核回写',
|
STORE_REVIEW: '审核回写',
|
||||||
GRAY_UPDATE: '灰度配置',
|
GRAY_UPDATE: '灰度配置',
|
||||||
AUTO_PUBLISH: '自动发布',
|
AUTO_PUBLISH: '自动发布',
|
||||||
@ -1722,7 +1898,17 @@ async function loadOperationLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStoreReview(json?: string): { store: string; state: string; reason?: string }[] {
|
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 }[] {
|
||||||
if (!json) return []
|
if (!json) return []
|
||||||
try {
|
try {
|
||||||
const m = JSON.parse(json) as Record<string, unknown>
|
const m = JSON.parse(json) as Record<string, unknown>
|
||||||
@ -1736,6 +1922,10 @@ function parseStoreReview(json?: string): { store: string; state: string; reason
|
|||||||
store,
|
store,
|
||||||
state: String(item.state ?? ''),
|
state: String(item.state ?? ''),
|
||||||
reason: String(item.reason ?? ''),
|
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: '' }
|
return { store, state: String(value ?? ''), reason: '' }
|
||||||
@ -1785,10 +1975,22 @@ onMounted(() => {
|
|||||||
loadStoreConfigs()
|
loadStoreConfigs()
|
||||||
loadPublishConfig()
|
loadPublishConfig()
|
||||||
loadOperationLogs()
|
loadOperationLogs()
|
||||||
|
void connectStoreReviewRealtime(appKey, () => {
|
||||||
|
scheduleStoreReviewReload()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[tenant-platform] store review realtime unavailable', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', updateViewport)
|
window.removeEventListener('resize', updateViewport)
|
||||||
|
disconnectStoreReviewRealtime()
|
||||||
|
if (storeReviewReloadTimer) {
|
||||||
|
clearTimeout(storeReviewReloadTimer)
|
||||||
|
storeReviewReloadTimer = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -2013,6 +2215,24 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 980px;
|
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,
|
.store-grid,
|
||||||
.guide-grid {
|
.guide-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -2046,4 +2266,36 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
|
|||||||
@ -1172,6 +1172,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue "^3.5.13"
|
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:
|
abbrev@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz"
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户