比较提交

..

2 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
7c4915bdb6 feat(update): manage terminal RN plugin packages 2026-07-27 09:58:25 +08:00
XuqmGroup
985c7f78d7 feat(platform): support signed sdk config v2 2026-07-27 09:58:25 +08:00
共有 41 个文件被更改,包括 263 次插入6535 次删除

查看文件

@ -1,6 +1,14 @@
pipeline { pipeline {
agent any agent any
parameters {
choice(
name: 'DEPLOY_ENV',
choices: ['development', 'production'],
description: 'development 只执行验证;production 需要人工确认后发布'
)
}
environment { environment {
ACR_REGISTRY = 'crpi-n44qjpuucgjt8e8c.cn-beijing.personal.cr.aliyuncs.com' ACR_REGISTRY = 'crpi-n44qjpuucgjt8e8c.cn-beijing.personal.cr.aliyuncs.com'
ACR_NAMESPACE = 'xuqmgroup' ACR_NAMESPACE = 'xuqmgroup'
@ -30,12 +38,33 @@ pipeline {
$class: 'GitSCM', $class: 'GitSCM',
branches: [[name: 'main']], branches: [[name: 'main']],
extensions: [[$class: 'CleanBeforeCheckout']], extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: scm.userRemoteConfigs userRemoteConfigs: [[
url: 'https://xuqinmin.com/xuqmGroup/XuqmGroup-Web.git',
credentialsId: 'XUQM_GIT_CREDENTIALS'
]]
]) ])
} }
} }
stage('Verify') {
steps {
bat 'yarn install --frozen-lockfile && yarn build:tenant'
}
}
stage('Confirm Production') {
when { expression { params.DEPLOY_ENV == 'production' } }
input {
message '确认发布并部署租户平台到生产环境?'
ok '确认生产发布'
}
steps {
echo '生产发布已人工确认'
}
}
stage('Resolve Version') { stage('Resolve Version') {
when { expression { params.DEPLOY_ENV == 'production' } }
steps { steps {
script { script {
def current = fileExists(env.VERSION_FILE) ? readFile(env.VERSION_FILE).trim() : '1.0.0' def current = fileExists(env.VERSION_FILE) ? readFile(env.VERSION_FILE).trim() : '1.0.0'
@ -50,6 +79,7 @@ pipeline {
} }
stage('Docker Build & Push') { stage('Docker Build & Push') {
when { expression { params.DEPLOY_ENV == 'production' } }
steps { steps {
withCredentials([string(credentialsId: 'ACR_PASSWORD', variable: 'ACR_PASS')]) { withCredentials([string(credentialsId: 'ACR_PASSWORD', variable: 'ACR_PASS')]) {
script { script {
@ -76,6 +106,7 @@ pipeline {
} }
stage('Deploy to Production') { stage('Deploy to Production') {
when { expression { params.DEPLOY_ENV == 'production' } }
steps { steps {
lock('prod-deploy') { lock('prod-deploy') {
withCredentials([sshUserPrivateKey(credentialsId: 'PROD_SSH_KEY', keyFileVariable: 'SSH_KEY')]) { withCredentials([sshUserPrivateKey(credentialsId: 'PROD_SSH_KEY', keyFileVariable: 'SSH_KEY')]) {
@ -108,6 +139,7 @@ print('versions.json updated: ${env.DEPLOY_SERVICE} = ${env.IMAGE_VERSION}')
} }
stage('Commit Version') { stage('Commit Version') {
when { expression { params.DEPLOY_ENV == 'production' } }
steps { steps {
script { script {
bat """ bat """
@ -123,7 +155,14 @@ print('versions.json updated: ${env.DEPLOY_SERVICE} = ${env.IMAGE_VERSION}')
} }
post { post {
success { bat "chcp 65001 >nul && echo ✅ ${env.IMAGE_NAME}:${env.IMAGE_VERSION} 构建部署成功" } success {
script {
def resultText = params.DEPLOY_ENV == 'production'
? "${env.IMAGE_NAME}:${env.IMAGE_VERSION} 构建部署成功"
: 'tenant-web development 验证成功(未发布)'
bat "chcp 65001 >nul && echo ✅ ${resultText}"
}
}
failure { bat 'chcp 65001 >nul && echo ❌ 构建失败,请检查日志' } failure { bat 'chcp 65001 >nul && echo ❌ 构建失败,请检查日志' }
} }
} }

查看文件

@ -5,7 +5,6 @@ export default defineConfig({
description: 'XuqmGroup 多端 SDK 接入指南', description: 'XuqmGroup 多端 SDK 接入指南',
base: '/docs/', base: '/docs/',
lang: 'zh-CN', lang: 'zh-CN',
ignoreDeadLinks: true,
themeConfig: { themeConfig: {
logo: '/logo.svg', logo: '/logo.svg',
@ -42,7 +41,6 @@ export default defineConfig({
{ text: 'IM 接入', link: '/android/im' }, { text: 'IM 接入', link: '/android/im' },
{ text: '推送接入', link: '/android/push' }, { text: '推送接入', link: '/android/push' },
{ text: '版本管理', link: '/android/update' }, { text: '版本管理', link: '/android/update' },
{ text: '授权管理', link: '/android/license' },
], ],
'/ios/': [ '/ios/': [
{ text: '概览', link: '/ios/' }, { text: '概览', link: '/ios/' },
@ -50,7 +48,6 @@ export default defineConfig({
{ text: 'IM 接入', link: '/ios/im' }, { text: 'IM 接入', link: '/ios/im' },
{ text: '推送接入', link: '/ios/push' }, { text: '推送接入', link: '/ios/push' },
{ text: '版本管理', link: '/ios/update' }, { text: '版本管理', link: '/ios/update' },
{ text: '授权管理', link: '/ios/license' },
], ],
'/rn/': [ '/rn/': [
{ text: '概览', link: '/rn/' }, { text: '概览', link: '/rn/' },
@ -59,7 +56,6 @@ export default defineConfig({
{ text: '群聊', link: '/rn/group' }, { text: '群聊', link: '/rn/group' },
{ text: '推送接入', link: '/rn/push' }, { text: '推送接入', link: '/rn/push' },
{ text: '版本管理', link: '/rn/update' }, { text: '版本管理', link: '/rn/update' },
{ text: '授权管理', link: '/rn/license' },
], ],
'/vue3/': [ '/vue3/': [
{ text: '概览', link: '/vue3/' }, { text: '概览', link: '/vue3/' },
@ -72,7 +68,6 @@ export default defineConfig({
{ text: 'IM 接入', link: '/flutter/im' }, { text: 'IM 接入', link: '/flutter/im' },
{ text: '推送接入', link: '/flutter/push' }, { text: '推送接入', link: '/flutter/push' },
{ text: '版本管理', link: '/flutter/update' }, { text: '版本管理', link: '/flutter/update' },
{ text: '授权管理', link: '/flutter/license' },
], ],
'/harmony/': [ '/harmony/': [
{ text: '概览', link: '/harmony/' }, { text: '概览', link: '/harmony/' },
@ -80,7 +75,6 @@ export default defineConfig({
{ text: 'IM 接入', link: '/harmony/im' }, { text: 'IM 接入', link: '/harmony/im' },
{ text: '推送接入', link: '/harmony/#push-接入' }, { text: '推送接入', link: '/harmony/#push-接入' },
{ text: '版本管理', link: '/harmony/#update-接入' }, { text: '版本管理', link: '/harmony/#update-接入' },
{ text: '授权管理', link: '/harmony/license' },
], ],
'/miniprogram/': [ '/miniprogram/': [
{ text: '概览', link: '/miniprogram/' }, { text: '概览', link: '/miniprogram/' },

查看文件

@ -10,7 +10,6 @@
| sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 | | sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 |
| sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册(华为/小米/OPPO/vivo/荣耀/FCM | | sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册(华为/小米/OPPO/vivo/荣耀/FCM |
| sdk-update | `com.xuqm:sdk-update` | App 更新检查、下载安装 | | sdk-update | `com.xuqm:sdk-update` | App 更新检查、下载安装 |
| sdk-license | `com.xuqm:sdk-license` | 设备授权注册与验证 |
## 快速接入 ## 快速接入
@ -33,7 +32,6 @@ dependencies {
implementation("com.xuqm:sdk-im:0.4.10") implementation("com.xuqm:sdk-im:0.4.10")
implementation("com.xuqm:sdk-push:0.4.10") // 按需 implementation("com.xuqm:sdk-push:0.4.10") // 按需
implementation("com.xuqm:sdk-update:0.4.10") // 按需 implementation("com.xuqm:sdk-update:0.4.10") // 按需
implementation("com.xuqm:sdk-license:0.4.10") // 按需
} }
``` ```
@ -199,4 +197,3 @@ lifecycleScope.launch {
- [Android IM 接入 →](./im) - [Android IM 接入 →](./im)
- [Android 推送接入 →](./push) - [Android 推送接入 →](./push)
- [Android 版本更新 →](./update) - [Android 版本更新 →](./update)
- [Android 授权管理 →](./license)

查看文件

@ -1,164 +0,0 @@
# Android 授权管理License SDK
**模块**`com.xuqm:sdk-license` · **最低 Android 版本**API 24 (Android 7.0)
License SDK 用于设备授权注册与验证,所有平台Android / iOS / 鸿蒙)共用同一个 License Key。
---
## 快速接入
### 1. 添加依赖
```kotlin
// app/build.gradle.kts
dependencies {
implementation("com.xuqm:sdk-license:0.4.2")
}
```
### 2. 放置 License 文件
从租户平台下载 `.xuqmlicense` 加密文件,放入 app 的 assets 目录:
```
app/src/main/assets/xuqm/license.xuqm
```
SDK 会按以下顺序查找文件:
1. `assets/xuqm/license.xuqm`
2. `assets/xuqm/` 目录下第一个 `.xuqmlicense` 文件
### 3. 检查授权
SDK **无需手动初始化**,首次调用 `checkLicense()` 时自动读取并解密 License 文件。
```kotlin
LicenseSDK.checkLicense { isValid ->
if (isValid) {
// 授权通过,允许使用
} else {
// 授权失败,提示用户
}
}
```
---
## API 说明
### checkLicense
检查设备授权状态。首次调用时自动完成:读取 License 文件 → 解密 → 注册/验证设备 → 缓存结果。
```kotlin
// 回调方式
LicenseSDK.checkLicense { isValid ->
// true: 授权通过 false: 授权失败
}
// 协程方式
lifecycleScope.launch {
val isValid = LicenseSDK.checkLicense()
}
```
**内部逻辑**
1. 检查本地缓存(默认 10 分钟有效期)
2. 缓存有效 → 直接返回 `true`
3. 缓存过期或无缓存:
- 有 Token → 调用验证接口
- 无 Token 或验证失败 → 调用注册接口
4. 网络异常且缓存曾成功 → 返回 `true`(离线模式)
5. 网络异常且无缓存 → 返回 `false`
---
## 设备唯一码
SDK 自动选择设备唯一码,优先级如下:
| 优先级 | 来源 | 稳定性 |
|--------|------|--------|
| 1 | ANDROID_ID | 同一签名 + 设备 + 用户不变,重装不变 |
| 2 | 持久化 UUID | 首次生成后持久化存储 |
**保持不变**的场景App 重启、App 重装(同一签名)、系统升级
**会变化**的场景:恢复出厂设置、更换签名重新安装
---
## 离线模式
SDK 支持离线使用:
- 首次激活需要网络连接
- 激活成功后,缓存有效期内(默认 10 分钟)可离线使用
- 网络异常时,若缓存曾经成功验证,继续返回授权通过
---
## 数据存储
| 数据 | 存储方式 | 说明 |
|------|----------|------|
| deviceId | EncryptedSharedPreferences | AES-256 加密 |
| token | EncryptedSharedPreferences | AES-256 加密 |
| 授权状态 | EncryptedSharedPreferences | AES-256 加密 |
| fallback UUID | SharedPreferences | ANDROID_ID 不可用时使用 |
---
## AppKey 变更
当 License 文件中的 AppKey 与已存储的不一致时,SDK 会自动:
1. 清除所有旧数据Token、deviceId、状态
2. 使用新 AppKey 重新注册
---
## 完整示例
```kotlin
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// 无需初始化代码,SDK 自动读取 License 文件
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
LicenseSDK.checkLicense { isValid ->
runOnUiThread {
if (isValid) {
// 进入主界面
} else {
Toast.makeText(this, "授权验证失败", Toast.LENGTH_LONG).show()
finish()
}
}
}
}
}
```
---
## 常见问题
**Q: checkLicense 返回 false 怎么办?**
A: 检查以下几点:
- License 文件是否放在 `assets/xuqm/` 目录
- 文件名是否为 `license.xuqm` 或以 `.xuqmlicense` 结尾
- 设备是否有网络连接(首次激活需要网络)
- License 是否已过期或被管理员禁用
**Q: 不同平台可以用同一个 License 吗?**
A: 可以。所有平台共用同一个 AppKey,设备数量统一计算。
**Q: 设备被禁用后如何恢复?**
A: 由租户管理员在租户平台的授权管理页面重新激活该设备,App 端无需额外操作,下次 `checkLicense` 会自动恢复正常。

查看文件

@ -28,7 +28,6 @@ dependencyResolutionManagement {
| sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 | | sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 |
| sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册(华为/小米/OPPO/vivo/荣耀/FCM| | sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册(华为/小米/OPPO/vivo/荣耀/FCM|
| sdk-update | `com.xuqm:sdk-update` | App 更新检查、下载安装(需配置 API Key,设置 `serviceActivated = true`| | sdk-update | `com.xuqm:sdk-update` | App 更新检查、下载安装(需配置 API Key,设置 `serviceActivated = true`|
| sdk-license | `com.xuqm:sdk-license` | 设备授权注册与验证 |
| sdk-bugcollect | `com.xuqm:sdk-bugcollect` | 崩溃日志、ANR、异常上报 | | sdk-bugcollect | `com.xuqm:sdk-bugcollect` | 崩溃日志、ANR、异常上报 |
`app/build.gradle.kts` 中按需引入: `app/build.gradle.kts` 中按需引入:
@ -39,7 +38,6 @@ dependencies {
implementation("com.xuqm:sdk-im:0.4.10") implementation("com.xuqm:sdk-im:0.4.10")
implementation("com.xuqm:sdk-push:0.4.10") // 按需 implementation("com.xuqm:sdk-push:0.4.10") // 按需
implementation("com.xuqm:sdk-update:0.4.10") // 按需 implementation("com.xuqm:sdk-update:0.4.10") // 按需
implementation("com.xuqm:sdk-license:0.4.10") // 按需
implementation("com.xuqm:sdk-bugcollect:0.4.10") // 按需 implementation("com.xuqm:sdk-bugcollect:0.4.10") // 按需
} }
``` ```
@ -98,9 +96,6 @@ dependencies {
# XuqmSDK Update # XuqmSDK Update
-keep class com.xuqm.sdk.update.** { *; } -keep class com.xuqm.sdk.update.** { *; }
# XuqmSDK License
-keep class com.xuqm.sdk.license.** { *; }
# GsonIM 消息序列化使用) # GsonIM 消息序列化使用)
-keepattributes Signature -keepattributes Signature
-keepattributes *Annotation* -keepattributes *Annotation*
@ -114,4 +109,3 @@ dependencies {
- [Android IM 接入 →](./im) - [Android IM 接入 →](./im)
- [Android Push 接入 →](./push) - [Android Push 接入 →](./push)
- [Android 版本更新 →](./update) - [Android 版本更新 →](./update)
- [Android 授权管理 →](./license)

查看文件

@ -14,7 +14,6 @@
| `xuqm_flutter_im` | `packages/im` | 单聊、群聊、消息收发、会话、好友、群组 | | `xuqm_flutter_im` | `packages/im` | 单聊、群聊、消息收发、会话、好友、群组 |
| `xuqm_flutter_push` | `packages/push` | 设备 Token 注册、厂商检测Android/ APNsiOS| | `xuqm_flutter_push` | `packages/push` | 设备 Token 注册、厂商检测Android/ APNsiOS|
| `xuqm_flutter_update` | `packages/update` | App 版本检查、商店跳转、APK 下载Android| | `xuqm_flutter_update` | `packages/update` | App 版本检查、商店跳转、APK 下载Android|
| `xuqm_flutter_license` | `packages/license` | 设备授权注册与验证License SDK|
--- ---

查看文件

@ -1,197 +0,0 @@
# Flutter 授权管理License SDK
**模块**`xuqm_flutter_license` · **依赖**`cryptography`、`device_info_plus`、`shared_preferences`
---
## 1. 安装
`pubspec.yaml` 中添加:
```yaml
dependencies:
xuqm_flutter_license:
git:
url: https://xuqinmin.com/xuqmGroup/XuqmGroup-FlutterSDK.git
ref: v0.2.2
path: packages/license
```
---
## 2. 放置 License 文件
从租户平台下载 `.xuqmlicense` 加密文件,放入 Flutter assets
```yaml
# pubspec.yaml
flutter:
assets:
- assets/xuqm/license.xuqm
```
---
## 3. 检查授权
```dart
import 'package:flutter/services.dart' show rootBundle;
import 'package:xuqm_flutter_license/xuqm_flutter_license.dart';
Future<void> verifyLicense() async {
// 从 assets 读取加密文件
final content = await rootBundle.loadString('assets/xuqm/license.xuqm');
// 自动解密并初始化
await initializeFromFile(content);
// 检查授权(有缓存时直接返回,否则网络验证)
final result = await checkLicense();
switch (result) {
case LicenseSuccess(reason: final r):
print('授权通过: $r');
case LicenseError(message: final m):
print('授权失败: $m');
}
}
```
---
## 4. 携带用户信息
```dart
final result = await checkLicense(
userInfo: LicenseUserInfo(
userId: 'user_001',
name: '张三',
email: 'zhangsan@company.com',
),
);
```
---
## 5. API 说明
### initializeFromFile
```dart
Future<void> initializeFromFile(String encryptedContent)
```
从加密 License 文件内容自动解析 appKey 和 baseUrl 并初始化。
### checkLicense
```dart
Future<LicenseResult> checkLicense({LicenseUserInfo? userInfo})
```
返回 `LicenseSuccess(reason)``LicenseError(message)`(密封类,使用 `switch` 匹配)。
**缓存策略**10 分钟有效期,有效期内不发起网络请求。
### getStatus
```dart
Future<LicenseStatus> getStatus() // LicenseStatus.ok / .denied / .unknown
```
### getDeviceId
```dart
Future<String?> getDeviceId()
```
### clear
```dart
Future<void> clear()
```
---
## 6. 设备唯一码
| 平台 | 来源 | 说明 |
|------|------|------|
| Android | `androidInfo.id` | `Settings.Secure.ANDROID_ID` |
| iOS | `iosInfo.identifierForVendor` | 同 Vendor 下卸载重装不变 |
| 其他 | 生成 UUID 存入 SharedPreferences | fallback |
---
## 7. 数据存储
| 数据 | 存储方式 | 说明 |
|------|----------|------|
| deviceId | SharedPreferences | 持久化 |
| token | SharedPreferences | 持久化 |
| 授权状态 | SharedPreferences | 非敏感,持久化 |
| statusTime | SharedPreferences | 缓存有效期判断 |
---
## 8. 离线模式
- 首次激活需要网络连接
- 激活后 10 分钟缓存内可离线使用
- 网络异常时,若历史缓存成功,继续返回 `LicenseSuccess`
---
## 9. 完整示例
```dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:xuqm_flutter_sdk/xuqm_flutter_sdk.dart';
import 'package:xuqm_flutter_license/xuqm_license.dart' as license;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await XuqmSDK.initialize(XuqmInitOptions(appKey: 'your_app_key'));
// 初始化 License
final content = await rootBundle.loadString('assets/xuqm/license.xuqm');
await license.initializeFromFile(content);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: FutureBuilder(
future: license.checkLicense(
userInfo: license.LicenseUserInfo(userId: 'user_001'),
),
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final result = snapshot.data!;
if (result is license.LicenseError) {
return Scaffold(
body: Center(child: Text('授权失败: ${result.message}')),
);
}
return const HomePage();
},
),
);
}
}
```
---
## 10. 常见问题
**Q: 提示 `Invalid license file format`?**
检查 License 文件是否完整,以及是否已在 `pubspec.yaml``flutter.assets` 中声明。
**Q: 不同平台可以用同一个 License 吗?**
可以,所有平台共用同一个 AppKey,设备数量统一计算。

查看文件

@ -28,7 +28,6 @@ dependencies:
| `xuqm_flutter_im` | `packages/im` | 单聊、群聊、消息收发、会话、好友、群组 | | `xuqm_flutter_im` | `packages/im` | 单聊、群聊、消息收发、会话、好友、群组 |
| `xuqm_flutter_push` | `packages/push` | 设备 Token 注册、厂商检测Android/ APNsiOS| | `xuqm_flutter_push` | `packages/push` | 设备 Token 注册、厂商检测Android/ APNsiOS|
| `xuqm_flutter_update` | `packages/update` | App 版本检查、商店跳转、APK 下载Android| | `xuqm_flutter_update` | `packages/update` | App 版本检查、商店跳转、APK 下载Android|
| `xuqm_flutter_license` | `packages/license` | 设备授权注册与验证License SDK|
--- ---
@ -83,4 +82,3 @@ await XuqmImSdk().logout();
- [Flutter IM 接入 →](./im) - [Flutter IM 接入 →](./im)
- [Flutter 推送接入 →](./push) - [Flutter 推送接入 →](./push)
- [Flutter 版本更新 →](./update) - [Flutter 版本更新 →](./update)
- [Flutter 授权管理 →](./license)

查看文件

@ -52,7 +52,6 @@ XuqmGroup 平台由以下核心服务模块组成,各模块可独立按需接
| **IM** | 即时通讯:单聊、群聊、消息收发、会话管理、好友、群组 | | **IM** | 即时通讯:单聊、群聊、消息收发、会话管理、好友、群组 |
| **Push** | 消息推送:自动检测厂商通道(华为/小米/OPPO/vivo/荣耀/FCM,设备 Token 注册 | | **Push** | 消息推送:自动检测厂商通道(华为/小米/OPPO/vivo/荣耀/FCM,设备 Token 注册 |
| **Update** | 版本更新App 更新检查、下载安装、商店跳转 | | **Update** | 版本更新App 更新检查、下载安装、商店跳转 |
| **License** | 授权管理:设备授权注册与验证 |
| **BugCollect** | 异常采集崩溃日志、ANR、异常上报,支持 Android / iOS / RN 多端统一采集 | | **BugCollect** | 异常采集崩溃日志、ANR、异常上报,支持 Android / iOS / RN 多端统一采集 |
| **File** | 文件服务:文件上传、下载、消息附件管理 | | **File** | 文件服务:文件上传、下载、消息附件管理 |

查看文件

@ -22,7 +22,7 @@
6. 客户端集成私有化 SDK。 6. 客户端集成私有化 SDK。
7. 客户端使用 `xuqm-private-sdk.json` 初始化。 7. 客户端使用 `xuqm-private-sdk.json` 初始化。
8. 业务服务端签发 `UserSig` 8. 业务服务端签发 `UserSig`
9. 客户端登录 SDK 并使用 IM、Push、Update、File、License、BugCollect 能力。 9. 客户端登录 SDK 并使用 IM、Push、Update、File、BugCollect 能力。
## 服务端签发 UserSig ## 服务端签发 UserSig
@ -43,7 +43,7 @@
-> 获取 userSig -> 获取 userSig
-> XuqmSDK.login(userId, userSig) -> XuqmSDK.login(userId, userSig)
-> IM WebSocket 连接 -> IM WebSocket 连接
-> Push / Update / License / BugCollect 模块按需工作 -> Push / Update / BugCollect 模块按需工作
``` ```
## 私有化注意事项 ## 私有化注意事项

查看文件

@ -98,8 +98,7 @@ JSON 内容由私有化部署仓库生成,示例:
"imWsUrl": "wss://im.customer.com/ws/im", "imWsUrl": "wss://im.customer.com/ws/im",
"pushBaseUrl": "https://push.customer.com", "pushBaseUrl": "https://push.customer.com",
"updateBaseUrl": "https://update.customer.com", "updateBaseUrl": "https://update.customer.com",
"fileBaseUrl": "https://file.customer.com", "fileBaseUrl": "https://file.customer.com"
"licenseBaseUrl": "https://license.customer.com"
} }
``` ```
@ -111,7 +110,7 @@ JSON 内容由私有化部署仓库生成,示例:
-> 业务服务端签发 UserSig -> 业务服务端签发 UserSig
-> 客户端调用 SDK login -> 客户端调用 SDK login
-> IM 连接建立 -> IM 连接建立
-> 收发消息、推送、更新、License 能力按模块启用 -> 收发消息、推送、更新能力按模块启用
``` ```
安全要求:`appSecret` 只能保存在业务服务端,不允许下发到客户端。 安全要求:`appSecret` 只能保存在业务服务端,不允许下发到客户端。

查看文件

@ -10,7 +10,6 @@
| `ImSDK` | 单聊、群聊、消息收发 | | `ImSDK` | 单聊、群聊、消息收发 |
| `PushSDK` | Push Token 注册与注销 | | `PushSDK` | Push Token 注册与注销 |
| `UpdateSDK` | App 版本检查、整包更新 | | `UpdateSDK` | App 版本检查、整包更新 |
| `LicenseSDK` | 设备授权注册与验证 |
## 安装 ## 安装

查看文件

@ -1,166 +0,0 @@
# HarmonyOS 授权管理License SDK
**模块**`LicenseSDK` · **包名**`@xuqm/harmony-sdk`
---
## 1. 安装
`LicenseSDK` 已包含在 `@xuqm/harmony-sdk` 中,无需额外安装。确保 `oh-package.json5` 中已依赖:
```json5
{
"dependencies": {
"@xuqm/harmony-sdk": "^0.1.0"
}
}
```
执行:
```bash
ohpm install
```
---
## 2. 放置 License 文件
从租户平台下载 `.xuqmlicense` 加密文件,放入项目 `resources/rawfile/` 目录:
```
entry/
src/main/
resources/
rawfile/
license.xuqm
```
---
## 3. 初始化并检查授权
`EntryAbility.onCreate` 或应用启动入口中初始化 License
```typescript
import { LicenseSDK } from '@xuqm/harmony-sdk'
import common from '@ohos.app.ability.common'
import rawfileManager from '@ohos.rawfileManager'
export default class EntryAbility extends UIAbility {
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
// 读取 License 文件内容
const context = getContext(this) as common.UIAbilityContext
const content = await readRawFile(context, 'license.xuqm')
// 从加密文件初始化
await LicenseSDK.initializeFromFile(content)
// 检查授权
const result = await LicenseSDK.checkLicense()
if (result.type === 'success') {
console.log('授权通过:', result.reason)
} else {
console.warn('授权失败:', result.message)
}
}
}
async function readRawFile(context: common.UIAbilityContext, filename: string): Promise<string> {
const rm = context.resourceManager
const data = await rm.getRawFileContent(filename)
return new TextDecoder().decode(data)
}
```
---
## 4. 携带用户信息
```typescript
import { LicenseSDK, LicenseUserInfo } from '@xuqm/harmony-sdk'
const userInfo: LicenseUserInfo = {
userId: 'user_001',
name: '张三',
email: 'zhangsan@company.com',
}
const result = await LicenseSDK.checkLicense(userInfo)
```
---
## 5. API 说明
### initializeFromFile
```typescript
LicenseSDK.initializeFromFile(encryptedContent: string): Promise<void>
```
从加密 License 文件内容自动解析 `appKey` 并初始化。
### checkLicense
```typescript
LicenseSDK.checkLicense(userInfo?: LicenseUserInfo): Promise<LicenseResult>
```
返回 `{ type: 'success', reason: string }``{ type: 'error', message: string }`
**缓存策略**10 分钟有效期,有效期内不发起网络请求。
### getStatus
```typescript
LicenseSDK.getStatus(): Promise<'ok' | 'denied' | 'unknown'>
```
### getDeviceId
```typescript
LicenseSDK.getDeviceId(): Promise<string>
```
### clear
```typescript
LicenseSDK.clear(): Promise<void>
```
---
## 6. 数据存储
| 数据 | 存储方式 |
|------|---------|
| deviceId | `preferences`(应用持久化存储)|
| token | `preferences` |
| 授权状态 | `preferences` |
| statusTime | `preferences` |
---
## 7. 离线模式
- 首次激活需要网络连接
- 激活后 10 分钟缓存内可离线使用
- 网络异常时,若历史缓存成功,继续返回授权通过
---
## 8. 权限配置
使用 License SDK 需要网络权限,确保 `module.json5` 中已声明:
```json5
{
"module": {
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },
{ "name": "ohos.permission.GET_NETWORK_INFO" }
]
}
}
```

查看文件

@ -111,4 +111,3 @@ const session = await XuqmSDK.login('user_001', 'your_user_sig_jwt')
## 下一步 ## 下一步
- [HarmonyOS IM 接入 →](./im) - [HarmonyOS IM 接入 →](./im)
- [HarmonyOS 授权管理 →](./license)

查看文件

@ -1,190 +0,0 @@
# iOS 授权管理License SDK
**模块**`LicenseSDK`(包含在 `XuqmSDK` 中)· **最低 iOS 版本**iOS 16
License SDK 用于设备授权注册与验证,Android / iOS / RN / Flutter 共用同一个 AppKey,设备数量统一计算。
---
## 快速接入
### 1. 添加依赖
License 模块已内置于 `XuqmSDK`,无需额外安装包:
```swift
// Package.swift 或 Xcode → File → Add Package Dependencies
// URL: https://xuqinmin.com/xuqinmin12/XuqmGroup-iOSSDK
import XuqmSDK
```
### 2. 放置 License 文件
从租户平台下载 `.xuqmlicense` 加密文件,放入 App Bundle
```
MyApp/Resources/xuqm/license.xuqm
```
在 Xcode 中将该文件添加到 Target → Build Phases → Copy Bundle Resources,确保它出现在 App Bundle 中。SDK 会自动读取 `Bundle.main` 中的 `xuqm/license.xuqm`
### 3. 检查授权
SDK **无需手动初始化**,首次调用 `checkLicense()` 时自动读取并解密 License 文件:
```swift
import XuqmSDK
Task {
let result = await LicenseSDK.shared.checkLicense()
switch result {
case .success(let reason):
print("授权通过:\(reason)")
case .error(let message):
print("授权失败:\(message)")
}
}
```
---
## API 说明
### checkLicense
```swift
func checkLicense(userInfo: LicenseUserInfo? = nil) async -> LicenseResult
```
**内部逻辑**
1. 检查本地缓存(默认 10 分钟有效期)
2. 缓存有效 → 直接返回 `.success`
3. 缓存过期或无缓存:
- 有 Token → 调用验证接口
- 无 Token 或验证失败 → 调用注册接口
4. 网络异常且缓存曾成功 → 返回 `.success("Offline - cached ok")`
5. 网络异常且无缓存 → 返回 `.error`
### getStatus
```swift
func getStatus() -> LicenseStatus // .ok / .denied / .unknown
```
同步查询当前状态(不发起网络请求)。
### getDeviceId
```swift
func getDeviceId() -> String?
```
### clear
```swift
func clear()
```
清除所有本地授权数据token、deviceId、状态
---
## 携带用户信息
注册时可携带业务用户信息,方便在租户平台进行设备管理:
```swift
let userInfo = LicenseUserInfo(
userId: "user_001",
name: "张三",
email: "zhangsan@company.com"
)
let result = await LicenseSDK.shared.checkLicense(userInfo: userInfo)
```
---
## 设备唯一码
| 优先级 | 来源 | 说明 |
|--------|------|------|
| 1 | `UIDevice.identifierForVendor` | 同一 App 卸载重装不变(同 Vendor|
| 2 | 首次生成 UUID 存入 Keychain | identifierForVendor 不可用时 |
---
## 数据存储
| 数据 | 存储方式 | 说明 |
|------|----------|------|
| deviceId | KeychainSecurity framework| 加密持久化 |
| token | Keychain | 加密持久化 |
| 授权状态 | UserDefaultsSuite: `xuqm_license`| 非敏感 |
| statusTime | UserDefaults | 缓存有效期判断 |
---
## 离线模式
- 首次激活需要网络连接
- 激活成功后,10 分钟缓存内可离线使用
- 网络异常时,若历史缓存成功,继续返回授权通过
---
## 手动初始化(高级用法)
```swift
// 不使用 License 文件,手动指定 AppKey
LicenseSDK.shared.initialize(
appKey: "your_app_key",
baseUrl: "https://auth.dev.xuqinmin.com",
deviceName: "我的 iPhone"
)
```
---
## 完整示例
```swift
import XuqmSDK
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task { await checkLicense() }
}
}
func checkLicense() async {
let result = await LicenseSDK.shared.checkLicense(
userInfo: LicenseUserInfo(userId: "user_001")
)
switch result {
case .success:
break // 授权通过,正常使用
case .error(let msg):
// 提示用户,限制功能入口
print("License 验证失败: \(msg)")
}
}
}
```
---
## 常见问题
**Q: checkLicense 返回 error 怎么办?**
检查:
- License 文件是否已添加到 Build Phases → Copy Bundle Resources
- 文件路径是否为 `xuqm/license.xuqm`(大小写敏感)
- 设备是否有网络连接(首次激活需要网络)
- License 是否已过期或被管理员禁用
**Q: 不同平台可以用同一个 License 吗?**
可以,所有平台共用同一个 AppKey,设备数量统一计算。

查看文件

@ -111,4 +111,3 @@ XuqmSDK.shared.initialize(config: config)
- [iOS IM 接入 →](./im) - [iOS IM 接入 →](./im)
- [iOS Push 接入 →](./push) - [iOS Push 接入 →](./push)
- [iOS 版本更新 →](./update) - [iOS 版本更新 →](./update)
- [iOS 授权管理 →](./license)

查看文件

@ -14,7 +14,6 @@
| `@xuqm/rn-im` | 单聊、群聊、消息收发、本地 DBWatermelonDB| | `@xuqm/rn-im` | 单聊、群聊、消息收发、本地 DBWatermelonDB|
| `@xuqm/rn-push` | 推送设备 Token 上报 | | `@xuqm/rn-push` | 推送设备 Token 上报 |
| `@xuqm/rn-update` | App 版本检查、RN Bundle 热更新 | | `@xuqm/rn-update` | App 版本检查、RN Bundle 热更新 |
| `@xuqm/rn-license` | 设备授权注册与验证License SDK|
| `@xuqm/rn-sdk` | 内部基础包,随 IM / Push / Update 自动安装,不建议业务方直接引用 | | `@xuqm/rn-sdk` | 内部基础包,随 IM / Push / Update 自动安装,不建议业务方直接引用 |
--- ---

查看文件

@ -1,170 +0,0 @@
# React Native 授权管理License SDK
**包名**`@xuqm/rn-license` · **依赖**`react-native-quick-crypto`peer dep
---
## 1. 安装
```bash
yarn add @xuqm/rn-license react-native-quick-crypto
```
iOS 需要执行 `pod install`
```bash
cd ios && pod install
```
---
## 2. 放置 License 文件
从租户平台下载 `.xuqmlicense` 加密文件,通过 `require()` 将其作为文本资源嵌入 App。
```ts
// 将 license.xuqm 放入 src/assets/
const licenseContent = require('./assets/license.xuqm')
```
---
## 3. 检查授权
```ts
import { initializeFromFile, checkLicense } from '@xuqm/rn-license'
// 启动时从加密文件自动初始化
const licenseContent = require('./assets/license.xuqm')
await initializeFromFile(licenseContent)
// 检查授权(有缓存时直接返回,否则网络验证)
const result = await checkLicense()
if (result.type === 'success') {
console.log('授权通过:', result.reason)
} else {
console.warn('授权失败:', result.message)
}
```
---
## 4. 携带用户信息
```ts
import type { LicenseUserInfo } from '@xuqm/rn-license'
const userInfo: LicenseUserInfo = {
userId: 'user_001',
name: '张三',
email: 'zhangsan@company.com',
}
const result = await checkLicense(userInfo)
```
---
## 5. API 说明
### initializeFromFile
```ts
initializeFromFile(encryptedContent: string): Promise<void>
```
从加密 License 文件内容自动解析 appKey 和 baseUrl 并初始化。
### checkLicense
```ts
checkLicense(userInfo?: LicenseUserInfo): Promise<LicenseResult>
```
返回 `{ type: 'success', reason: string }``{ type: 'error', message: string }`
**缓存策略**10 分钟有效期,有效期内直接返回缓存结果(不发起网络请求)。
### getStatus
```ts
getStatus(): Promise<LicenseStatus> // 'ok' | 'denied' | 'unknown'
```
### getDeviceId
```ts
getDeviceId(): Promise<string>
```
### clear
```ts
clear(): Promise<void>
```
---
## 6. 数据存储
| 数据 | 存储方式 |
|------|---------|
| deviceId | AsyncStorage |
| token | AsyncStorage |
| 授权状态 | AsyncStorage |
| statusTime | AsyncStorage |
---
## 7. 离线模式
- 首次激活需要网络连接
- 激活后 10 分钟缓存内可离线使用
- 网络异常时,若历史缓存成功,继续返回授权通过
---
## 8. 完整示例
```ts
import React, { useEffect, useState } from 'react'
import { View, Text } from 'react-native'
import { initializeFromFile, checkLicense } from '@xuqm/rn-license'
export default function App() {
const [licensed, setLicensed] = useState<boolean | null>(null)
useEffect(() => {
async function verify() {
const content = require('./assets/license.xuqm')
await initializeFromFile(content)
const result = await checkLicense({ userId: 'user_001' })
setLicensed(result.type === 'success')
}
verify()
}, [])
if (licensed === null) return <Text>验证中...</Text>
if (!licensed) return <Text>授权验证失败,请联系管理员</Text>
return <View>{ /* 主界面 */ }</View>
}
```
---
## 9. 常见问题
**Q: 提示 `react-native-quick-crypto not available`?**
确认已安装 `react-native-quick-crypto` 并执行了 `pod install`iOS或重新 buildAndroid
**Q: License 文件如何用 require 加载?**
需要在 Metro 配置中将 `.xuqm` 加入 `assetExts`
```js
// metro.config.js
const config = {
resolver: {
assetExts: [...defaultConfig.resolver.assetExts, 'xuqm'],
},
}
```

查看文件

@ -97,7 +97,6 @@ allprojects {
├── @xuqm/rn-im ← IM 模块(依赖 WatermelonDB ├── @xuqm/rn-im ← IM 模块(依赖 WatermelonDB
├── @xuqm/rn-push ← Push 模块 ├── @xuqm/rn-push ← Push 模块
├── @xuqm/rn-update ← 更新模块 ├── @xuqm/rn-update ← 更新模块
├── @xuqm/rn-license ← 授权管理模块(依赖 react-native-quick-crypto
└── @xuqm/rn-bugcollect ← 异常采集模块崩溃、ANR 上报) └── @xuqm/rn-bugcollect ← 异常采集模块崩溃、ANR 上报)
``` ```
@ -146,4 +145,3 @@ yarn add @xuqm/rn-bugcollect
- [RN 群聊 →](./group) - [RN 群聊 →](./group)
- [RN 推送接入 →](./push) - [RN 推送接入 →](./push)
- [RN 版本更新 →](./update) - [RN 版本更新 →](./update)
- [RN 授权管理 →](./license)

查看文件

@ -55,64 +55,34 @@ await UpdateSDK.openStore(appUpdate.appStoreUrl, appUpdate.marketUrl)
--- ---
## 4. RN Bundle 热更新 ## 4. RN 插件更新
### 4.1 注册插件 插件更新以服务端返回的 release-set 为最小事务单位。一个 release-set 只包含目标
app/buz 及其 Common 依赖;SDK 会统一完成签名、宿主身份、整包版本、原生基线、
SHA-256 和最终依赖闭包校验。宿主不能分别下载后自行拼接版本。
在插件 Bundle 的入口文件顶部注册插件元数据: 进入 buz 前自动检查并安装
```ts ```ts
// plugin/index.ts await UpdateSDK.checkAndInstallPlugin('home', {
import { UpdateSDK } from '@xuqm/rn-update' onProgress(moduleId, progress) {
import meta from './plugin.json' console.log(moduleId, progress.percent)
},
UpdateSDK.registerPlugin(meta) })
``` ```
`plugin.json` 示例 需要由宿主展示确认弹窗时,检查与安装分开调用
```json ```ts
{ const plan = await UpdateSDK.checkPluginRelease('home')
"moduleId": "home", if (plan && (await showPluginUpdateDialog(plan))) {
"version": "1.2.3" await UpdateSDK.installPluginRelease(plan)
} }
``` ```
### 4.2 检查热更新 `checkPluginRelease` 只检查,不下载、不修改本地状态。安装阶段会再次验证签名和检查时
记录的本地基线;计划已过期时必须重新检查。插件版本、路径和事务状态由原生层统一
```ts 管理,宿主不得另建一套缓存版本表。
const rnUpdate = await UpdateSDK.checkRnUpdate('home')
if (rnUpdate.needsUpdate) {
console.log('最新版本:', rnUpdate.latestVersion)
console.log('下载地址:', rnUpdate.downloadUrl)
console.log('更新说明:', rnUpdate.note)
console.log('最低依赖版本:', rnUpdate.minCommonVersion)
}
```
### 4.3 下载并缓存 Bundle
```ts
// 下载 Bundle 源码
const source = await UpdateSDK.downloadRnBundle(rnUpdate.downloadUrl)
// 缓存到本地
await UpdateSDK.cacheRnBundle('home', rnUpdate.latestVersion, rnUpdate.md5, source)
// 读取已缓存的 Bundle
const cached = await UpdateSDK.getCachedRnBundle('home')
if (cached) {
console.log('缓存版本:', cached.version)
console.log('缓存时间:', cached.downloadedAt)
}
```
### 4.4 获取已注册插件版本
```ts
const version = UpdateSDK.getRegisteredPluginVersion('home')
console.log('当前运行版本:', version)
```
--- ---
@ -160,30 +130,32 @@ await XuqmSDK.login({ userId: 'user_001', userSig: 'your_user_sig_jwt' })
SDK 提供 `xuqm_release.mjs` 脚本,可一键完成 RN Bundle 构建并上传至 XuqmGroup 版本管理服务: SDK 提供 `xuqm_release.mjs` 脚本,可一键完成 RN Bundle 构建并上传至 XuqmGroup 版本管理服务:
```bash ```bash
node scripts/xuqm_release.mjs --platform android --env production XUQM_API_TOKEN=*** node scripts/xuqm_release.mjs --platform android --publish
``` ```
脚本执行流程: 脚本执行流程:
1. 执行 `react-native bundle` 构建 JS Bundle 1. 按模块构建 JS/Hermes Bundle 和 Source Map
2. 自动提取模块 ID、版本号、更新日志 2. 生成根目录唯一的 `rn-manifest.json`
3. 上传 Bundle 到 XuqmGroup 后台 3. 生成 `<moduleId>.<platform>.xuqm.zip`
4. 返回发布结果 4. 上传归档并由服务端校验、签名
5. 按用户选择发布,返回不可变发布记录
常用参数: 常用参数:
| 参数 | 说明 | | 参数 | 说明 |
|------|------| |------|------|
| `--platform` | 平台:`android` / `ios` | | `--platform` | 平台:`android` / `ios` |
| `--env` | 环境:`production` / `staging` | | `--module` | 可重复传入,只构建或发布指定模块 |
| `--moduleId` | 模块 ID`home`| | `--note` | 本次发布说明 |
| `--forceUpdate` | 是否强制更新 | | `--publish` | 上传后立即发布 |
| `--bugcollect` | `platform` 上传 Source Map;`disabled` 仅保留本地产物 |
`package.json` 中添加快捷命令: `package.json` 中添加快捷命令:
```json ```json
{ {
"scripts": { "scripts": {
"xuqm:release": "node scripts/xuqm_release.mjs --platform android --env production" "xuqm:release": "node scripts/xuqm_release.mjs --platform android --publish"
} }
} }
``` ```

查看文件

@ -0,0 +1,26 @@
# 租户平台 SDK V2 页面接手说明
SDK 平台的完整服务端契约见 Server 仓库 `docs/SDK_PLATFORM_V2_HANDOFF.md`
当前 Web 变更:
- 应用详情展示 Config 的配置 ID、版本、签发时间和到期时间。
- 重新生成 Config 时必须明确选择长期有效或指定到期时间;指定时间必须晚于当前时间,并以 ISO 8601 UTC 传给服务端。
- BugCollect 页面统一展示 `RN_SOURCEMAP``R8_MAPPING`,手动上传示例使用 `/bugcollect/v1/artifacts/upload`
- 产物上传示例只使用 `Authorization: Bearer XUQM_API_TOKEN`,不得把 Token 放入 URL。
- 已删除废弃的离线 license 服务页面、路由、API、菜单和日志入口。
- 依赖管理统一为根 `package.json` 声明的 Yarn 1 与 `yarn.lock`
- `Jenkinsfile.tenant-web` 默认 development,仅验证;production 需要人工确认才允许推送、部署和提交版本号。
验证:
```bash
yarn install --frozen-lockfile
yarn build:tenant
yarn build:ops
yarn workspace @xuqm/docs-site build
```
2026-07-26 三项构建均已通过。
未执行提交、推送、Jenkins 或部署。

查看文件

@ -13,7 +13,6 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
@ -31,6 +30,7 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton']

查看文件

@ -58,11 +58,6 @@
<el-dialog v-model="showReview" :title="reviewAction === 'approve' ? '通过申请' : '拒绝申请'" width="420px"> <el-dialog v-model="showReview" :title="reviewAction === 'approve' ? '通过申请' : '拒绝申请'" width="420px">
<el-form> <el-form>
<el-form-item v-if="reviewAction === 'approve' && currentRequest?.serviceType === 'LICENSE'" label="有效期">
<el-date-picker v-model="expiresAt" type="datetime" placeholder="选择过期时间(可选,留空为永久)"
style="width:100%" value-format="YYYY-MM-DDTHH:mm:ss" :disabled-date="(date: Date) => date.getTime() < Date.now() - 86400000" />
<div class="el-text--info" style="font-size:12px;margin-top:4px">设置后不可修改留空表示永久有效</div>
</el-form-item>
<el-form-item label="审核备注"> <el-form-item label="审核备注">
<el-input v-model="reviewNote" type="textarea" :rows="3" placeholder="可选备注" /> <el-input v-model="reviewNote" type="textarea" :rows="3" placeholder="可选备注" />
</el-form-item> </el-form-item>
@ -92,7 +87,6 @@ const total = ref(0)
const showReview = ref(false) const showReview = ref(false)
const reviewAction = ref<'approve' | 'reject'>('approve') const reviewAction = ref<'approve' | 'reject'>('approve')
const reviewNote = ref('') const reviewNote = ref('')
const expiresAt = ref('')
const currentRequest = ref<ServiceRequest | null>(null) const currentRequest = ref<ServiceRequest | null>(null)
const submitting = ref(false) const submitting = ref(false)
@ -113,7 +107,6 @@ function openReview(row: ServiceRequest, action: 'approve' | 'reject') {
currentRequest.value = row currentRequest.value = row
reviewAction.value = action reviewAction.value = action
reviewNote.value = '' reviewNote.value = ''
expiresAt.value = ''
showReview.value = true showReview.value = true
} }
@ -122,8 +115,7 @@ async function submitReview() {
submitting.value = true submitting.value = true
try { try {
if (reviewAction.value === 'approve') { if (reviewAction.value === 'approve') {
const exp = currentRequest.value.serviceType === 'LICENSE' ? expiresAt.value || undefined : undefined await opsApi.approveRequest(currentRequest.value.id, reviewNote.value)
await opsApi.approveRequest(currentRequest.value.id, reviewNote.value, exp)
ElMessage.success('已通过申请,服务已开通') ElMessage.success('已通过申请,服务已开通')
} else { } else {
await opsApi.rejectRequest(currentRequest.value.id, reviewNote.value) await opsApi.rejectRequest(currentRequest.value.id, reviewNote.value)

4746
package-lock.json 自动生成的

文件差异内容过多而无法显示 加载差异

查看文件

@ -2,7 +2,7 @@
## 项目定位 ## 项目定位
XuqmGroup 租户管理平台 Web 前端。Vue3 + TypeScript + Element Plus,管理应用、IM、推送、版本、License 等功能。 XuqmGroup 租户管理平台 Web 前端。Vue3 + TypeScript + Element Plus,管理应用、IM、推送、版本和崩溃收集等功能。
- Git 远端:`https://xuqinmin.com/xuqmGroup/XuqmGroup-Web.git`(子目录 `tenant-platform` - Git 远端:`https://xuqinmin.com/xuqmGroup/XuqmGroup-Web.git`(子目录 `tenant-platform`
- 技术栈Vue 3.5 + TypeScript 5.8 + Vite 6 + Element Plus 2.9 + Pinia 3 + Vue Router 4 - 技术栈Vue 3.5 + TypeScript 5.8 + Vite 6 + Element Plus 2.9 + Pinia 3 + Vue Router 4
@ -35,7 +35,6 @@ tenant-platform/
| `views/im/` | IM 管理(配置 / Webhook / 告警) | | `views/im/` | IM 管理(配置 / Webhook / 告警) |
| `views/push/` | 推送管理(配置 / 设备管理) | | `views/push/` | 推送管理(配置 / 设备管理) |
| `views/update/` | 版本管理APK / RN Bundle | | `views/update/` | 版本管理APK / RN Bundle |
| `views/license/` | License 管理 |
| `views/logs/` | 操作日志 | | `views/logs/` | 操作日志 |
| `views/accounts/` | 子账号管理 | | `views/accounts/` | 子账号管理 |
| `views/security/` | 安全中心 | | `views/security/` | 安全中心 |
@ -56,7 +55,6 @@ tenant-platform/
| `/apps/:appKey/im-config` | IM 配置 | | `/apps/:appKey/im-config` | IM 配置 |
| `/apps/:appKey/push-config` | 推送配置 | | `/apps/:appKey/push-config` | 推送配置 |
| `/apps/:appKey/update` | 版本管理 | | `/apps/:appKey/update` | 版本管理 |
| `/apps/:appKey/license` | License 管理 |
| `/operation-logs` | 操作日志 | | `/operation-logs` | 操作日志 |
| `/accounts` | 子账号 | | `/accounts` | 子账号 |
| `/security` | 安全中心 | | `/security` | 安全中心 |
@ -70,7 +68,6 @@ tenant-platform/
| `api/im.ts` | im-service | | `api/im.ts` | im-service |
| `api/push.ts` | push-service | | `api/push.ts` | push-service |
| `api/update.ts` | update-service | | `api/update.ts` | update-service |
| `api/license.ts` | license-service |
| `api/account.ts` | tenant-service子账号 | | `api/account.ts` | tenant-service子账号 |
| `api/dashboard.ts` | tenant-service统计 | | `api/dashboard.ts` | tenant-service统计 |
| `api/system.ts` | tenant-service系统 | | `api/system.ts` | tenant-service系统 |
@ -80,7 +77,6 @@ tenant-platform/
```ts ```ts
proxy: { proxy: {
'/api/license': { target: 'http://127.0.0.1:8085' },
'/api/push': { target: 'http://127.0.0.1:8083' }, '/api/push': { target: 'http://127.0.0.1:8083' },
'/api': { target: 'http://127.0.0.1:8081' }, '/api': { target: 'http://127.0.0.1:8081' },
} }

查看文件

@ -42,13 +42,15 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress'] ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSlider: typeof import('element-plus/es')['ElSlider'] ElSlider: typeof import('element-plus/es')['ElSlider']
ElSpace: typeof import('element-plus/es')['ElSpace'] ElSpace: typeof import('element-plus/es')['ElSpace']
ElStatistic: typeof import('element-plus/es')['ElStatistic'] ElStatistic: typeof import('element-plus/es')['ElStatistic']

查看文件

@ -11,9 +11,21 @@ export interface App {
iconUrl?: string iconUrl?: string
appKey: string appKey: string
appSecret: string appSecret: string
configFileId?: string | null
configFileRevision?: number | null
configFileIssuedAt?: string | null
configFileExpiresAt?: string | null
createdAt: string createdAt: string
} }
export interface ConfigFileMetadata {
configId: string
revision: number
issuedAt: string
expiresAt: string | null
longTerm: boolean
}
export interface CreateAppRequest { export interface CreateAppRequest {
packageName?: string packageName?: string
iosBundleId?: string iosBundleId?: string
@ -27,7 +39,7 @@ export interface FeatureService {
id: string id: string
appKey: string appKey: string
platform: 'ANDROID' | 'IOS' | 'HARMONY' platform: 'ANDROID' | 'IOS' | 'HARMONY'
serviceType: 'IM' | 'PUSH' | 'UPDATE' | 'LICENSE' | 'BUG_COLLECT' serviceType: 'IM' | 'PUSH' | 'UPDATE' | 'BUG_COLLECT'
enabled: boolean enabled: boolean
config?: string | null config?: string | null
createdAt: string createdAt: string
@ -205,16 +217,16 @@ export const appApi = {
downloadConfigFile: (appKey: string) => downloadConfigFile: (appKey: string) =>
client.get<Blob>(`/apps/${appKey}/config-file`, { responseType: 'blob' }), client.get<Blob>(`/apps/${appKey}/config-file`, { responseType: 'blob' }),
regenerateConfigFile: (appKey: string) => regenerateConfigFile: (appKey: string, data: { longTerm: boolean; expiresAt: string | null }) =>
client.post<{ data: null }>(`/apps/${appKey}/config-file/regenerate`), client.post<{ data: ConfigFileMetadata }>(`/apps/${appKey}/config-file/regenerate`, data),
requestActivation: (appKey: string, serviceType: 'IM' | 'PUSH' | 'UPDATE' | 'LICENSE' | 'BUG_COLLECT', reason: string) => requestActivation: (appKey: string, serviceType: 'IM' | 'PUSH' | 'UPDATE' | 'BUG_COLLECT', reason: string) =>
client.post<{ data: null }>(`/apps/${appKey}/services/request-activation`, null, { client.post<{ data: null }>(`/apps/${appKey}/services/request-activation`, null, {
params: { platform: 'ANDROID', serviceType, applyReason: reason }, params: { platform: 'ANDROID', serviceType, applyReason: reason },
}), }),
parseConfigFile: (content: string) => parseConfigFile: (content: string) =>
client.post<{ data: { appKey: string; appName: string; packageName: string; iosBundleId: string; harmonyBundleName: string; baseUrl: string; serverUrl: string } }>( client.post<{ data: { schemaVersion: number; configId: string; revision: number; issuedAt: string; expiresAt: string | null; appKey: string; appName: string; packageName: string; iosBundleId: string; harmonyBundleName: string; serverUrl: string } }>(
'/apps/config/parse', '/apps/config/parse',
{ content }, { content },
), ),

查看文件

@ -106,8 +106,14 @@ export interface BugCollectSourcemap {
appKey: string appKey: string
platform: string platform: string
appVersion: string appVersion: string
bundleName: string
buildId: string | null buildId: string | null
moduleId: string | null
moduleVersion: string | null
bundleHash: string | null
artifactType: 'RN_SOURCEMAP' | 'R8_MAPPING'
artifactHash: string | null
contentHash: string
storageKey: string
uploadedAt: string uploadedAt: string
} }

查看文件

@ -1,139 +0,0 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { isJwtExpired } from '@/utils/jwt'
const licenseClient = axios.create({
baseURL: import.meta.env.VITE_LICENSE_API_BASE_URL ?? '',
timeout: 15000,
})
if (import.meta.env.DEV) {
licenseClient.interceptors.request.use((config) => {
console.debug('[tenant-platform][License] request', {
method: config.method?.toUpperCase(),
url: config.baseURL ? `${config.baseURL}${config.url ?? ''}` : config.url,
params: config.params,
})
return config
})
licenseClient.interceptors.response.use((res) => {
console.debug('[tenant-platform][License] response', {
status: res.status,
url: res.config.url,
})
return res
})
}
licenseClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token && !isJwtExpired(token)) {
config.headers.Authorization = `Bearer ${token}`
} else if (token && isJwtExpired(token)) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录'))
}
return Promise.reject(new Error('登录已失效,请重新登录'))
}
return config
})
licenseClient.interceptors.response.use(
(res) => res,
(error) => {
const status = error.response?.status
if (status === 401) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
ElMessage.error('登录已失效,请重新登录')
return Promise.reject(error)
}
if (status === 403) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录'))
}
ElMessage.error(error.response?.data?.message ?? '登录已失效,请重新登录')
return Promise.reject(error)
}
const msg = error.response?.data?.message ?? '授权请求失败'
ElMessage.error(msg)
return Promise.reject(error)
},
)
export interface AppLicense {
id: string
name: string
maxDevices: number
registeredDevices: number
expiresAt?: string | null
isActive: boolean
remark?: string | null
createdAt: string
updatedAt: string
}
export interface LicenseDevice {
id: string
appKey: string
deviceId: string
deviceName?: string | null
deviceModel?: string | null
deviceVendor?: string | null
osVersion?: string | null
userId?: string | null
userName?: string | null
userEmail?: string | null
userPhone?: string | null
userInfoJson?: string | null
registeredAt: string
lastVerifiedAt?: string | null
isActive: boolean
createdAt: string
}
export interface LicenseOperationLog {
id: string
appKey: string
operator: string
action: string
resourceType: string
resourceId?: string | null
summary?: string | null
detailJson?: string | null
createdAt: string
}
export const licenseApi = {
getAppLicense(appKey: string) {
return licenseClient.get<{ data: { license: AppLicense; devices: LicenseDevice[] } }>(
`/api/license/admin/apps/${encodeURIComponent(appKey)}`,
)
},
updateAppLicense(appKey: string, data: { maxDevices?: number; isActive?: boolean; remark?: string; expiresAt?: string }) {
return licenseClient.patch<{ data: AppLicense }>(
`/api/license/admin/apps/${encodeURIComponent(appKey)}`,
data,
)
},
revokeDevice(id: string) {
return licenseClient.delete<{ data: null }>(`/api/license/admin/devices/${encodeURIComponent(id)}`)
},
reactivateDevice(id: string) {
return licenseClient.put<{ data: null }>(`/api/license/admin/devices/${encodeURIComponent(id)}/reactivate`)
},
getOperationLogs(appKey: string, page = 0, size = 20) {
return licenseClient.get<{ data: { content: LicenseOperationLog[]; total: number; totalPages: number } }>(
`/api/license/admin/operation-logs`, { params: { appKey, page, size } },
)
},
}

查看文件

@ -43,8 +43,7 @@ updateClient.interceptors.request.use((config) => {
url.startsWith('/api/v1/updates/app/check') || url.startsWith('/api/v1/updates/app/check') ||
url.startsWith('/api/v1/updates/app/inspect') || url.startsWith('/api/v1/updates/app/inspect') ||
url.startsWith('/api/v1/updates/public/') || url.startsWith('/api/v1/updates/public/') ||
url.startsWith('/api/v1/rn/update/check') || url.startsWith('/api/v1/rn/release-set/check') ||
url.startsWith('/api/v1/rn/inspect') ||
url.startsWith('/api/v1/rn/files/') url.startsWith('/api/v1/rn/files/')
) )
if (!skipAuth) { if (!skipAuth) {
@ -186,12 +185,21 @@ export interface AppPackageInspectResult {
export interface RnBundle { export interface RnBundle {
id: string id: string
appKey: string appKey: string
packageName: string
moduleId: string moduleId: string
platform: 'ANDROID' | 'IOS' | 'HARMONY' type: 'common' | 'app' | 'buz'
platform: 'ANDROID' | 'IOS'
version: string version: string
md5: string appVersionRange: string
minCommonVersion?: string builtAgainstNativeBaselineId: string
packageName?: string buildId: string
bundleFormat: string
commonVersionRange?: string
minNativeApiLevel: number
bundleSha256: string
archiveSha256: string
keyId: string
signature: string
note?: string note?: string
publishStatus: 'DRAFT' | 'PUBLISHED' | 'DEPRECATED' publishStatus: 'DRAFT' | 'PUBLISHED' | 'DEPRECATED'
publishMode?: PublishMode publishMode?: PublishMode
@ -203,16 +211,6 @@ export interface RnBundle {
createdAt: string createdAt: string
} }
export interface RnBundleInspectResult {
moduleId?: string
platform?: 'ANDROID' | 'IOS' | 'HARMONY'
version?: string
minCommonVersion?: string
packageName?: string
fileName?: string
detected: boolean
}
export interface UnifiedAppUploadItem { export interface UnifiedAppUploadItem {
fileKey: string fileKey: string
platform: 'ANDROID' | 'IOS' | 'HARMONY' platform: 'ANDROID' | 'IOS' | 'HARMONY'
@ -226,19 +224,8 @@ export interface UnifiedAppUploadItem {
publishImmediately: boolean publishImmediately: boolean
} }
export interface UnifiedRnUploadItem {
fileKey: string
moduleId: string
platform: 'ANDROID' | 'IOS' | 'HARMONY'
version: string
minCommonVersion?: string
packageName?: string
note?: string
}
export interface UnifiedReleaseManifest { export interface UnifiedReleaseManifest {
appVersions: UnifiedAppUploadItem[] appVersions: UnifiedAppUploadItem[]
rnBundles: UnifiedRnUploadItem[]
} }
export interface DownloadPageHistoryItem { export interface DownloadPageHistoryItem {
@ -342,10 +329,6 @@ export const updateAdminApi = {
return updateClient.post<{ data: RnBundle }>('/api/v1/rn/upload', formData, uploadProgressConfig(onProgress)) return updateClient.post<{ data: RnBundle }>('/api/v1/rn/upload', formData, uploadProgressConfig(onProgress))
}, },
inspectRnBundle(formData: FormData, onProgress?: UploadProgressHandler) {
return updateClient.post<{ data: RnBundleInspectResult }>('/api/v1/rn/inspect', formData, uploadProgressConfig(onProgress))
},
uploadUnifiedRelease(formData: FormData, onProgress?: UploadProgressHandler) { uploadUnifiedRelease(formData: FormData, onProgress?: UploadProgressHandler) {
return updateClient.post('/api/v1/updates/unified/upload', formData, uploadProgressConfig(onProgress)) return updateClient.post('/api/v1/updates/unified/upload', formData, uploadProgressConfig(onProgress))
}, },

查看文件

@ -3,7 +3,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string readonly VITE_API_BASE_URL: string
readonly VITE_FILE_SERVICE_URL: string readonly VITE_FILE_SERVICE_URL: string
readonly VITE_LICENSE_API_BASE_URL: string
readonly BASE_URL: string readonly BASE_URL: string
} }

查看文件

@ -85,10 +85,6 @@ const router = createRouter({
path: 'apps/:appKey/update', path: 'apps/:appKey/update',
component: () => import('@/views/update/VersionManagementView.vue'), component: () => import('@/views/update/VersionManagementView.vue'),
}, },
{
path: 'apps/:appKey/license',
component: () => import('@/views/license/LicenseManagementView.vue'),
},
{ {
path: 'services/im/:appKey?', path: 'services/im/:appKey?',
component: () => import('@/views/im/ImManagementView.vue'), component: () => import('@/views/im/ImManagementView.vue'),
@ -101,10 +97,6 @@ const router = createRouter({
path: 'services/update/:appKey?', path: 'services/update/:appKey?',
component: () => import('@/views/update/VersionManagementView.vue'), component: () => import('@/views/update/VersionManagementView.vue'),
}, },
{
path: 'services/license/:appKey?',
component: () => import('@/views/license/LicenseManagementView.vue'),
},
{ {
path: 'services/bugcollect/:appKey?', path: 'services/bugcollect/:appKey?',
component: () => import('@/views/bug-collect/BugCollectOverview.vue'), component: () => import('@/views/bug-collect/BugCollectOverview.vue'),

查看文件

@ -31,7 +31,12 @@
<el-descriptions-item label="简述" :span="2">{{ app.description || '-' }}</el-descriptions-item> <el-descriptions-item label="简述" :span="2">{{ app.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="Config 文件"> <el-descriptions-item label="Config 文件">
<el-button size="small" type="primary" plain @click="downloadConfigFile">下载</el-button> <el-button size="small" type="primary" plain @click="downloadConfigFile">下载</el-button>
<el-button size="small" plain @click="regenerateConfigFile" :loading="regenerating">重新生成</el-button> <el-button size="small" plain @click="openRegenerateDialog">重新生成</el-button>
<div class="config-metadata">
<span>版本{{ app.configFileRevision ?? '尚未生成' }}</span>
<span>签发{{ app.configFileIssuedAt ? formatTime(app.configFileIssuedAt) : '-' }}</span>
<span>有效期{{ app.configFileExpiresAt ? formatTime(app.configFileExpiresAt) : '长期有效' }}</span>
</div>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@ -138,40 +143,6 @@
</div> </div>
</el-card> </el-card>
<el-card class="info-card" style="margin-top:16px">
<template #header>授权管理</template>
<div class="service-grid">
<el-card class="service-card">
<div class="service-header">
<div class="service-title-block">
<span class="service-name">{{ serviceLabel('LICENSE') }}</span>
<span class="service-help">{{ serviceHelp('LICENSE') }}</span>
</div>
<el-switch
:model-value="isServiceEnabled('LICENSE')"
@change="(val: boolean) => onToggleService('LICENSE', val)"
/>
</div>
<div class="service-status-row">
<el-tag :type="isServiceEnabled('LICENSE') ? 'success' : 'info'" size="small">
{{ isServiceEnabled('LICENSE') ? '已开通' : '未开通' }}
</el-tag>
<span class="service-status-text">
管理 PAD 设备的授权注册与验证
</span>
</div>
<div class="service-actions">
<el-button v-if="isServiceEnabled('LICENSE')" size="small" type="primary" plain @click="$router.push(`/apps/${app.appKey}/license`)">
授权管理
</el-button>
<el-button v-else size="small" type="primary" plain @click="openActivationRequest('LICENSE')">
申请开通
</el-button>
</div>
</el-card>
</div>
</el-card>
<el-card class="info-card" style="margin-top:16px"> <el-card class="info-card" style="margin-top:16px">
<template #header>崩溃收集</template> <template #header>崩溃收集</template>
<div class="service-grid"> <div class="service-grid">
@ -222,7 +193,7 @@
</div> </div>
</template> </template>
<p style="color:#606266;font-size:13px;margin-bottom:12px"> <p style="color:#606266;font-size:13px;margin-bottom:12px">
API Key 用于外部工具Postman / CI/CD调用平台 API 进行认证支持 update / push / im / license 等所有服务每个应用最多 8 创建后仅显示一次 API Key 用于外部工具Postman / CI/CD调用平台 API 进行认证支持 update / push / im / bugcollect 服务每个应用最多 8 创建后仅显示一次
</p> </p>
<el-table :data="apiKeys" v-loading="loadingApiKeys" border stripe> <el-table :data="apiKeys" v-loading="loadingApiKeys" border stripe>
<el-table-column prop="name" label="名称" min-width="120" /> <el-table-column prop="name" label="名称" min-width="120" />
@ -333,6 +304,43 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="showRegenerateDialog" title="重新生成 Config 文件" :width="dialogWidth">
<el-alert
title="重新生成后旧文件将失效;已安装应用仍可运行,但下次构建必须使用新文件。"
type="warning"
:closable="false"
show-icon
style="margin-bottom:16px"
/>
<el-descriptions :column="1" border style="margin-bottom:16px">
<el-descriptions-item label="当前版本">{{ app.configFileRevision ?? '尚未生成' }}</el-descriptions-item>
<el-descriptions-item label="配置 ID">{{ app.configFileId || '-' }}</el-descriptions-item>
<el-descriptions-item label="签发时间">{{ app.configFileIssuedAt ? formatTime(app.configFileIssuedAt) : '-' }}</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ app.configFileExpiresAt ? formatTime(app.configFileExpiresAt) : '长期有效' }}</el-descriptions-item>
</el-descriptions>
<el-form label-width="90px">
<el-form-item label="有效方式">
<el-radio-group v-model="configExpiryMode">
<el-radio value="longTerm">长期有效</el-radio>
<el-radio value="expiresAt">指定到期时间</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="configExpiryMode === 'expiresAt'" label="到期时间">
<el-date-picker
v-model="configExpiresAt"
type="datetime"
placeholder="请选择到期时间"
:disabled-date="date => date.getTime() < Date.now() - 86400000"
style="width:100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showRegenerateDialog = false">取消</el-button>
<el-button type="primary" :loading="regenerating" @click="regenerateConfigFile">确认重新生成</el-button>
</template>
</el-dialog>
<!-- Activation Request Dialog --> <!-- Activation Request Dialog -->
<el-dialog v-model="showActivationDialog" title="申请开通服务" :width="dialogWidth"> <el-dialog v-model="showActivationDialog" title="申请开通服务" :width="dialogWidth">
<el-form label-width="80px"> <el-form label-width="80px">
@ -383,6 +391,9 @@ const showActivationDialog = ref(false)
const submittingActivation = ref(false) const submittingActivation = ref(false)
const activationForm = ref({ platform: '', serviceType: '', reason: '' }) const activationForm = ref({ platform: '', serviceType: '', reason: '' })
const regenerating = ref(false) const regenerating = ref(false)
const showRegenerateDialog = ref(false)
const configExpiryMode = ref<'longTerm' | 'expiresAt'>('longTerm')
const configExpiresAt = ref<Date | null>(null)
// API Key Management // API Key Management
const apiKeys = ref<{ id: string; appKey: string; apiKey: string; name: string; enabled: boolean; createdAt: string }[]>([]) const apiKeys = ref<{ id: string; appKey: string; apiKey: string; name: string; enabled: boolean; createdAt: string }[]>([])
@ -527,7 +538,6 @@ function serviceLabel(type: string) {
IM: '即时通讯 (IM)', IM: '即时通讯 (IM)',
PUSH: '离线推送', PUSH: '离线推送',
UPDATE: '版本管理', UPDATE: '版本管理',
LICENSE: '授权管理',
BUG_COLLECT: '崩溃收集 (BugCollect)', BUG_COLLECT: '崩溃收集 (BugCollect)',
}[type] ?? type }[type] ?? type
} }
@ -537,7 +547,6 @@ function serviceHelp(type: string) {
IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。', IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。',
PUSH: '一次开通后,可在推送配置页按厂商维护配置。', PUSH: '一次开通后,可在推送配置页按厂商维护配置。',
UPDATE: '一次开通后,版本管理页独立管理版本上传、商店配置和灰度发布。', UPDATE: '一次开通后,版本管理页独立管理版本上传、商店配置和灰度发布。',
LICENSE: '管理 PAD 设备的授权注册与验证。',
BUG_COLLECT: '采集 App 崩溃与自定义事件,支持漏斗分析和 Webhook 告警。', BUG_COLLECT: '采集 App 崩溃与自定义事件,支持漏斗分析和 Webhook 告警。',
}[type] ?? '' }[type] ?? ''
} }
@ -654,21 +663,35 @@ async function downloadConfigFile() {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
function openRegenerateDialog() {
configExpiryMode.value = app.value?.configFileExpiresAt ? 'expiresAt' : 'longTerm'
configExpiresAt.value = app.value?.configFileExpiresAt ? new Date(app.value.configFileExpiresAt) : null
showRegenerateDialog.value = true
}
async function regenerateConfigFile() { async function regenerateConfigFile() {
const current = app.value const current = app.value
if (!current) return if (!current) return
try { if (configExpiryMode.value === 'expiresAt'
await ElMessageBox.confirm('重新生成后,旧的 Config 文件将失效,需要重新下载并替换到项目中。确定继续?', '重新生成 Config 文件', { && (!configExpiresAt.value || configExpiresAt.value.getTime() <= Date.now())) {
confirmButtonText: '确定', ElMessage.warning('到期时间必须晚于当前时间')
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return return
} }
regenerating.value = true regenerating.value = true
try { try {
await appApi.regenerateConfigFile(current.appKey) const res = await appApi.regenerateConfigFile(current.appKey, {
longTerm: configExpiryMode.value === 'longTerm',
expiresAt: configExpiryMode.value === 'expiresAt' ? configExpiresAt.value!.toISOString() : null,
})
const metadata = res.data.data
app.value = {
...current,
configFileId: metadata.configId,
configFileRevision: metadata.revision,
configFileIssuedAt: metadata.issuedAt,
configFileExpiresAt: metadata.expiresAt,
}
showRegenerateDialog.value = false
ElMessage.success('Config 文件已重新生成,请重新下载') ElMessage.success('Config 文件已重新生成,请重新下载')
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || '重新生成失败') ElMessage.error(e?.response?.data?.message || '重新生成失败')
@ -719,6 +742,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.mono { font-family: monospace; font-size: 12px; } .mono { font-family: monospace; font-size: 12px; }
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; } .form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
.config-metadata { display:flex; flex-direction:column; margin-top:8px; color:var(--el-text-color-secondary); font-size:12px; line-height:1.7; }
.api-key-result-box { .api-key-result-box {
font-family: monospace; font-family: monospace;
font-size: 13px; font-size: 13px;

查看文件

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<h2 style="margin-bottom: 24px">Sourcemap 管理</h2> <h2 style="margin-bottom: 24px">符号化产物管理</h2>
<!-- App selector bar --> <!-- App selector bar -->
<div class="app-selector-bar"> <div class="app-selector-bar">
@ -27,7 +27,7 @@
<el-card shadow="never"> <el-card shadow="never">
<template #header> <template #header>
<div class="toolbar toolbar-space-between"> <div class="toolbar toolbar-space-between">
<span>已上传的 Sourcemap 文件</span> <span>已上传的符号化产物</span>
<el-button @click="loadData" :loading="loading">刷新</el-button> <el-button @click="loadData" :loading="loading">刷新</el-button>
</div> </div>
</template> </template>
@ -38,8 +38,14 @@
<el-tag size="small" :type="platformTagType(row.platform)">{{ row.platform }}</el-tag> <el-tag size="small" :type="platformTagType(row.platform)">{{ row.platform }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="artifactType" label="产物类型" width="150" />
<el-table-column prop="appVersion" label="应用版本" width="120" /> <el-table-column prop="appVersion" label="应用版本" width="120" />
<el-table-column prop="bundleName" label="Bundle" width="120" /> <el-table-column prop="moduleId" label="模块" width="120">
<template #default="{ row }">{{ row.moduleId || '—' }}</template>
</el-table-column>
<el-table-column prop="moduleVersion" label="模块版本" width="120">
<template #default="{ row }">{{ row.moduleVersion || '—' }}</template>
</el-table-column>
<el-table-column prop="buildId" label="Build ID" width="160" show-overflow-tooltip> <el-table-column prop="buildId" label="Build ID" width="160" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span class="buildid-text">{{ row.buildId ?? '—' }}</span> <span class="buildid-text">{{ row.buildId ?? '—' }}</span>
@ -51,7 +57,7 @@
<el-table-column label="操作" width="80" fixed="right"> <el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-popconfirm <el-popconfirm
title="确认删除该 Sourcemap?" title="确认删除该符号化产物?"
confirm-button-text="删除" confirm-button-text="删除"
cancel-button-text="取消" cancel-button-text="取消"
confirm-button-type="danger" confirm-button-type="danger"
@ -65,7 +71,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-empty v-if="!loading && sourcemaps.length === 0" description="暂无 Sourcemap 文件" /> <el-empty v-if="!loading && sourcemaps.length === 0" description="暂无符号化产物" />
</el-card> </el-card>
<el-card shadow="never" style="margin-top: 16px"> <el-card shadow="never" style="margin-top: 16px">
@ -92,10 +98,14 @@ npx react-native bundle --platform android --dev false</pre>
<h4>手动上传</h4> <h4>手动上传</h4>
<p>使用 curl 命令手动上传</p> <p>使用 curl 命令手动上传</p>
<pre class="code-block">curl -X POST "https://your-api.com/bugcollect/v1/sourcemaps/upload" \ <pre class="code-block">curl -X POST "https://your-api.com/bugcollect/v1/artifacts/upload" \
-H "Authorization: Bearer $XUQM_API_TOKEN" \
-F "artifactType=R8_MAPPING" \
-F "appKey=your-app-key" \ -F "appKey=your-app-key" \
-F "platform=android" \ -F "platform=android" \
-F "appVersion=1.0.0" \ -F "appVersion=1.0.0" \
-F "buildId=your-build-id" \
-F "artifactHash=sha256-of-mapping" \
-F "file=@mapping.txt"</pre> -F "file=@mapping.txt"</pre>
</div> </div>
</el-card> </el-card>

查看文件

@ -20,7 +20,6 @@
<el-menu-item index="/services/im"><el-icon><ChatDotRound /></el-icon><span></span></el-menu-item> <el-menu-item index="/services/im"><el-icon><ChatDotRound /></el-icon><span></span></el-menu-item>
<el-menu-item index="/services/push"><el-icon><Bell /></el-icon><span>线</span></el-menu-item> <el-menu-item index="/services/push"><el-icon><Bell /></el-icon><span>线</span></el-menu-item>
<el-menu-item index="/services/update"><el-icon><Upload /></el-icon><span></span></el-menu-item> <el-menu-item index="/services/update"><el-icon><Upload /></el-icon><span></span></el-menu-item>
<el-menu-item index="/services/license"><el-icon><Key /></el-icon><span></span></el-menu-item>
<el-sub-menu index="services-bugcollect"> <el-sub-menu index="services-bugcollect">
<template #title><el-icon><DataLine /></el-icon><span></span></template> <template #title><el-icon><DataLine /></el-icon><span></span></template>
<el-menu-item index="/bugcollect/overview"><el-icon><Odometer /></el-icon><span></span></el-menu-item> <el-menu-item index="/bugcollect/overview"><el-icon><Odometer /></el-icon><span></span></el-menu-item>
@ -71,7 +70,6 @@
<el-menu-item index="/services/im"><el-icon><ChatDotRound /></el-icon><span></span></el-menu-item> <el-menu-item index="/services/im"><el-icon><ChatDotRound /></el-icon><span></span></el-menu-item>
<el-menu-item index="/services/push"><el-icon><Bell /></el-icon><span>线</span></el-menu-item> <el-menu-item index="/services/push"><el-icon><Bell /></el-icon><span>线</span></el-menu-item>
<el-menu-item index="/services/update"><el-icon><Upload /></el-icon><span></span></el-menu-item> <el-menu-item index="/services/update"><el-icon><Upload /></el-icon><span></span></el-menu-item>
<el-menu-item index="/services/license"><el-icon><Key /></el-icon><span></span></el-menu-item>
<el-sub-menu index="services-bugcollect"> <el-sub-menu index="services-bugcollect">
<template #title><el-icon><DataLine /></el-icon><span></span></template> <template #title><el-icon><DataLine /></el-icon><span></span></template>
<el-menu-item index="/bugcollect/overview"><el-icon><Odometer /></el-icon><span></span></el-menu-item> <el-menu-item index="/bugcollect/overview"><el-icon><Odometer /></el-icon><span></span></el-menu-item>

查看文件

@ -1,380 +0,0 @@
<template>
<div>
<div v-if="isServicesPortal" class="portal-bar">
<span class="portal-bar-title">授权管理</span>
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
<el-option v-for="a in portalApps" :key="a.appKey" :label="a.name" :value="a.appKey" />
</el-select>
</div>
<el-page-header v-else @back="$router.back()" :content="`授权管理 - ${appName}`" style="margin-bottom:20px" />
<el-empty v-if="isServicesPortal && !appKey" description="请选择一个应用" style="margin-top:80px" />
<div v-if="isServicesPortal && appKey && checkingService" v-loading="true" style="min-height:200px" />
<template v-if="isServicesPortal && appKey && serviceEnabled === false">
<el-empty :image-size="80" description="当前应用未开通授权管理服务" style="margin-top:60px">
<el-button type="primary" @click="showActivationDialog = true">申请开通</el-button>
</el-empty>
<el-dialog v-model="showActivationDialog" title="申请开通授权管理" width="460px">
<el-form label-width="80px">
<el-form-item label="服务">授权管理</el-form-item>
<el-form-item label="申请理由">
<el-input v-model="activationReason" type="textarea" :rows="3" placeholder="请描述您的业务场景" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showActivationDialog = false">取消</el-button>
<el-button type="primary" :loading="submittingActivation" @click="submitActivation">提交申请</el-button>
</template>
</el-dialog>
</template>
<template v-if="!isServicesPortal || (appKey && serviceEnabled === true)">
<el-row :gutter="16" class="stat-grid">
<el-col :xs="24" :sm="12" :md="6" v-for="item in statCards" :key="item.label">
<el-card shadow="never">
<div class="stat-card">
<span class="stat-value">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" style="margin-top:16px">
<div class="license-summary">
<el-descriptions :column="isMobile ? 1 : 3" border>
<el-descriptions-item label="应用">{{ appName }}</el-descriptions-item>
<el-descriptions-item label="AppKey">{{ appKey || '-' }}</el-descriptions-item>
<el-descriptions-item label="授权状态">
<el-tag :type="license?.isActive ? 'success' : 'danger'" size="small">
{{ license?.isActive ? '正常' : '停用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最大设备数">
<div v-if="!editingMaxDevices" class="max-devices-display">
<span>{{ license?.maxDevices ?? '-' }}</span>
<el-button link type="primary" size="small" style="margin-left:8px" @click="startEditMaxDevices">修改</el-button>
</div>
<div v-else class="max-devices-edit">
<el-input-number v-model="editMaxDevicesValue" :min="1" :max="999999" size="small" />
<el-button type="primary" size="small" :loading="savingMaxDevices" style="margin-left:8px" @click="saveMaxDevices">保存</el-button>
<el-button size="small" style="margin-left:4px" @click="editingMaxDevices = false">取消</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="已注册设备">{{ license?.registeredDevices ?? devices.length }}</el-descriptions-item>
<el-descriptions-item label="过期时间">
<div v-if="!editingExpiresAt" class="max-devices-display">
<span>{{ license?.expiresAt ? formatDate(license.expiresAt) : '永久' }}</span>
<el-button link type="primary" size="small" style="margin-left:8px" @click="startEditExpiresAt">修改</el-button>
</div>
<div v-else class="max-devices-edit">
<el-date-picker
v-model="editExpiresAtValue"
type="datetime"
placeholder="留空表示永久"
clearable
style="width:200px"
size="small"
/>
<el-button type="primary" size="small" :loading="savingExpiresAt" style="margin-left:8px" @click="saveExpiresAt">保存</el-button>
<el-button size="small" style="margin-left:4px" @click="editingExpiresAt = false">取消</el-button>
</div>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="toolbar responsive-toolbar">
<el-button @click="loadData" :loading="loading">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="devices" v-loading="loading" border stripe>
<el-table-column prop="deviceId" label="设备ID" min-width="220" />
<el-table-column prop="deviceName" label="设备名称" min-width="130">
<template #default="{ row }">{{ row.deviceName || '-' }}</template>
</el-table-column>
<el-table-column label="用户" min-width="180">
<template #default="{ row }">
<div class="stack-cell">
<span>{{ row.userName || row.userId || '-' }}</span>
<small v-if="row.userName && row.userId">{{ row.userId }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="联系方式" min-width="190">
<template #default="{ row }">
<div class="stack-cell">
<span>{{ row.userPhone || '-' }}</span>
<small v-if="row.userEmail">{{ row.userEmail }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="设备信息" min-width="190">
<template #default="{ row }">
<div class="stack-cell">
<span>{{ [row.deviceVendor, row.deviceModel].filter(Boolean).join(' ') || '-' }}</span>
<small v-if="row.osVersion">{{ row.osVersion }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="注册时间" width="170">
<template #default="{ row }">{{ formatDate(row.registeredAt) }}</template>
</el-table-column>
<el-table-column label="最后验证" width="170">
<template #default="{ row }">{{ row.lastVerifiedAt ? formatDate(row.lastVerifiedAt) : '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'" size="small">
{{ row.isActive ? '正常' : '吊销' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isActive" size="small" type="danger" plain @click="revokeDevice(row)">吊销</el-button>
<el-button v-else size="small" type="success" plain @click="reactivateDevice(row)">激活</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { licenseApi, type AppLicense, type LicenseDevice } from '@/api/license'
import { appApi, type App } from '@/api/app'
import { formatTime } from '@/utils/date'
import { getLastServiceApp, saveLastServiceApp } from '@/utils/serviceApp'
import {
connectServiceActivationRealtime,
disconnectServiceActivationRealtime,
type ServiceActivationRefreshEvent,
} from '@/services/storeReviewRealtime'
const route = useRoute()
const router = useRouter()
const isMobile = ref(false)
const appKey = computed(() => (route.params.appKey as string) || (route.query.appKey as string) || '')
const isServicesPortal = computed(() => route.path.startsWith('/services/'))
const serviceEnabled = ref<boolean | null>(null)
const checkingService = ref(false)
const showActivationDialog = ref(false)
const activationReason = ref('')
const submittingActivation = ref(false)
const portalApps = ref<App[]>([])
const currentApp = ref<App | null>(null)
const license = ref<AppLicense | null>(null)
const devices = ref<LicenseDevice[]>([])
const loading = ref(false)
const editingMaxDevices = ref(false)
const editMaxDevicesValue = ref(1)
const savingMaxDevices = ref(false)
const editingExpiresAt = ref(false)
const editExpiresAtValue = ref<Date | null>(null)
const savingExpiresAt = ref(false)
const appName = computed(() => currentApp.value?.name || license.value?.name || appKey.value || '-')
const activeDevices = computed(() => devices.value.filter(d => d.isActive).length)
const revokedDevices = computed(() => devices.value.filter(d => !d.isActive).length)
const statCards = computed(() => [
{ label: '最大设备数', value: license.value?.maxDevices ?? '-' },
{ label: '已注册设备', value: license.value?.registeredDevices ?? devices.value.length },
{ label: '正常设备', value: activeDevices.value },
{ label: '吊销设备', value: revokedDevices.value },
])
async function loadData() {
const key = appKey.value
if (!key) return
loading.value = true
try {
const [licenseRes, appRes] = await Promise.all([
licenseApi.getAppLicense(key),
appApi.get(key).catch(() => null),
])
license.value = licenseRes.data.data.license
devices.value = licenseRes.data.data.devices || []
currentApp.value = appRes?.data.data ?? portalApps.value.find(item => item.appKey === key) ?? null
} finally {
loading.value = false
}
}
function startEditMaxDevices() {
editMaxDevicesValue.value = license.value?.maxDevices ?? 1
editingMaxDevices.value = true
}
async function saveMaxDevices() {
const key = appKey.value
if (!key) return
savingMaxDevices.value = true
try {
const res = await licenseApi.updateAppLicense(key, { maxDevices: editMaxDevicesValue.value })
license.value = res.data.data
editingMaxDevices.value = false
ElMessage.success('最大设备数已更新')
} catch {
ElMessage.error('更新失败')
} finally {
savingMaxDevices.value = false
}
}
function startEditExpiresAt() {
editExpiresAtValue.value = license.value?.expiresAt ? new Date(license.value.expiresAt) : null
editingExpiresAt.value = true
}
async function saveExpiresAt() {
const key = appKey.value
if (!key) return
savingExpiresAt.value = true
try {
const expiresAt = editExpiresAtValue.value ? editExpiresAtValue.value.toISOString() : ''
const res = await licenseApi.updateAppLicense(key, { expiresAt })
license.value = res.data.data
editingExpiresAt.value = false
ElMessage.success('过期时间已更新')
} catch {
ElMessage.error('更新失败')
} finally {
savingExpiresAt.value = false
}
}
async function revokeDevice(row: LicenseDevice) {
try {
await ElMessageBox.confirm('确认吊销该设备?', '吊销确认', { type: 'warning' })
await licenseApi.revokeDevice(row.id)
ElMessage.success('已吊销')
loadData()
} catch (e) {
// cancelled
}
}
async function reactivateDevice(row: LicenseDevice) {
await licenseApi.reactivateDevice(row.id)
ElMessage.success('已激活')
loadData()
}
async function checkServiceEnabled(key: string) {
checkingService.value = true
serviceEnabled.value = null
try {
const res = await appApi.getServices(key)
const enabled = res.data.data.some(s => s.serviceType === 'LICENSE' && s.enabled)
serviceEnabled.value = enabled
if (enabled) loadData()
} catch {
serviceEnabled.value = false
} finally {
checkingService.value = false
}
}
async function submitActivation() {
if (!activationReason.value.trim()) {
ElMessage.warning('请填写申请理由')
return
}
submittingActivation.value = true
try {
await appApi.requestActivation(appKey.value, 'LICENSE', activationReason.value.trim())
ElMessage.success('申请已提交,等待运营审核')
showActivationDialog.value = false
activationReason.value = ''
} catch {
ElMessage.error('提交失败')
} finally {
submittingActivation.value = false
}
}
function switchApp(key: string) {
saveLastServiceApp('license', key)
router.push(`/services/license/${key}`)
}
const formatDate = formatTime
function updateViewport() {
isMobile.value = window.innerWidth < 768
}
function connectLicenseRealtime(key: string) {
void connectServiceActivationRealtime(key, (event: ServiceActivationRefreshEvent) => {
if (event.serviceType !== 'LICENSE') return
if (event.status === 'APPROVED') {
ElMessage.success('授权管理服务已审核通过')
checkServiceEnabled(key)
} else if (event.status === 'REJECTED') {
ElMessage.error(`授权管理服务审核未通过:${event.reviewNote || '请联系运营'}`)
checkServiceEnabled(key)
}
})
}
watch(appKey, (key) => {
if (isServicesPortal.value) {
if (key) checkServiceEnabled(key)
} else {
loadData()
}
if (key) connectLicenseRealtime(key)
})
onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
if (isServicesPortal.value) {
appApi.list().then(res => {
portalApps.value = res.data.data || []
currentApp.value = portalApps.value.find(item => item.appKey === appKey.value) ?? currentApp.value
if (!appKey.value && portalApps.value.length) {
const saved = getLastServiceApp('license')
const target = saved && portalApps.value.some(a => a.appKey === saved) ? saved : portalApps.value[0].appKey
switchApp(target)
}
})
if (appKey.value) checkServiceEnabled(appKey.value)
} else {
loadData()
}
if (appKey.value) connectLicenseRealtime(appKey.value)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
disconnectServiceActivationRealtime()
})
</script>
<style scoped>
.stat-grid { margin-bottom: 16px; }
.stat-card { display: flex; flex-direction: column; align-items: center; padding: 12px; }
.stat-value { font-size: 28px; font-weight: 700; color: #0E7BF2; }
.stat-label { font-size: 13px; color: #64748b; margin-top: 4px; }
.license-summary { margin-bottom: 16px; }
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.table-wrap { overflow-x: auto; }
.portal-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.portal-bar-title { font-size: 18px; font-weight: 600; }
.stack-cell { display: flex; flex-direction: column; gap: 2px; line-height: 1.35; }
.stack-cell small { color: #909399; font-size: 12px; }
.max-devices-display { display: flex; align-items: center; }
.max-devices-edit { display: flex; align-items: center; }
</style>

查看文件

@ -239,36 +239,6 @@
@current-change="handlePushPageChange" /> @current-change="handlePushPageChange" />
</el-tab-pane> </el-tab-pane>
<!-- 设备授权 -->
<el-tab-pane label="设备授权" name="LICENSE">
<div class="toolbar responsive-toolbar">
<el-select v-model="licenseAppKey" placeholder="选择应用" style="width: 320px" filterable @change="handleLicenseAppChange">
<el-option v-for="app in apps" :key="app.appKey" :label="`${app.name} · ${app.packageName}`" :value="app.appKey" />
</el-select>
<el-button :loading="licenseLoading" @click="loadLicenseLogs">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="licenseLogs" v-loading="licenseLoading" border stripe>
<el-table-column prop="createdAt" label="时间" width="170">
<template #default="{ row }"><span class="time-text">{{ formatTime(row.createdAt) }}</span></template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-tag size="small" :type="actionTagType(row.action)" effect="dark">{{ actionLabel(row.action) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作详情" min-width="360">
<template #default="{ row }"><span class="summary-text">{{ licenseSummaryText(row) }}</span></template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="120" />
</el-table>
</div>
<el-pagination style="margin-top: 16px" layout="total, prev, pager, next"
:total="licenseTotal" :page-size="licensePageSize" :current-page="licensePage + 1"
@current-change="handleLicensePageChange" />
</el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
</div> </div>
@ -281,10 +251,9 @@ import { updateAdminApi, type OperationLog as UpdateOperationLog } from '@/api/u
import { operationLogApi, type TenantOperationLog } from '@/api/operationLog' import { operationLogApi, type TenantOperationLog } from '@/api/operationLog'
import { imAdminApi, type OperationLog as ImOperationLog } from '@/api/im' import { imAdminApi, type OperationLog as ImOperationLog } from '@/api/im'
import { pushAdminApi, type PushOperationLog } from '@/api/push' import { pushAdminApi, type PushOperationLog } from '@/api/push'
import { licenseApi, type LicenseOperationLog } from '@/api/license'
import { formatTime } from '@/utils/date' import { formatTime } from '@/utils/date'
const activeSource = ref<'TENANT' | 'IM' | 'UPDATE' | 'PUSH' | 'LICENSE'>('TENANT') const activeSource = ref<'TENANT' | 'IM' | 'UPDATE' | 'PUSH'>('TENANT')
// state // state
@ -321,15 +290,6 @@ const pushPage = ref(0)
const pushPageSize = ref(20) const pushPageSize = ref(20)
const pushAppKey = ref('') const pushAppKey = ref('')
// state
const licenseLoading = ref(false)
const licenseLogs = ref<LicenseOperationLog[]>([])
const licenseTotal = ref(0)
const licensePage = ref(0)
const licensePageSize = ref(20)
const licenseAppKey = ref('')
// //
onMounted(async () => { onMounted(async () => {
@ -337,13 +297,12 @@ onMounted(async () => {
}) })
watch(activeSource, async (value) => { watch(activeSource, async (value) => {
if (['UPDATE', 'IM', 'PUSH', 'LICENSE'].includes(value) && !apps.value.length) { if (['UPDATE', 'IM', 'PUSH'].includes(value) && !apps.value.length) {
await loadApps() await loadApps()
} }
if (value === 'UPDATE') await loadUpdateLogs() if (value === 'UPDATE') await loadUpdateLogs()
if (value === 'IM' && imAppKey.value) await loadImLogs() if (value === 'IM' && imAppKey.value) await loadImLogs()
if (value === 'PUSH' && pushAppKey.value) await loadPushLogs() if (value === 'PUSH' && pushAppKey.value) await loadPushLogs()
if (value === 'LICENSE' && licenseAppKey.value) await loadLicenseLogs()
}) })
// //
@ -356,7 +315,6 @@ async function loadApps() {
if (!updateAppKey.value && apps.value.length) updateAppKey.value = apps.value[0].appKey if (!updateAppKey.value && apps.value.length) updateAppKey.value = apps.value[0].appKey
if (!imAppKey.value && apps.value.length) imAppKey.value = apps.value[0].appKey if (!imAppKey.value && apps.value.length) imAppKey.value = apps.value[0].appKey
if (!pushAppKey.value && apps.value.length) pushAppKey.value = apps.value[0].appKey if (!pushAppKey.value && apps.value.length) pushAppKey.value = apps.value[0].appKey
if (!licenseAppKey.value && apps.value.length) licenseAppKey.value = apps.value[0].appKey
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@ -413,19 +371,6 @@ async function loadPushLogs() {
} }
} }
async function loadLicenseLogs() {
if (!licenseAppKey.value) { licenseLogs.value = []; return }
licenseLoading.value = true
try {
const res = await licenseApi.getOperationLogs(licenseAppKey.value, licensePage.value, licensePageSize.value)
const data = res.data.data
licenseLogs.value = data.content ?? []
licenseTotal.value = data.total ?? 0
} finally {
licenseLoading.value = false
}
}
// //
function handleTenantPageChange(nextPage: number) { function handleTenantPageChange(nextPage: number) {
@ -462,16 +407,6 @@ function handlePushPageChange(nextPage: number) {
loadPushLogs() loadPushLogs()
} }
function handleLicenseAppChange() {
licensePage.value = 0
loadLicenseLogs()
}
function handleLicensePageChange(nextPage: number) {
licensePage.value = nextPage - 1
loadLicenseLogs()
}
// //
function moduleLabel(moduleType: string): string { function moduleLabel(moduleType: string): string {
@ -604,10 +539,6 @@ function actionLabel(action: string): string {
DELETE_USER: '删除用户', DELETE_USER: '删除用户',
IMPORT_USER: '导入用户', IMPORT_USER: '导入用户',
TEST_PUSH: '发送测试推送', TEST_PUSH: '发送测试推送',
// license-service:
UPDATE_LICENSE: '更新授权配置',
REVOKE_DEVICE: '吊销设备授权',
REACTIVATE_DEVICE: '重新激活设备',
} }
return map[action] ?? action return map[action] ?? action
} }
@ -655,9 +586,6 @@ function resourceTypeLabel(resourceType: string): string {
SERVICE: '服务配置', SERVICE: '服务配置',
// push-service // push-service
PUSH: '推送消息', PUSH: '推送消息',
// license-service
LICENSE: '授权许可',
DEVICE: '设备',
}[resourceType] ?? resourceType }[resourceType] ?? resourceType
} }
@ -688,16 +616,6 @@ function pushSummaryText(row: PushOperationLog): string {
return `${act}${res}` return `${act}${res}`
} }
//
function licenseSummaryText(row: LicenseOperationLog): string {
if (row.summary) return row.summary
const act = actionLabel(row.action)
const res = resourceTypeLabel(row.resourceType)
if (row.resourceId) return `${act}${res} ${row.resourceId}`
return `${act}${res}`
}
// //
function updateSummaryText(row: UpdateOperationLog): string { function updateSummaryText(row: UpdateOperationLog): string {

查看文件

@ -140,7 +140,10 @@
<el-descriptions-item label="Android 包名">{{ configParseResult.packageName || '-' }}</el-descriptions-item> <el-descriptions-item label="Android 包名">{{ configParseResult.packageName || '-' }}</el-descriptions-item>
<el-descriptions-item label="iOS BundleId">{{ configParseResult.iosBundleId || '-' }}</el-descriptions-item> <el-descriptions-item label="iOS BundleId">{{ configParseResult.iosBundleId || '-' }}</el-descriptions-item>
<el-descriptions-item label="鸿蒙 BundleName">{{ configParseResult.harmonyBundleName || '-' }}</el-descriptions-item> <el-descriptions-item label="鸿蒙 BundleName">{{ configParseResult.harmonyBundleName || '-' }}</el-descriptions-item>
<el-descriptions-item label="服务地址">{{ configParseResult.serverUrl || configParseResult.baseUrl || '-' }}</el-descriptions-item> <el-descriptions-item label="Config 版本">{{ configParseResult.revision }}</el-descriptions-item>
<el-descriptions-item label="签发时间">{{ formatTime(configParseResult.issuedAt) }}</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ configParseResult.expiresAt ? formatTime(configParseResult.expiresAt) : '长期有效' }}</el-descriptions-item>
<el-descriptions-item label="服务地址">{{ configParseResult.serverUrl || '-' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@ -285,12 +288,16 @@ const deploymentMode = ref<'PUBLIC' | 'PRIVATE' | null>(null)
const configFileContent = ref('') const configFileContent = ref('')
const parsingConfig = ref(false) const parsingConfig = ref(false)
const configParseResult = ref<{ const configParseResult = ref<{
schemaVersion: number
configId: string
revision: number
issuedAt: string
expiresAt: string | null
appKey: string appKey: string
appName: string appName: string
packageName: string packageName: string
iosBundleId: string iosBundleId: string
harmonyBundleName: string harmonyBundleName: string
baseUrl: string
serverUrl: string serverUrl: string
} | null>(null) } | null>(null)
const configUploadRef = ref<any>(null) const configUploadRef = ref<any>(null)
@ -418,7 +425,6 @@ const SERVICE_DISPLAY_NAMES: Record<string, string> = {
'push-service': '推送服务', 'push-service': '推送服务',
'update-service': '版本管理服务', 'update-service': '版本管理服务',
'file-service': '文件服务', 'file-service': '文件服务',
'license-service': '授权服务',
'xuqm-bugcollect-service': '崩溃收集服务', 'xuqm-bugcollect-service': '崩溃收集服务',
'demo-service': 'Demo 服务', 'demo-service': 'Demo 服务',
'tenant-web': '租户平台 (Web)', 'tenant-web': '租户平台 (Web)',

查看文件

@ -178,8 +178,18 @@
<div class="table-wrap"> <div class="table-wrap">
<el-table :data="rnBundles" v-loading="loadingRn" border stripe> <el-table :data="rnBundles" v-loading="loadingRn" border stripe>
<el-table-column prop="moduleId" label="模块ID" width="140" /> <el-table-column prop="moduleId" label="模块ID" width="140" />
<el-table-column prop="type" label="类型" width="90" />
<el-table-column prop="version" label="版本" width="100" /> <el-table-column prop="version" label="版本" width="100" />
<el-table-column prop="platform" label="平台" width="90" /> <el-table-column prop="platform" label="平台" width="90" />
<el-table-column prop="appVersionRange" label="整包版本范围" width="170" />
<el-table-column prop="commonVersionRange" label="Common 版本范围" width="180">
<template #default="{row}">{{ row.commonVersionRange || '—' }}</template>
</el-table-column>
<el-table-column prop="builtAgainstNativeBaselineId" label="原生基线" width="220" show-overflow-tooltip />
<el-table-column prop="buildId" label="构建 ID" width="150" show-overflow-tooltip />
<el-table-column prop="bundleFormat" label="Bundle 格式" width="140" />
<el-table-column prop="minNativeApiLevel" label="最低原生 API" width="130" />
<el-table-column prop="archiveSha256" label="归档 SHA-256" width="220" show-overflow-tooltip />
<el-table-column label="状态" width="140"> <el-table-column label="状态" width="140">
<template #default="{row}"> <template #default="{row}">
<el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag> <el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
@ -191,7 +201,6 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="minCommonVersion" label="最低 Common 版本" width="160" />
<el-table-column prop="note" label="说明" show-overflow-tooltip /> <el-table-column prop="note" label="说明" show-overflow-tooltip />
<el-table-column prop="createdAt" label="上传时间" width="160"> <el-table-column prop="createdAt" label="上传时间" width="160">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template> <template #default="{row}">{{ formatTime(row.createdAt) }}</template>
@ -924,44 +933,25 @@
:auto-upload="false" :auto-upload="false"
:limit="1" :limit="1"
:on-change="onRnBundleChange" :on-change="onRnBundleChange"
accept=".bundle,.js" accept=".xuqm.zip"
> >
<el-icon class="el-icon--upload"><UploadFilled /></el-icon> <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text"> <div class="el-upload__text">
RN Bundle 拖到这里 <em>点击选择文件</em> CLI 生成的 .xuqm.zip 发布包拖到这里 <em>点击选择文件</em>
</div> </div>
<template #tip> <template #tip>
<div class="el-upload__tip"> <div class="el-upload__tip">
选择或拖入文件后会自动识别模块平台版本和 Common 版本 发布身份只读取压缩包根目录的 rn-manifest.json页面不能修改
</div> </div>
</template> </template>
</el-upload> </el-upload>
</el-form-item> </el-form-item>
<el-form-item v-if="rnInspectUploadProgress > 0" label="识别进度">
<div class="upload-progress-block">
<el-progress :percentage="rnInspectUploadProgress" :status="rnInspectUploadProgress === 100 ? 'success' : undefined" />
<span class="upload-progress-text">{{ rnInspectUploadProgress }}%</span>
</div>
</el-form-item>
<el-alert <el-alert
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
title="推荐文件名格式moduleId__ANDROID__1.0.0__1.0.0__com.example.app.bundle,系统会按命名自动识别模块、平台、版本、最低 Common 版本和包名。" title="只接受由发布 CLI 生成的 .xuqm.zip。服务端会校验清单、Bundle SHA-256、归档 SHA-256、应用归属并签署不可变发布身份。"
/> />
<el-form-item label="模块ID"><el-input v-model="rnUploadForm.moduleId" placeholder="可由文件名自动识别" /></el-form-item>
<el-form-item label="平台">
<el-select v-model="rnUploadForm.platform">
<el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" />
<el-option value="HARMONY" label="Harmony" />
</el-select>
</el-form-item>
<el-form-item label="版本"><el-input v-model="rnUploadForm.version" placeholder="可由文件名自动识别" /></el-form-item>
<el-form-item label="最低 Common 版本"><el-input v-model="rnUploadForm.minCommonVersion" placeholder="可由文件名自动识别" /></el-form-item>
<el-form-item label="包名 / Bundle">
<el-input v-model="rnUploadForm.packageName" placeholder="可选,建议与应用包名一致" />
</el-form-item>
<el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></el-form-item> <el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></el-form-item>
<el-form-item v-if="rnBundleUploadProgress > 0" label="提交进度"> <el-form-item v-if="rnBundleUploadProgress > 0" label="提交进度">
<div class="upload-progress-block"> <div class="upload-progress-block">
@ -981,7 +971,7 @@
<div class="drag-overlay-content"> <div class="drag-overlay-content">
<el-icon size="64"><UploadFilled /></el-icon> <el-icon size="64"><UploadFilled /></el-icon>
<p class="drag-overlay-title">释放文件以上传</p> <p class="drag-overlay-title">释放文件以上传</p>
<p class="drag-overlay-hint">支持 .apk.bundle.js</p> <p class="drag-overlay-hint">支持 .apk.xuqm.zip</p>
</div> </div>
</div> </div>
</template> </template>
@ -1005,7 +995,6 @@ import {
type GrayTag, type GrayTag,
type PublishMode, type PublishMode,
type RnBundle, type RnBundle,
type RnBundleInspectResult,
type StoreConfig, type StoreConfig,
type StoreType, type StoreType,
} from '@/api/update' } from '@/api/update'
@ -1132,12 +1121,12 @@ async function handleDrop(e: DragEvent) {
openUploadAppDialog() openUploadAppDialog()
await nextTick() await nextTick()
await onAppPackageChange({ raw: file }) await onAppPackageChange({ raw: file })
} else if (ext === '.bundle' || ext === '.js') { } else if (file.name.toLowerCase().endsWith('.xuqm.zip')) {
showUploadRn.value = true showUploadRn.value = true
await nextTick() await nextTick()
await onRnBundleChange({ raw: file }) await onRnBundleChange({ raw: file })
} else { } else {
ElMessage.warning(`不支持的文件类型:${file.name},请拖入 .apk、.bundle 或 .js 文件`) ElMessage.warning(`不支持的文件类型:${file.name},请拖入 .apk 或 .xuqm.zip 文件`)
} }
} }
@ -1167,7 +1156,6 @@ const appPackageInspecting = ref(false)
const appPackageUploadProgress = ref(0) const appPackageUploadProgress = ref(0)
const appVersionUploadProgress = ref(0) const appVersionUploadProgress = ref(0)
const appUploadRef = ref<any>(null) const appUploadRef = ref<any>(null)
const rnInspectUploadProgress = ref(0)
const rnBundleUploadProgress = ref(0) const rnBundleUploadProgress = ref(0)
const operationLogs = ref<{ const operationLogs = ref<{
id: string id: string
@ -2155,81 +2143,28 @@ async function submitAppUpload() {
const showUploadRn = ref(false) const showUploadRn = ref(false)
const uploadingRn = ref(false) const uploadingRn = ref(false)
const rnUploadForm = ref({ const rnUploadForm = ref({
moduleId: '',
platform: 'ANDROID' as 'ANDROID' | 'IOS' | 'HARMONY',
version: '',
minCommonVersion: '',
packageName: '',
note: '', note: '',
file: null as File | null, file: null as File | null,
}) })
function parseRnBundleName(fileName: string): RnBundleInspectResult | null {
const baseName = fileName.replace(/\.[^.]+$/, '')
const parts = baseName.split('__')
if (parts.length >= 4) {
return {
moduleId: parts[0],
platform: parts[1].toUpperCase() as 'ANDROID' | 'IOS' | 'HARMONY',
version: parts[2],
minCommonVersion: parts[3],
packageName: parts[4],
fileName,
detected: true,
}
}
return null
}
async function onRnBundleChange(uploadFile: { raw?: File } | null) { async function onRnBundleChange(uploadFile: { raw?: File } | null) {
const file = uploadFile?.raw ?? null const file = uploadFile?.raw ?? null
rnUploadForm.value.file = file rnUploadForm.value.file = file
if (!file) return if (!file) return
if (!file.name.toLowerCase().endsWith('.xuqm.zip')) {
const local = parseRnBundleName(file.name) rnUploadForm.value.file = null
if (local) { ElMessage.warning('RN 插件只接受发布 CLI 生成的 .xuqm.zip')
if (local.moduleId) rnUploadForm.value.moduleId = local.moduleId
if (local.platform) rnUploadForm.value.platform = local.platform
if (local.version) rnUploadForm.value.version = local.version
if (local.minCommonVersion) rnUploadForm.value.minCommonVersion = local.minCommonVersion
if (local.packageName) rnUploadForm.value.packageName = local.packageName
return
}
const formData = new FormData()
formData.append('bundle', file)
rnInspectUploadProgress.value = 0
try {
const res = await updateAdminApi.inspectRnBundle(formData, (percent) => {
rnInspectUploadProgress.value = percent
})
rnInspectUploadProgress.value = 100
const inspected = res.data.data as RnBundleInspectResult
if (inspected.moduleId) rnUploadForm.value.moduleId = inspected.moduleId
if (inspected.platform) rnUploadForm.value.platform = inspected.platform
if (inspected.version) rnUploadForm.value.version = inspected.version
if (inspected.minCommonVersion) rnUploadForm.value.minCommonVersion = inspected.minCommonVersion
if (inspected.packageName) rnUploadForm.value.packageName = inspected.packageName
} catch {
ElMessage.warning('已选择文件,但未能从文件名识别出 RN Bundle 元数据,请补全后上传')
} finally {
rnInspectUploadProgress.value = 0
} }
} }
async function submitRnUpload() { async function submitRnUpload() {
const f = rnUploadForm.value const f = rnUploadForm.value
if (!f.moduleId || !f.version || !f.file) return ElMessage.warning('请填写模块ID、版本和 Bundle 文件') if (!f.file) return ElMessage.warning('请选择 CLI 生成的 .xuqm.zip 发布包')
uploadingRn.value = true uploadingRn.value = true
rnBundleUploadProgress.value = 0 rnBundleUploadProgress.value = 0
try { try {
const fd = new FormData() const fd = new FormData()
fd.append('appKey', appKey.value) fd.append('appKey', appKey.value)
fd.append('moduleId', f.moduleId)
fd.append('platform', f.platform)
fd.append('version', f.version)
if (f.minCommonVersion) fd.append('minCommonVersion', f.minCommonVersion)
if (f.packageName) fd.append('packageName', f.packageName)
if (f.note) fd.append('note', f.note) if (f.note) fd.append('note', f.note)
fd.append('bundle', f.file) fd.append('bundle', f.file)
await updateAdminApi.uploadRnBundle(fd, (percent) => { await updateAdminApi.uploadRnBundle(fd, (percent) => {

查看文件

@ -46,10 +46,6 @@ export default defineConfig(({ mode }) => {
target: 'http://127.0.0.1:8087', target: 'http://127.0.0.1:8087',
changeOrigin: true, changeOrigin: true,
}, },
'/api/license': {
target: 'http://127.0.0.1:8085',
changeOrigin: true,
},
'/api/push': { '/api/push': {
target: 'http://127.0.0.1:8083', target: 'http://127.0.0.1:8083',
changeOrigin: true, changeOrigin: true,

查看文件

@ -1432,7 +1432,7 @@
"@xuqm/vue3-sdk@0.2.3", "@xuqm/vue3-sdk@^0.2.3": "@xuqm/vue3-sdk@0.2.3", "@xuqm/vue3-sdk@^0.2.3":
version "0.2.3" version "0.2.3"
resolved "https://nexus.xuqinmin.com/repository/npm-hosted/@xuqm/vue3-sdk/-/vue3-sdk-0.2.3.tgz#b0100a309bf4179a43d98e21e24759b020221797" resolved "https://nexus.xuqinmin.com/repository/npm-hosted/@xuqm/vue3-sdk/-/vue3-sdk-0.2.3.tgz#b0100a309bf4179a43d98e21e24759b020221797"
integrity sha512-lUQSQNaYyrji8WwL/N4Xswdzyo4MrbwqSHsghC2Sg/zEGtuTAxo+D7t8pK1wTRLXwR5P2ZutYhE75OIFhXMFLg== integrity "sha1-tkzePZdiSzSq2XYRrs7o/kRWbig= sha512-R7s9Hups0ng7IsYi3oCrV77O0CI3r3IiRnp0h94bHJoaCqFiMY8sdXvCJTGGkNymkSLcQf17viGHH1HbdoMziA=="
abbrev@^2.0.0: abbrev@^2.0.0:
version "2.0.0" version "2.0.0"