feat(sdk): 更新 SDK 设计文档和 API 重构

- 添加 expiresAt 和 refreshUserSig 参数支持自动续签
- 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化
- 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发
- 重构 RN SDK 文档结构,简化安装和使用方式
- 更新统一登录流程,支持 profile 信息传递
- 添加 IM 数据库自动隔离功能
- 修复 Android 群消息聚合问题
- 补充自动化测试验证和错误处理机制
这个提交包含在:
XuqmGroup 2026-05-01 21:27:39 +08:00
父节点 8853ee2d65
当前提交 7b7989525f
共有 24 个文件被更改,包括 1897 次插入90 次删除

查看文件

@ -42,6 +42,9 @@ yarn workspace ops-platform dev
| `/dashboard` | DashboardView | 控制台首页(需登录) | | `/dashboard` | DashboardView | 控制台首页(需登录) |
| `/apps` | AppListView | 应用列表(需登录) | | `/apps` | AppListView | 应用列表(需登录) |
| `/apps/:id` | AppDetailView | 应用详情(需登录) | | `/apps/:id` | AppDetailView | 应用详情(需登录) |
| `/security` | SecurityCenterView | 安全中心(需登录) |
| `/docs` | DocsCenterView | 接入文档(需登录) |
| `/packages` | BillingView | 服务套餐 / 配额(需登录) |
| `/accounts` | SubAccountView | 子账号管理(需登录) | | `/accounts` | SubAccountView | 子账号管理(需登录) |
### 认证流程 ### 认证流程
@ -91,6 +94,12 @@ JWT Payload 由 `atob(token.split('.')[1])` 解析,无需额外请求。
- 每个平台下显示 `IM` / `推送` / `版本管理` 三个服务卡片 - 每个平台下显示 `IM` / `推送` / `版本管理` 三个服务卡片
- 支持一键开关服务、复制 secretKey、重新生成 secretKey - 支持一键开关服务、复制 secretKey、重新生成 secretKey
### 安全中心 / 接入文档 / 服务套餐
- 安全中心提供 AppSecret 查看/重置入口,直接复用邮箱验证码流程
- 接入文档页提供 RN、Android / iOS 和服务端的最短接入示例
- 服务套餐页展示当前租户的配额和服务开通概览,不涉及费用计费
### 子账号管理SubAccountView ### 子账号管理SubAccountView
创建子账号**两步流程** 创建子账号**两步流程**
@ -109,7 +118,12 @@ JWT Payload 由 `atob(token.split('.')[1])` 解析,无需额外请求。
|------|------|------| |------|------|------|
| `/login` | LoginView | 运营管理员登录 | | `/login` | LoginView | 运营管理员登录 |
| `/tenants` | TenantListView | 租户列表(搜索/分页/启用禁用) | | `/tenants` | TenantListView | 租户列表(搜索/分页/启用禁用) |
| `/statistics` | StatisticsView | 数据统计 | | `/tenants/:id` | TenantDetailView | 租户详情 |
| `/apps` | AppListView | 应用列表 |
| `/apps/:id` | AppDetailView | 应用详情 |
| `/statistics` | StatisticsView | 服务运营总览 |
| `/service-requests` | ServiceRequestsView | 服务开通审核 |
| `/operation-logs` | OperationLogView | 操作日志 |
### 认证 ### 认证

查看文件

@ -1,17 +1,15 @@
# Android SDK 接入指南 # Android SDK 接入指南
**版本**0.2.xv0.4.0 将引入 UserSig 鉴权)· **最低 Android 版本**API 24 (Android 7.0) · **语言**Kotlin **版本**0.4.xUserSig 鉴权)· **最低 Android 版本**API 24 (Android 7.0) · **语言**Kotlin
> **注意**v0.4.0 将是 Breaking 版本,`initialize()` 将保持不变,`ImSDK.login()` 将改为 UserSig 鉴权模式。
## 功能模块 ## 功能模块
| 模块 | Artifact | 功能 | | 模块 | Artifact | 功能 |
|------|----------|------| |------|----------|------|
| sdk-core | `com.xuqm:sdk-core` | 初始化、网络、鉴权EncryptedSharedPreferences| | sdk-core | `com.xuqm:sdk-core` | 初始化、网络、鉴权、UserSig 续签 |
| sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 | | sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 |
| sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册 | | sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册(华为/小米/OPPO/vivo/荣耀/FCM |
| sdk-update | `com.xuqm:sdk-update` | App 更新检查、RN 热更新 | | sdk-update | `com.xuqm:sdk-update` | App 更新检查、下载安装 |
## 快速接入 ## 快速接入
@ -30,10 +28,10 @@ dependencyResolutionManagement {
```kotlin ```kotlin
// app/build.gradle.kts // app/build.gradle.kts
dependencies { dependencies {
implementation("com.xuqm:sdk-core:0.2.0") implementation("com.xuqm:sdk-core:0.4.0")
implementation("com.xuqm:sdk-im:0.2.0") implementation("com.xuqm:sdk-im:0.4.0")
implementation("com.xuqm:sdk-push:0.2.0") // 按需 implementation("com.xuqm:sdk-push:0.4.0") // 按需
implementation("com.xuqm:sdk-update:0.2.0") // 按需 implementation("com.xuqm:sdk-update:0.4.0") // 按需
} }
``` ```
@ -50,12 +48,18 @@ XuqmSDK.initialize(
) )
``` ```
### 3. IM 登录与收消息 ### 3. IM 登录与收消息UserSig 模式)
```kotlin ```kotlin
// 登录(协程 suspend 函数) // 登录(协程 suspend 函数)
lifecycleScope.launch { lifecycleScope.launch {
ImSDK.login(userId = "user_001", nickname = "张三") XuqmSDK.login(
userId = "user_001",
userSig = "your_user_sig_jwt", // 由业务服务端签发
nickname = "张三",
avatar = "https://...",
userSigExpiresAt = 1893456000000L, // UserSig 过期时间戳(毫秒),可选
)
} }
// 监听实时消息 // 监听实时消息
@ -63,19 +67,30 @@ ImSDK.addListener(object : ImEventListener {
override fun onConnected() { /* WebSocket 已连接 */ } override fun onConnected() { /* WebSocket 已连接 */ }
override fun onMessage(msg: ImMessage) { /* 单聊消息 */ } override fun onMessage(msg: ImMessage) { /* 单聊消息 */ }
override fun onGroupMessage(msg: ImMessage) { /* 群聊消息 */ } override fun onGroupMessage(msg: ImMessage) { /* 群聊消息 */ }
override fun onRead(msg: ImMessage) { /* 对方已读回执 */ }
override fun onRevoke(msg: ImMessage) { /* 消息被撤回 */ }
override fun onDisconnected(reason: String?) { /* 断线处理 */ } override fun onDisconnected(reason: String?) { /* 断线处理 */ }
override fun onError(error: String) { /* 错误回调 */ }
}) })
``` ```
### 4. 发送消息 ### 4. 发送消息
```kotlin ```kotlin
// 发送文本(通过 WebSocket 实时发送) // 发送文本
ImSDK.sendMessage( ImSDK.sendTextMessage(
toId = "user_002", toId = "user_002",
chatType = "SINGLE", chatType = "SINGLE",
msgType = "TEXT", content = "Hello!",
content = """{"text":"Hello!"}""", )
// 发送图片(需先调用 FileSDK 上传)
ImSDK.sendImageMessage(
toId = "user_002",
chatType = "SINGLE",
file = uploadResult,
width = 800,
height = 600,
) )
``` ```
@ -84,7 +99,7 @@ ImSDK.sendMessage(
```kotlin ```kotlin
// 群组 // 群组
val groups = ImSDK.listGroups() val groups = ImSDK.listGroups()
val group = ImSDK.createGroup("项目讨论", listOf("user_002", "user_003")) val group = ImSDK.createGroup("项目讨论", listOf("user_002", "user_003"))
ImSDK.addGroupMember(groupId, "user_004") ImSDK.addGroupMember(groupId, "user_004")
ImSDK.leaveGroup(groupId) ImSDK.leaveGroup(groupId)
@ -95,23 +110,88 @@ ImSDK.addFriend("user_002")
// 会话 // 会话
val conversations = ImSDK.listConversations() val conversations = ImSDK.listConversations()
ImSDK.setConversationPinned(targetId, "SINGLE", true) ImSDK.setConversationPinned(targetId, "SINGLE", true)
ImSDK.markRead(targetId) ImSDK.setConversationMuted(targetId, "SINGLE", true)
ImSDK.markRead(targetId, "SINGLE")
ImSDK.setDraft(targetId, "SINGLE", "草稿内容")
ImSDK.deleteConversation(targetId, "SINGLE")
``` ```
### 6. 推送设备注册 ### 6. Push 多厂商接入
SDK 在登录后会自动检测手机厂商并初始化对应 Push 服务。业务层通常无需手动调用,但可根据需要主动控制:
```kotlin ```kotlin
// 登录后调用;SDK 自动检测手机厂商(华为/小米/OPPO/vivo/荣耀) // 手动检测当前设备厂商
PushSDK.registerDevice(context, userId = "user_001") val vendor = PushSDK.detectVendor()
// 返回HUAWEI / XIAOMI / OPPO / VIVO / HONOR / FCM
// 手动初始化各厂商 Push SDK通常由 onSdkLogin 自动调用)
PushSDK.initializeVendors(context)
// 手动绑定/解绑 IM 用户与 Push Token
PushSDK.bindImUser(context, userId = "user_001")
PushSDK.unbindImUser(userId = "user_001")
// 开启或关闭推送接收
PushSDK.setReceivePush(context, enabled = false)
``` ```
### 7. 版本更新 **各厂商接入注意事项**
| 厂商 | 需额外依赖 | 说明 |
|------|-----------|------|
| 华为 | `com.huawei.hms:push` | 需在华为开发者平台配置 AppID |
| 小米 | `com.xiaomi.mipush:mipush-sdk` | 需在小米开放平台申请 AppID/AppKey |
| OPPO | `com.heytap.mcssdk:mcssdk` | 需在 OPPO 开放平台申请 |
| vivo | `com.vivo.push:vivopush` | 需在 vivo 开放平台申请 |
| 荣耀 | `com.hihonor.mcs:mcs-push` | 需在荣耀开发者服务平台配置 |
| FCM | `com.google.firebase:firebase-messaging` | 模拟器无 Google Play 服务时会 fallback |
### 7. UserSig 续签
`UserSig` 即将过期(默认提前 5 分钟时,SDK 会触发续签回调。业务层应在回调中向自己的服务端申请新的 UserSig,然后重新调用 `XuqmSDK.login()` 刷新 Token 和定时器。
```kotlin
// 设置续签监听器(建议在初始化后、登录前设置)
XuqmSDK.setUserSigRefreshListener {
// 异步获取新 UserSig
lifecycleScope.launch {
val newUserSig = yourBackend.refreshUserSig("user_001")
XuqmSDK.login(
userId = "user_001",
userSig = newUserSig,
userSigExpiresAt = newExpiryTimeMs,
)
}
}
```
> **注意**`userSigExpiresAt` 为 Unix 时间戳(毫秒)。若登录时不传该值,则不会启动续签定时器。
### 8. 版本更新
```kotlin ```kotlin
// 检查 App 更新 // 检查 App 更新
val update = UpdateSDK.checkAppUpdate(context) val update = UpdateSDK.checkAppUpdate(context)
if (update?.needsUpdate == true) { if (update?.needsUpdate == true) {
UpdateSDK.downloadAndInstall(context, update.downloadUrl) UpdateSDK.downloadAndInstall(context, update.downloadUrl) { progress ->
// 更新下载进度 0-100
}
}
```
### 9. 连接状态监听
```kotlin
// 通过 StateFlow 监听 IM 连接状态
lifecycleScope.launch {
ImSDK.connectionState.collect { state ->
when (state) {
is ImConnectionState.Connected -> { /* 已连接 */ }
is ImConnectionState.Connecting -> { /* 连接中 */ }
is ImConnectionState.Disconnected -> { /* 已断开state.reason */ }
}
}
} }
``` ```

查看文件

@ -6,7 +6,7 @@
1. 访问 [XuqmGroup 控制台](https://dev.xuqinmin.com) 1. 访问 [XuqmGroup 控制台](https://dev.xuqinmin.com)
2. 注册租户账号,创建应用 2. 注册租户账号,创建应用
3. 记录 `appKey` 3. 记录 `appKey`Android`appId + appSecret`iOS
## 2. 选择你的平台 ## 2. 选择你的平台
@ -38,14 +38,85 @@ WS 地址wss://dev.xuqinmin.com/ws/im
``` ```
你的业务服务端 你的业务服务端
→ 持有 appKey/appSecret → 持有 appKey/appSecret
→ 调用 IM 登录接口换取 IM Token → 调用 IM 登录接口换取 IM Token(或签发 UserSig JWT
→ 平台内部协议字段由 SDK 和后端自动处理,业务方无需感知 → 平台内部协议字段由 SDK 和后端自动处理,业务方无需感知
→ 返回 Token 给客户端 → 返回 Token / UserSig 给客户端
客户端 SDK 客户端 SDK
→ 使用 Token 初始化 IM 连接 → 使用 Token / UserSig 初始化 IM 连接
→ 建立 WebSocket 长连接 → 建立 WebSocket 长连接
→ 开始收发消息 → 开始收发消息
``` ```
> **安全提示**appSecret 应仅在你的服务端持有,不应下发给客户端。 > **安全提示**appSecret 应仅在你的服务端持有,不应下发给客户端。
---
## 6. Android Demo 运行说明
### 环境要求
- Android Studio Ladybug2024.2.1)或更高版本
- JDK 21
- Android 模拟器或真机API 24+
### 运行步骤
1. 打开 `XuqmGroup-AndroidSDK` 目录为 Android Studio 项目
2. 等待 Gradle Sync 完成(首次可能需要下载依赖)
3. 在 `sample-app/src/main/java/.../MainActivity.kt` 或对应配置中修改服务器地址(如需连接本地环境)
4. 选择模拟器或真机,点击 **Run 'sample-app'**
5. 使用演示账号登录:`user_a` / `123456``user_b` / `123456`
### 关键路径
```
APK 输出XuqmGroup-AndroidSDK/sample-app/build/outputs/apk/debug/sample-app-debug.apk
主 Activitycom.xuqm.sdk.sample.MainActivity
包名com.xuqm.demo
```
### 常用命令
```bash
# 构建全量 SDK + App
cd XuqmGroup-AndroidSDK && ./gradlew clean build
# 安装到指定设备
adb -s emulator-5556 install -r sample-app/build/outputs/apk/debug/sample-app-debug.apk
# 查看 IM 日志
adb -s emulator-5556 logcat -d "*:S" XuqmImSDK:D XuqmImClient:D 2>/dev/null | tail -30
```
---
## 7. iOS Demo 运行说明
### 环境要求
- Xcode 16.0 或更高版本
- iOS 18 模拟器或真机(最低支持 iOS 14
- Swift 5.9+
### 运行步骤
1. 打开 `XuqmGroup-iOSSDK/XuqmDemo/XuqmDemo.xcodeproj`(或 `.xcworkspace`
2. 等待 Swift Package Manager 依赖解析完成
3. 在 Xcode 顶部选择目标模拟器(如 iPhone 16 Pro或连接的真机
4. 点击 **Run**(⌘+R
5. 使用演示账号登录验证消息收发
### 关键路径
```
Demo 工程XuqmGroup-iOSSDK/XuqmDemo/XuqmDemo.xcodeproj
SDK 源码XuqmGroup-iOSSDK/Sources/XuqmSDK/
单元测试XuqmGroup-iOSSDK/Tests/
```
### 常见问题
- **SPM 依赖下载慢**:检查网络或更换 Xcode → Preferences → Accounts → Git 配置
- **真机运行失败**:确保 Apple Developer Account 已配置 Signing & Capabilities
- **Push 测试**:模拟器不支持 APNs,Push 功能需在真机测试

查看文件

@ -6,10 +6,10 @@
| 模块 | 功能 | | 模块 | 功能 |
|------|------| |------|------|
| XuqmCore | 初始化、网络、鉴权 | | XuqmCore | 初始化、网络、鉴权、UserSig 过期检测 |
| XuqmIM | 单聊、群聊、消息收发13 种类型)| | XuqmIM | 单聊、群聊、消息收发15 种类型)、连接状态监听 |
| XuqmPush | APNs 设备 Token 注册、通知处理 | | XuqmPush | APNs 设备 Token 注册、FCM 备选、通知处理 |
| XuqmUpdate | App 版本检查、RN Bundle 热更新 | | XuqmUpdate | App 版本检查、App Store 跳转 |
## 安装 ## 安装
@ -35,8 +35,9 @@ dependencies: [
.target( .target(
name: "MyApp", name: "MyApp",
dependencies: [ dependencies: [
.product(name: "XuqmCore", package: "XuqmGroup-iOSSDK"), .product(name: "XuqmCore", package: "XuqmGroup-iOSSDK"),
.product(name: "XuqmIM", package: "XuqmGroup-iOSSDK"), .product(name: "XuqmIM", package: "XuqmGroup-iOSSDK"),
.product(name: "XuqmPush", package: "XuqmGroup-iOSSDK"),
.product(name: "XuqmUpdate", package: "XuqmGroup-iOSSDK"), .product(name: "XuqmUpdate", package: "XuqmGroup-iOSSDK"),
] ]
) )
@ -51,91 +52,169 @@ dependencies: [
```swift ```swift
import XuqmCore import XuqmCore
XuqmSDK.shared.initialize( let config = SDKConfig(appId: "your_app_id", appSecret: "your_app_secret")
appKey: "your_app_key", XuqmSDK.shared.initialize(config: config)
appSecret: "your_app_secret",
debug: false
)
``` ```
### 2. IM 登录与监听消息 ### 2. IM 登录与监听消息UserSig 模式)
```swift ```swift
import XuqmIM import XuqmIM
// 登录appKey 已在 init 时指定 // 方式一:使用 UserSig 登录(推荐生产环境
try await ImSDK.shared.login(userId: "user_001", nickname: "张三") try await XuqmSDK.shared.login(userId: "user_001", userSig: "your_user_sig_jwt")
// 监听事件 // 方式二Demo 环境快速登录
ImSDK.shared.addListener(self) try await ImSDK.shared.loginWithDemo(userId: "user_001", password: "123456")
extension ViewController: ImEventListener { // 设置事件代理
func onConnected() { print("WS connected") } ImSDK.shared.setDelegate(self)
func onMessage(_ msg: ImMessage) { /* 处理消息 */ }
func onDisconnected(reason: String?) { /* 断线 */ } extension ViewController: ImEventDelegate {
func imClientDidConnect() { print("WS connected") }
func imClientDidReceiveMessage(_ msg: ImMessage) { /* 处理单聊消息 */ }
func imClientDidReceiveGroupMessage(_ msg: ImMessage) { /* 处理群消息 */ }
func imClientDidReadMessage(_ msg: ImMessage) { /* 对方已读回执 */ }
func imClientDidReceiveRevokedMessage(_ msg: ImMessage) { /* 消息被撤回 */ }
func imClientDidDisconnect(reason: String?) { /* 断线 */ }
func imClientDidError(_ error: String) { /* 错误 */ }
} }
``` ```
### 3. 发送消息 ### 3. 连接状态监听
SDK 暴露 `connectionState` 属性与 `addConnectionStateListener`,方便业务层实时感知 WebSocket 连接状态:
```swift
// 查询当前状态
let state = ImSDK.shared.connectionState
// 返回:.disconnected / .connecting / .connected
// 注册状态变更监听器
ImSDK.shared.addConnectionStateListener { state in
switch state {
case .connected:
print("IM 已连接")
case .connecting:
print("IM 连接中...")
case .disconnected:
print("IM 已断开")
}
}
```
> 状态变更触发时机:
> - 登录后 → `.connecting``.connected`
> - 网络异常/断线 → `.disconnected`,内部自动重连 → `.connecting``.connected`
> - 调用 `disconnect()``logout()``.disconnected`
### 4. 发送消息
```swift ```swift
// 发文本 // 发文本
let sent = try await ImSDK.shared.sendMessage( let msg = ImSDK.shared.sendTextMessage(
toId: "user_002", toId: "user_002",
chatType: .single, chatType: .single,
msgType: .text, content: "Hello!"
content: "Hello!"
) )
// 发图片content 为 JSON 字符串) // 发图片
let imgContent = try JSONSerialization.data(withJSONObject: [ let imgContent = try JSONSerialization.data(withJSONObject: [
"url": "https://cdn.example.com/img.jpg", "url": "https://cdn.example.com/img.jpg",
"width": 800, "height": 600 "width": 800, "height": 600
]) ])
let sent = try await ImSDK.shared.sendMessage( let msg2 = try await ImSDK.shared.sendMessage(
toId: "user_002", chatType: .single, toId: "user_002", chatType: .single,
msgType: .image, content: String(data: imgContent, encoding: .utf8)! msgType: .image, content: String(data: imgContent, encoding: .utf8)!
) )
``` ```
### 4. 撤回消息 ### 5. 撤回与编辑
```swift ```swift
// 撤回消息
let revoked = try await ImSDK.shared.revokeMessage(messageId: msg.id) let revoked = try await ImSDK.shared.revokeMessage(messageId: msg.id)
// 编辑消息
let edited = try await ImSDK.shared.editMessage(messageId: msg.id, content: "新内容")
``` ```
### 5. 群聊 ### 6. 群聊
```swift ```swift
// 创建群组 // 创建群组
let group = try await ImSDK.shared.createGroup(name: "我的群", memberIds: ["user_001", "user_002"]) let group = try await ImSDK.shared.createGroup(name: "我的群", memberIds: ["user_001", "user_002"])
// 订阅群消息 // 订阅群消息
ImSDK.shared.subscribeGroup(groupId: group.id) ImSDK.shared.subscribeGroup(group.id)
// 发送群消息 // 发送群消息
try await ImSDK.shared.sendMessage( ImSDK.shared.sendTextMessage(toId: group.id, chatType: .group, content: "大家好")
toId: group.id, chatType: .group, msgType: .text, content: "大家好"
) // 群管理
try await ImSDK.shared.addGroupMember(groupId: group.id, userId: "user_003")
try await ImSDK.shared.leaveGroup(groupId: group.id)
``` ```
### 6. 检查更新 ### 7. 会话管理
```swift
// 会话列表
let conversations = try await ImSDK.shared.listConversations()
// 置顶 / 静音 / 已读 / 草稿 / 删除
try await ImSDK.shared.setConversationPinned(targetId: "user_002", chatType: .single, pinned: true)
try await ImSDK.shared.setConversationMuted(targetId: "user_002", chatType: .single, muted: true)
try await ImSDK.shared.markRead(targetId: "user_002", chatType: .single)
try await ImSDK.shared.setDraft(targetId: "user_002", chatType: .single, draft: "未完成")
try await ImSDK.shared.deleteConversation(targetId: "user_002", chatType: .single)
```
### 8. Push 设备注册APNs + FCM
```swift
import XuqmPush
// 请求通知授权并自动注册 APNs
let granted = try await PushSDK.shared.requestAuthorization(options: [.alert, .badge, .sound])
// 在 AppDelegate 中转发 deviceToken
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
XuqmSDK.shared.registerDeviceToken(deviceToken)
}
// 如需手动注册 FCM Token若集成 FirebaseMessaging
try await PushSDK.shared.registerFcmToken(fcmToken, userId: "user_001")
```
### 9. UserSig 过期检测
`XuqmSDK` 会在登录时解析 UserSig JWT 的 `exp` 字段,并在过期前 5 分钟触发 `onUserSigExpired` 回调。业务层应在此回调中重新获取 UserSig 并登录。
```swift
// 设置过期回调(建议在初始化后设置)
XuqmSDK.shared.onUserSigExpired = { [weak self] in
Task {
let newUserSig = await self?.yourBackend.refreshUserSig()
try? await XuqmSDK.shared.login(userId: "user_001", userSig: newUserSig)
}
}
```
> **注意**
> - 回调触发时机为 `exp - 300s`(提前 5 分钟)。
> - 调用 `logout()` 后内部 Timer 会被自动 `invalidate`
> - 若 UserSig 已过期(`exp <= now`),回调会立即触发。
### 10. 检查更新
```swift ```swift
import XuqmUpdate import XuqmUpdate
// App 整包更新 // App 整包更新
let appInfo = try await UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1) let appInfo = try await UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)
if let info = appInfo, info.forceUpdate { if let info = appInfo, info.forceUpdate == true {
// 强制更新:跳转 App Store 或下载链接 UpdateSDK.shared.openAppStore(url: info.appStoreUrl ?? info.downloadUrl!)
UIApplication.shared.open(URL(string: info.downloadUrl ?? info.appStoreUrl!)!)
}
// RN Bundle 热更新
let bundle = try await UpdateSDK.shared.checkRNUpdate(moduleId: "home", currentVersion: "1.0.0")
if let bundle = bundle {
let data = try await UpdateSDK.shared.downloadBundle(url: bundle.downloadUrl)
// 缓存至本地,下次启动时由 BundleRuntime 加载
} }
``` ```
@ -150,8 +229,11 @@ if let bundle = bundle {
| `.file` | 文件 | `{url, name, size, mimeType}` | | `.file` | 文件 | `{url, name, size, mimeType}` |
| `.location` | 位置 | `{lat, lng, address, title}` | | `.location` | 位置 | `{lat, lng, address, title}` |
| `.custom` | 自定义 | 任意 JSON | | `.custom` | 自定义 | 任意 JSON |
| `.notify` | 系统通知 | `{title, content, level}` | | `.notify` | 系统通知 | `{title, content}` |
| `.richText` | 富文本 | `{html}` | | `.richText` | 富文本 | `{html}` |
| `.callAudio` | 语音通话信令 | `{callId, action, callerName}` | | `.callAudio` | 语音通话信令 | `{callId, action, callerName}` |
| `.callVideo` | 视频通话信令 | `{callId, action, callerName}` | | `.callVideo` | 视频通话信令 | `{callId, action, callerName}` |
| `.forward` | 转发 | `{originalMsgId, originalContent, originalSender}` | | `.forward` | 转发 | `{originalMsgId, originalContent, originalSender}` |
| `.quote` | 引用 | `{quotedMsgId, quotedContent, text}` |
| `.merge` | 合并转发 | `{title, msgList}` |
| `.revoked` | 撤回 | 系统内部填充 |

查看文件

@ -12,6 +12,8 @@ declare module 'vue' {
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
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']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
@ -19,14 +21,17 @@ declare module 'vue' {
ElHeader: typeof import('element-plus/es')['ElHeader'] ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain'] ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
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']
ElStatistic: typeof import('element-plus/es')['ElStatistic'] ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']

查看文件

@ -9,9 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"echarts": "^6.0.0",
"element-plus": "^2.9.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
@ -19,9 +20,9 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8",
"unplugin-auto-import": "^0.18.2", "unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4" "unplugin-vue-components": "^0.27.4",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
} }
} }

查看文件

@ -12,6 +12,13 @@ export interface TenantItem {
createdAt: string createdAt: string
} }
export interface TenantDetail extends TenantItem {
appCount: number
subAccountCount: number
activeServiceCount: number
apps: AppItem[]
}
export interface TenantPage { export interface TenantPage {
content: TenantItem[] content: TenantItem[]
total: number total: number
@ -43,6 +50,80 @@ export interface ServiceRequestPage {
totalPages: number totalPages: number
} }
export interface AppItem {
id: string
appKey: string
appSecret: string
name: string
packageName: string
tenantId: string
createdAt: string
}
export interface FeatureServiceItem {
id: string
appId: string
platform: string
serviceType: string
enabled: boolean
config?: string | null
createdAt: string
}
export interface AppDetail {
app: AppItem
tenant: TenantItem | null
services: FeatureServiceItem[]
serviceCount: number
enabledServiceCount: number
}
export interface AppPage {
content: AppItem[]
total: number
totalPages: number
}
export interface OpsLogItem {
id: string
tenantId: string
moduleType: string
resourceType: string
resourceId: string
action: string
operator: string
detailJson: string
createdAt: string
}
export interface OpsLogPage {
content: OpsLogItem[]
total: number
totalPages: number
}
export interface RiskRuleForm {
ipRateLimit: number
loginFailThreshold: number
loginLockMinutes: number
abnormalDetection: boolean
}
export interface SensitiveWord {
id: string
word: string
level: '高' | '中' | '低'
category: string
enabled: boolean
updatedAt: string
}
export interface SensitiveWordPage {
content: SensitiveWord[]
total: number
totalPages: number
}
export const opsApi = { export const opsApi = {
listTenants: (keyword = '', page = 0, size = 20) => listTenants: (keyword = '', page = 0, size = 20) =>
client.get<{ data: TenantPage }>('/ops/tenants', { params: { keyword, page, size } }), client.get<{ data: TenantPage }>('/ops/tenants', { params: { keyword, page, size } }),
@ -50,6 +131,12 @@ export const opsApi = {
toggleStatus: (id: string) => toggleStatus: (id: string) =>
client.post(`/ops/tenants/${id}/toggle-status`), client.post(`/ops/tenants/${id}/toggle-status`),
getTenant: (id: string) =>
client.get<{ data: TenantDetail }>(`/ops/tenants/${id}`),
listTenantApps: (id: string) =>
client.get<{ data: AppItem[] }>(`/ops/tenants/${id}/apps`),
statistics: () => statistics: () =>
client.get<{ data: Statistics }>('/ops/statistics'), client.get<{ data: Statistics }>('/ops/statistics'),
@ -61,4 +148,38 @@ export const opsApi = {
rejectRequest: (requestId: string, reviewNote = '') => rejectRequest: (requestId: string, reviewNote = '') =>
client.post<{ data: ServiceRequest }>(`/ops/service-requests/${requestId}/reject`, { reviewNote }), client.post<{ data: ServiceRequest }>(`/ops/service-requests/${requestId}/reject`, { reviewNote }),
listApps: (keyword = '', page = 0, size = 20) =>
client.get<{ data: AppPage }>('/ops/apps', { params: { keyword, page, size } }),
getApp: (id: string) =>
client.get<{ data: AppDetail }>(`/ops/apps/${id}`),
listAppServices: (id: string) =>
client.get<{ data: FeatureServiceItem[] }>(`/ops/apps/${id}/services`),
listOperationLogs: (page = 0, size = 20) =>
client.get<{ data: OpsLogPage }>('/ops/operation-logs', { params: { page, size } }),
// 风控相关 APImock 实现,TODO接入后端
getRiskRules: () =>
client.get<{ data: RiskRuleForm }>('/ops/risk/rules'),
saveRiskRules: (rules: RiskRuleForm) =>
client.post('/ops/risk/rules', rules),
listSensitiveWords: (page = 0, size = 20) =>
client.get<{ data: SensitiveWordPage }>('/ops/risk/sensitive-words', { params: { page, size } }),
createSensitiveWord: (word: Omit<SensitiveWord, 'id' | 'updatedAt'>) =>
client.post<{ data: SensitiveWord }>('/ops/risk/sensitive-words', word),
updateSensitiveWord: (id: string, word: Partial<SensitiveWord>) =>
client.put<{ data: SensitiveWord }>(`/ops/risk/sensitive-words/${id}`, word),
toggleSensitiveWord: (id: string, enabled: boolean) =>
client.patch(`/ops/risk/sensitive-words/${id}/toggle`, { enabled }),
deleteSensitiveWord: (id: string) =>
client.delete(`/ops/risk/sensitive-words/${id}`),
} }

查看文件

@ -11,8 +11,13 @@ const router = createRouter({
children: [ children: [
{ path: '', redirect: '/tenants' }, { path: '', redirect: '/tenants' },
{ path: 'tenants', component: () => import('@/views/tenants/TenantListView.vue') }, { path: 'tenants', component: () => import('@/views/tenants/TenantListView.vue') },
{ path: 'tenants/:id', component: () => import('@/views/tenants/TenantDetailView.vue') },
{ path: 'statistics', component: () => import('@/views/statistics/StatisticsView.vue') }, { path: 'statistics', component: () => import('@/views/statistics/StatisticsView.vue') },
{ path: 'service-requests', component: () => import('@/views/services/ServiceRequestsView.vue') }, { path: 'service-requests', component: () => import('@/views/services/ServiceRequestsView.vue') },
{ path: 'apps', component: () => import('@/views/apps/AppListView.vue') },
{ path: 'apps/:id', component: () => import('@/views/apps/AppDetailView.vue') },
{ path: 'operation-logs', component: () => import('@/views/logs/OperationLogView.vue') },
{ path: 'risk-control', component: () => import('@/views/risk/RiskControlView.vue') },
], ],
}, },
], ],

查看文件

@ -0,0 +1,87 @@
<template>
<div v-if="detail">
<el-page-header @back="$router.back()" :content="detail.app.name" style="margin-bottom: 20px" />
<el-card style="margin-bottom: 16px">
<template #header>
<div class="header-row">
<span>应用信息</span>
<el-tag :type="detail.enabledServiceCount > 0 ? 'success' : 'info'">
已开通 {{ detail.enabledServiceCount }} 项服务
</el-tag>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="应用名称">{{ detail.app.name }}</el-descriptions-item>
<el-descriptions-item label="包名">{{ detail.app.packageName }}</el-descriptions-item>
<el-descriptions-item label="AppKey">{{ detail.app.appKey }}</el-descriptions-item>
<el-descriptions-item label="AppSecret">{{ mask(detail.app.appSecret) }}</el-descriptions-item>
<el-descriptions-item label="租户ID">{{ detail.app.tenantId }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ fmt(detail.app.createdAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card style="margin-bottom: 16px">
<template #header>所属租户</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="租户昵称">{{ detail.tenant?.nickname || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户用户名">{{ detail.tenant?.username || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户邮箱">{{ detail.tenant?.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户状态">{{ detail.tenant?.status === 'ACTIVE' ? '正常' : '禁用' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card>
<template #header>功能服务</template>
<el-table :data="detail.services" border stripe>
<el-table-column prop="platform" label="平台" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="140" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">
{{ row.enabled ? '已开通' : '未开通' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="config" label="配置" min-width="240" show-overflow-tooltip />
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { opsApi, type AppDetail } from '@/api/ops'
const route = useRoute()
const detail = ref<AppDetail | null>(null)
async function loadDetail() {
const res = await opsApi.getApp(route.params.id as string)
detail.value = res.data.data
}
function fmt(value: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
function mask(value: string) {
if (!value) return '-'
return `${value.slice(0, 4)}********${value.slice(-4)}`
}
onMounted(loadDetail)
</script>
<style scoped>
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
</style>

查看文件

@ -0,0 +1,59 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>应用管理</h2>
<el-input v-model="search" placeholder="搜索应用名称 / AppKey" style="width:280px" clearable @clear="loadApps" @keyup.enter="loadApps">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<el-table :data="apps" v-loading="loading" border stripe>
<el-table-column prop="appKey" label="AppKey" width="220" />
<el-table-column prop="name" label="应用名称" min-width="160" />
<el-table-column prop="packageName" label="包名" min-width="160" />
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="$router.push(`/apps/${row.id}`)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:16px"
:current-page="page + 1" :page-size="size" :total="total"
layout="prev, pager, next" @current-change="(p: number) => { page = p - 1; loadApps() }" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { opsApi, type AppItem } from '@/api/ops'
const apps = ref<AppItem[]>([])
const loading = ref(false)
const search = ref('')
const page = ref(0)
const size = ref(20)
const total = ref(0)
async function loadApps() {
loading.value = true
try {
const res = await opsApi.listApps(search.value, page.value, size.value)
apps.value = res.data.data.content
total.value = res.data.data.total
} catch {
apps.value = []
} finally {
loading.value = false
}
}
onMounted(loadApps)
</script>

查看文件

@ -66,7 +66,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Avatar, Bell, Menu, TrendCharts } from '@element-plus/icons-vue' import { Avatar, Bell, Menu, TrendCharts, Grid, Document, Warning } from '@element-plus/icons-vue'
const router = useRouter() const router = useRouter()
const isMobile = ref(false) const isMobile = ref(false)
@ -74,8 +74,11 @@ const drawerVisible = ref(false)
const navItems = computed(() => [ const navItems = computed(() => [
{ path: '/tenants', label: '租户管理', icon: Avatar }, { path: '/tenants', label: '租户管理', icon: Avatar },
{ path: '/statistics', label: '数据统计', icon: TrendCharts }, { path: '/statistics', label: '服务运营总览', icon: TrendCharts },
{ path: '/service-requests', label: '服务开通审核', icon: Bell }, { path: '/service-requests', label: '服务开通审核', icon: Bell },
{ path: '/apps', label: '应用管理', icon: Grid },
{ path: '/operation-logs', label: '操作日志', icon: Document },
{ path: '/risk-control', label: '风控配置', icon: Warning },
]) ])
function logout() { function logout() {

查看文件

@ -0,0 +1,77 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>操作日志</h2>
<el-button @click="loadLogs" :loading="loading">刷新</el-button>
</div>
<el-table :data="logs" v-loading="loading" border stripe>
<el-table-column prop="createdAt" label="时间" width="180">
<template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作者" width="140" />
<el-table-column prop="moduleType" label="模块" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.moduleType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="action" label="动作" width="180" />
<el-table-column prop="resourceType" label="资源类型" width="120" />
<el-table-column prop="resourceId" label="资源ID" width="220" show-overflow-tooltip />
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
<el-table-column label="详情" min-width="200">
<template #default="{ row }">
<span class="detail-text">{{ formatDetail(row.detailJson) }}</span>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:16px"
:current-page="page + 1" :page-size="size" :total="total"
layout="prev, pager, next" @current-change="(p: number) => { page = p - 1; loadLogs() }" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { opsApi, type OpsLogItem } from '@/api/ops'
const logs = ref<OpsLogItem[]>([])
const loading = ref(false)
const page = ref(0)
const size = ref(20)
const total = ref(0)
function formatDetail(json: string) {
if (!json) return '-'
try {
const obj = JSON.parse(json)
return Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(', ')
} catch {
return json
}
}
async function loadLogs() {
loading.value = true
try {
const res = await opsApi.listOperationLogs(page.value, size.value)
logs.value = res.data.data.content
total.value = res.data.data.total
} catch {
logs.value = []
} finally {
loading.value = false
}
}
onMounted(loadLogs)
</script>
<style scoped>
.detail-text {
color: #606266;
font-size: 13px;
word-break: break-all;
}
</style>

查看文件

@ -0,0 +1,283 @@
<template>
<div>
<h2 style="margin-bottom:12px">风控配置</h2>
<p style="margin:0 0 24px;color:#606266">
管理敏感词库与全局风控规则保障平台安全运行
</p>
<!-- 全局风控规则 -->
<el-card style="margin-bottom: 16px">
<template #header>
<span>全局风控规则</span>
</template>
<el-form :model="ruleForm" label-width="160px" :label-position="isMobile ? 'top' : 'right'">
<el-form-item label="IP限流阈值次/分)">
<el-input-number v-model="ruleForm.ipRateLimit" :min="10" :max="10000" />
</el-form-item>
<el-form-item label="登录失败次数阈值">
<el-input-number v-model="ruleForm.loginFailThreshold" :min="3" :max="20" />
</el-form-item>
<el-form-item label="登录失败锁定时长(分)">
<el-input-number v-model="ruleForm.loginLockMinutes" :min="5" :max="1440" />
</el-form-item>
<el-form-item label="账号异常检测">
<el-switch v-model="ruleForm.abnormalDetection" active-text="开启" inactive-text="关闭" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveRules" :loading="ruleLoading">保存配置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 敏感词库 -->
<el-card>
<template #header>
<div class="card-header">
<span>敏感词库</span>
<el-button type="primary" @click="openDialog()">新增敏感词</el-button>
</div>
</template>
<el-table :data="wordList" v-loading="tableLoading" border stripe>
<el-table-column prop="word" label="敏感词" min-width="160" />
<el-table-column prop="level" label="风险等级" width="120">
<template #default="{ row }">
<el-tag :type="levelTagType(row.level)">{{ row.level }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="140" />
<el-table-column prop="enabled" label="状态" width="100">
<template #default="{ row }">
<el-switch v-model="row.enabled" @change="toggleWord(row)" />
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="180">
<template #default="{ row }">{{ fmt(row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
<el-button link type="danger" @click="deleteWord(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="size"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
@size-change="loadWords"
@current-change="loadWords"
/>
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="敏感词" prop="word">
<el-input v-model="form.word" placeholder="请输入敏感词" />
</el-form-item>
<el-form-item label="风险等级" prop="level">
<el-select v-model="form.level" placeholder="请选择风险等级" style="width:100%">
<el-option label="低" value="低" />
<el-option label="中" value="中" />
<el-option label="高" value="高" />
</el-select>
</el-form-item>
<el-form-item label="分类" prop="category">
<el-input v-model="form.category" placeholder="如:政治、色情、广告" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { opsApi, type RiskRuleForm, type SensitiveWord } from '@/api/ops'
const isMobile = ref(window.innerWidth < 768)
const updateViewport = () => { isMobile.value = window.innerWidth < 768 }
onMounted(() => { window.addEventListener('resize', updateViewport) })
/* ---------- 全局规则 ---------- */
const ruleForm = reactive<RiskRuleForm>({
ipRateLimit: 300,
loginFailThreshold: 5,
loginLockMinutes: 30,
abnormalDetection: true,
})
const ruleLoading = ref(false)
async function saveRules() {
ruleLoading.value = true
try {
// TODO:
// await opsApi.saveRiskRules({ ...ruleForm })
await new Promise(r => setTimeout(r, 500))
ElMessage.success('配置已保存')
} catch {
ElMessage.error('保存失败')
} finally {
ruleLoading.value = false
}
}
/* ---------- 敏感词 ---------- */
const wordList = ref<SensitiveWord[]>([])
const tableLoading = ref(false)
const page = ref(1)
const size = ref(20)
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增敏感词')
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const isEdit = ref(false)
const form = reactive<Partial<SensitiveWord>>({
word: '',
level: '中',
category: '',
enabled: true,
})
const formRules: FormRules = {
word: [{ required: true, message: '请输入敏感词', trigger: 'blur' }],
level: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
category: [{ required: true, message: '请输入分类', trigger: 'blur' }],
}
function levelTagType(level: string) {
if (level === '高') return 'danger'
if (level === '中') return 'warning'
return 'info'
}
async function loadWords() {
tableLoading.value = true
try {
// TODO:
// const res = await opsApi.listSensitiveWords(page.value - 1, size.value)
await new Promise(r => setTimeout(r, 300))
wordList.value = generateMockWords(page.value, size.value)
total.value = 63
} catch {
ElMessage.error('加载失败')
} finally {
tableLoading.value = false
}
}
function generateMockWords(pageNum: number, pageSize: number): SensitiveWord[] {
const words = ['违规', '垃圾广告', '诈骗', '恶意攻击', '敏感信息', '暴力', '赌博', '毒品']
const levels: Array<'高' | '中' | '低'> = ['高', '中', '低']
const categories = ['政治', '色情', '广告', '欺诈', '暴力']
const result: SensitiveWord[] = []
const start = (pageNum - 1) * pageSize
for (let i = 0; i < pageSize; i++) {
const idx = start + i
if (idx >= 63) break
result.push({
id: `word_${idx}`,
word: `${words[idx % words.length]}_${idx + 1}`,
level: levels[idx % levels.length],
category: categories[idx % categories.length],
enabled: idx % 3 !== 0,
updatedAt: new Date(Date.now() - idx * 86400000).toISOString(),
})
}
return result
}
function openDialog(row?: SensitiveWord) {
isEdit.value = !!row
dialogTitle.value = row ? '编辑敏感词' : '新增敏感词'
if (row) {
Object.assign(form, { ...row })
} else {
Object.assign(form, { word: '', level: '中', category: '', enabled: true })
}
dialogVisible.value = true
}
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
// TODO:
// if (isEdit.value && form.id) {
// await opsApi.updateSensitiveWord(form.id, form as SensitiveWord)
// } else {
// await opsApi.createSensitiveWord(form as SensitiveWord)
// }
await new Promise(r => setTimeout(r, 400))
ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
dialogVisible.value = false
loadWords()
} catch {
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
}
async function toggleWord(row: SensitiveWord) {
try {
// TODO:
// await opsApi.toggleSensitiveWord(row.id, row.enabled)
ElMessage.success('状态已更新')
} catch {
row.enabled = !row.enabled
ElMessage.error('更新失败')
}
}
async function deleteWord(row: SensitiveWord) {
try {
await ElMessageBox.confirm(`确定删除敏感词「${row.word}」吗?`, '提示', { type: 'warning' })
// TODO:
// await opsApi.deleteSensitiveWord(row.id)
ElMessage.success('删除成功')
loadWords()
} catch {
// cancel
}
}
function fmt(value: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
onMounted(() => {
loadWords()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>

查看文件

@ -1,26 +1,47 @@
<template> <template>
<div> <div>
<h2 style="margin-bottom:24px">数据统计</h2> <h2 style="margin-bottom:12px">服务运营总览</h2>
<p style="margin:0 0 24px;color:#606266">
汇总租户应用与服务开通情况便于快速判断平台健康状态
</p>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="6" v-for="item in stats" :key="item.label"> <el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover"> <el-card shadow="hover">
<el-statistic :title="item.label" :value="item.value" /> <el-statistic :title="item.label" :value="item.value" />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-card style="margin-top:24px"> <el-row :gutter="16" style="margin-top:16px">
<template #header>近7天注册趋势</template> <el-col :xs="24" :md="12">
<div style="text-align:center;padding:40px;color:#999"> <el-card>
图表区域 可集成 ECharts Chart.js <template #header>近7天注册趋势</template>
</div> <div ref="trendChartRef" class="chart-box" />
</el-card> </el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card>
<template #header>服务开通分布</template>
<div ref="pieChartRef" class="chart-box" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :xs="24">
<el-card>
<template #header>消息量统计</template>
<div ref="barChartRef" class="chart-box" />
</el-card>
</el-col>
</el-row>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { opsApi } from '@/api/ops' import { opsApi } from '@/api/ops'
import * as echarts from 'echarts'
const stats = ref([ const stats = ref([
{ label: '总租户数', value: 0 }, { label: '总租户数', value: 0 },
@ -29,6 +50,95 @@ const stats = ref([
{ label: '在线用户', value: 0 }, { label: '在线用户', value: 0 },
]) ])
const trendChartRef = ref<HTMLDivElement>()
const pieChartRef = ref<HTMLDivElement>()
const barChartRef = ref<HTMLDivElement>()
let trendChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null
let barChart: echarts.ECharts | null = null
function initTrendChart() {
if (!trendChartRef.value) return
const dates = Array.from({ length: 7 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() - (6 - i))
return `${d.getMonth() + 1}/${d.getDate()}`
})
trendChart = echarts.init(trendChartRef.value)
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: dates, boundaryGap: false },
yAxis: { type: 'value', minInterval: 1 },
series: [{
name: '新增注册',
type: 'line',
smooth: true,
data: [12, 19, 8, 24, 32, 18, 27],
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64,158,255,0.3)' },
{ offset: 1, color: 'rgba(64,158,255,0.05)' },
]),
},
itemStyle: { color: '#409eff' },
}],
})
}
function initPieChart() {
if (!pieChartRef.value) return
pieChart = echarts.init(pieChartRef.value)
pieChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: '0%' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: [
{ value: 1048, name: 'IM' },
{ value: 735, name: 'Push' },
{ value: 580, name: 'Update' },
{ value: 484, name: 'Webhook' },
{ value: 300, name: '灰度发布' },
],
}],
})
}
function initBarChart() {
if (!barChartRef.value) return
const dates = Array.from({ length: 7 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() - (6 - i))
return `${d.getMonth() + 1}/${d.getDate()}`
})
barChart = echarts.init(barChartRef.value)
barChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['发送消息', '接收消息'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value' },
series: [
{ name: '发送消息', type: 'bar', data: [120, 132, 101, 134, 90, 230, 210], itemStyle: { color: '#409eff' } },
{ name: '接收消息', type: 'bar', data: [220, 182, 191, 234, 290, 330, 310], itemStyle: { color: '#67c23a' } },
],
})
// TODO:
}
function handleResize() {
trendChart?.resize()
pieChart?.resize()
barChart?.resize()
}
onMounted(async () => { onMounted(async () => {
try { try {
const res = await opsApi.statistics() const res = await opsApi.statistics()
@ -38,5 +148,24 @@ onMounted(async () => {
stats.value[2].value = d.activeApps ?? 0 stats.value[2].value = d.activeApps ?? 0
stats.value[3].value = d.onlineUsers ?? 0 stats.value[3].value = d.onlineUsers ?? 0
} catch {} } catch {}
initTrendChart()
initPieChart()
initBarChart()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
trendChart?.dispose()
pieChart?.dispose()
barChart?.dispose()
}) })
</script> </script>
<style scoped>
.chart-box {
width: 100%;
height: 320px;
}
</style>

查看文件

@ -0,0 +1,104 @@
<template>
<div v-if="tenant">
<el-page-header @back="$router.back()" :content="tenant.nickname || tenant.username" style="margin-bottom: 20px" />
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :xs="24" :sm="8">
<el-card shadow="hover">
<el-statistic title="应用数" :value="tenant.appCount" />
</el-card>
</el-col>
<el-col :xs="24" :sm="8">
<el-card shadow="hover">
<el-statistic title="子账号数" :value="tenant.subAccountCount" />
</el-card>
</el-col>
<el-col :xs="24" :sm="8">
<el-card shadow="hover">
<el-statistic title="已开通服务" :value="tenant.activeServiceCount" />
</el-card>
</el-col>
</el-row>
<el-card style="margin-bottom: 16px">
<template #header>
<div class="header-row">
<span>租户信息</span>
<el-button :type="tenant.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus">
{{ tenant.status === 'ACTIVE' ? '禁用租户' : '启用租户' }}
</el-button>
</div>
</template>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="用户名">{{ tenant.username }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ tenant.nickname }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ tenant.email }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ tenant.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ tenant.type === 'MAIN' ? '主账号' : '子账号' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ tenant.status === 'ACTIVE' ? '正常' : '禁用' }}</el-descriptions-item>
<el-descriptions-item label="父租户ID">{{ tenant.parentId || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ fmt(tenant.createdAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card>
<template #header>租户应用</template>
<el-table :data="tenant.apps" border stripe>
<el-table-column prop="name" label="应用名称" min-width="160" />
<el-table-column prop="packageName" label="包名" min-width="180" />
<el-table-column prop="appKey" label="AppKey" min-width="220" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="$router.push(`/apps/${row.id}`)">详情</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { opsApi, type TenantDetail } from '@/api/ops'
const route = useRoute()
const tenant = ref<TenantDetail | null>(null)
const isMobile = computed(() => window.innerWidth < 768)
async function loadDetail() {
const res = await opsApi.getTenant(route.params.id as string)
tenant.value = res.data.data
}
async function toggleStatus() {
if (!tenant.value) return
await ElMessageBox.confirm(
`确认${tenant.value.status === 'ACTIVE' ? '禁用' : '启用'}该租户?`,
'提示',
{ type: 'warning' },
)
await opsApi.toggleStatus(tenant.value.id)
ElMessage.success('状态已更新')
await loadDetail()
}
function fmt(value: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
onMounted(loadDetail)
</script>
<style scoped>
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
</style>

查看文件

@ -30,6 +30,9 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="$router.push(`/tenants/${row.id}`)">
详情
</el-button>
<el-button link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" <el-button link :type="row.status === 'ACTIVE' ? 'danger' : 'success'"
@click="toggleStatus(row)"> @click="toggleStatus(row)">
{{ row.status === 'ACTIVE' ? '禁用' : '启用' }} {{ row.status === 'ACTIVE' ? '禁用' : '启用' }}

26
package-lock.json 自动生成的
查看文件

@ -1852,6 +1852,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/editorconfig": { "node_modules/editorconfig": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
@ -3769,6 +3779,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@ -4658,6 +4674,15 @@
} }
} }
}, },
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zwitch": { "node_modules/zwitch": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz",
@ -4674,6 +4699,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"echarts": "^6.0.0",
"element-plus": "^2.9.1", "element-plus": "^2.9.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.13", "vue": "^3.5.13",

查看文件

@ -48,6 +48,7 @@ declare module 'vue' {
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']
ElSlider: typeof import('element-plus/es')['ElSlider'] ElSlider: typeof import('element-plus/es')['ElSlider']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElStatistic: typeof import('element-plus/es')['ElStatistic'] ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElStep: typeof import('element-plus/es')['ElStep'] ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps'] ElSteps: typeof import('element-plus/es')['ElSteps']

查看文件

@ -33,6 +33,18 @@ const router = createRouter({
path: 'apps', path: 'apps',
component: () => import('@/views/apps/AppListView.vue'), component: () => import('@/views/apps/AppListView.vue'),
}, },
{
path: 'security',
component: () => import('@/views/security/SecurityCenterView.vue'),
},
{
path: 'docs',
component: () => import('@/views/docs/DocsCenterView.vue'),
},
{
path: 'packages',
component: () => import('@/views/billing/BillingView.vue'),
},
{ {
path: 'operation-logs', path: 'operation-logs',
component: () => import('@/views/logs/OperationLogView.vue'), component: () => import('@/views/logs/OperationLogView.vue'),

查看文件

@ -0,0 +1,323 @@
<template>
<div>
<div class="page-header">
<div>
<h2>服务套餐</h2>
<p class="subtitle">查看当前租户已开通能力和配额使用情况</p>
</div>
<el-button type="primary" plain @click="$router.push('/apps')">前往应用管理</el-button>
</div>
<!-- 套餐卡片 -->
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :xs="24" :sm="8" v-for="plan in planCards" :key="plan.name">
<el-card
shadow="hover"
:class="['plan-card', plan.current ? 'plan-card-current' : '']"
>
<div class="plan-header">
<h3 class="plan-name">{{ plan.name }}</h3>
<el-tag v-if="plan.current" type="success" effect="dark">当前套餐</el-tag>
</div>
<div class="plan-price">
<span class="price-num">{{ plan.price }}</span>
<span class="price-unit">{{ plan.priceUnit }}</span>
</div>
<ul class="plan-features">
<li v-for="(f, idx) in plan.features" :key="idx">
<el-icon color="#67c23a"><CircleCheck /></el-icon>
<span>{{ f }}</span>
</li>
</ul>
<div class="plan-quota">
<div class="quota-row">
<span>应用上限</span>
<span class="quota-value">{{ plan.apps }}</span>
</div>
<div class="quota-row">
<span>子账号上限</span>
<span class="quota-value">{{ plan.subAccounts }}</span>
</div>
</div>
<el-button
v-if="!plan.current"
type="primary"
plain
class="plan-action"
@click="upgradePlan(plan.name)"
>
升级套餐
</el-button>
</el-card>
</el-col>
</el-row>
<!-- 用量统计 -->
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :xs="24" :sm="8">
<el-card shadow="hover">
<el-statistic title="应用数量" :value="summary.appCount" />
<div class="usage-bar">
<el-progress
:percentage="calcPercent(summary.appCount, currentPlan.appsNum)"
:status="calcStatus(summary.appCount, currentPlan.appsNum)"
/>
<div class="usage-text">
{{ summary.appCount }} / {{ currentPlan.apps }}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="8">
<el-card shadow="hover">
<el-statistic title="已开通服务" :value="summary.serviceCount" />
<div class="usage-bar">
<el-progress :percentage="100" status="success" />
<div class="usage-text">全部可用</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="8">
<el-card shadow="hover">
<el-statistic title="子账号数量" :value="summary.subAccountCount" />
<div class="usage-bar">
<el-progress
:percentage="calcPercent(summary.subAccountCount, currentPlan.subAccountsNum)"
:status="calcStatus(summary.subAccountCount, currentPlan.subAccountsNum)"
/>
<div class="usage-text">
{{ summary.subAccountCount }} / {{ currentPlan.subAccounts }}
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-bottom: 16px">
<template #header>套餐与配额</template>
<el-table :data="plans" border stripe>
<el-table-column prop="name" label="套餐" width="160" />
<el-table-column prop="apps" label="应用上限" width="120" />
<el-table-column prop="subAccounts" label="子账号上限" width="120" />
<el-table-column prop="services" label="服务能力" min-width="320" />
</el-table>
</el-card>
<el-card>
<template #header>应用服务明细</template>
<el-table :data="appRows" v-loading="loading" border stripe>
<el-table-column prop="name" label="应用名称" min-width="160" />
<el-table-column prop="packageName" label="包名" min-width="180" />
<el-table-column prop="enabledServiceCount" label="已开通服务" width="120" />
<el-table-column prop="availableServices" label="服务类型" min-width="260" />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheck } from '@element-plus/icons-vue'
import { appApi, type App } from '@/api/app'
import { dashboardApi } from '@/api/dashboard'
const loading = ref(false)
const apps = ref<App[]>([])
const summary = reactive({
appCount: 0,
serviceCount: 0,
subAccountCount: 0,
})
const planCards = [
{
name: '免费版',
price: '¥0',
priceUnit: '/月',
current: true,
apps: '3',
appsNum: 3,
subAccounts: '5',
subAccountsNum: 5,
features: ['IM 基础功能', 'Push 推送', 'Update 更新', '社区支持'],
},
{
name: '专业版',
price: '¥299',
priceUnit: '/月',
current: false,
apps: '20',
appsNum: 20,
subAccounts: '20',
subAccountsNum: 20,
features: ['全部基础功能', 'Webhook 支持', '灰度发布', '优先客服'],
},
{
name: '企业版',
price: '¥999',
priceUnit: '/月',
current: false,
apps: '不限',
appsNum: Infinity,
subAccounts: '不限',
subAccountsNum: Infinity,
features: ['全部专业功能', '运营白名单', '专属客户经理', 'SLA 保障'],
},
]
const currentPlan = computed(() => planCards.find(p => p.current) || planCards[0])
const plans = [
{ name: '基础套餐', apps: '3', subAccounts: '5', services: 'IM / Push / Update 基础开通' },
{ name: '标准套餐', apps: '20', subAccounts: '20', services: 'IM / Push / Update + Webhook + 灰度' },
{ name: '企业套餐', apps: '不限', subAccounts: '不限', services: '全部能力 + 运营白名单' },
]
const appRows = ref<Array<App & { enabledServiceCount: number; availableServices: string }>>([])
async function loadData() {
loading.value = true
try {
const [dashRes, appsRes] = await Promise.all([dashboardApi.stats(), appApi.list()])
summary.appCount = dashRes.data.data.appCount
summary.serviceCount = dashRes.data.data.serviceCount
summary.subAccountCount = dashRes.data.data.subAccountCount
apps.value = appsRes.data.data
const serviceData = await Promise.all(apps.value.map(async (app) => {
const res = await appApi.getServices(app.id)
const services = res.data.data
return {
...app,
enabledServiceCount: services.filter(item => item.enabled).length,
availableServices: services.map(item => `${item.platform}/${item.serviceType}`).join(',') || '-',
}
}))
appRows.value = serviceData
} finally {
loading.value = false
}
}
function calcPercent(current: number, limit: number) {
if (limit === Infinity) return 0
if (limit <= 0) return 0
const p = Math.round((current / limit) * 100)
return p > 100 ? 100 : p
}
function calcStatus(current: number, limit: number) {
if (limit === Infinity) return undefined
const p = current / limit
if (p >= 1) return 'exception'
if (p >= 0.8) return 'warning'
return undefined
}
function upgradePlan(name: string) {
ElMessage.info(`已选择「${name}」,请联系商务完成升级`)
}
function fmt(value: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
onMounted(loadData)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.subtitle {
margin: 6px 0 0;
color: #606266;
}
.plan-card {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
transition: transform 0.2s;
}
.plan-card:hover {
transform: translateY(-4px);
}
.plan-card-current {
border: 2px solid #67c23a;
}
.plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.plan-name {
margin: 0;
font-size: 18px;
color: #303133;
}
.plan-price {
margin-bottom: 16px;
}
.price-num {
font-size: 28px;
font-weight: 700;
color: #409eff;
}
.price-unit {
font-size: 14px;
color: #909399;
}
.plan-features {
list-style: none;
padding: 0;
margin: 0 0 16px;
flex: 1;
}
.plan-features li {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.plan-quota {
margin-bottom: 12px;
padding-top: 12px;
border-top: 1px solid #ebeef5;
}
.quota-row {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #606266;
margin-bottom: 6px;
}
.quota-value {
font-weight: 600;
color: #303133;
}
.plan-action {
width: 100%;
}
.usage-bar {
margin-top: 12px;
}
.usage-text {
margin-top: 6px;
font-size: 12px;
color: #909399;
text-align: right;
}
</style>

查看文件

@ -0,0 +1,85 @@
<template>
<div>
<div class="page-header">
<div>
<h2>接入文档</h2>
<p class="subtitle">按平台查看最短接入路径和常用调用方式</p>
</div>
<el-button @click="openDocs">打开文档站</el-button>
</div>
<el-row :gutter="16">
<el-col :xs="24" :md="8">
<el-card shadow="hover" class="doc-card">
<template #header>React Native</template>
<pre class="code-block">import { XuqmSDK } from '@xuqm/rn-sdk'
await XuqmSDK.initialize({ appKey })
await XuqmSDK.login({
userId,
userSig,
expiresAt,
refreshUserSig,
})</pre>
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card shadow="hover" class="doc-card">
<template #header>Android / iOS</template>
<pre class="code-block">XuqmSDK.initialize(appKey)
XuqmSDK.login(userId, userSig)
PushSDK.initialize(userId)</pre>
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card shadow="hover" class="doc-card">
<template #header>服务端</template>
<pre class="code-block">POST /api/auth/login
POST /api/im/auth/login
POST /api/ops/service-requests/{id}/approve</pre>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 16px">
<template #header>接入步骤</template>
<el-steps :active="3" align-center finish-status="success">
<el-step title="创建应用" description="在应用列表创建 AppKey 和基础信息" />
<el-step title="开启服务" description="在应用详情开启 IM / Push / Update" />
<el-step title="接入 SDK" description="按平台文档完成登录和消息能力接入" />
</el-steps>
</el-card>
</div>
</template>
<script setup lang="ts">
function openDocs() {
window.open('/docs/', '_blank', 'noopener,noreferrer')
}
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.subtitle {
margin: 6px 0 0;
color: #606266;
}
.doc-card {
height: 100%;
}
.code-block {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
color: #303133;
}
</style>

查看文件

@ -88,7 +88,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Document, Grid, Menu, Odometer, User } from '@element-plus/icons-vue' import { Coin, Document, Grid, Lock, Menu, Odometer, User } from '@element-plus/icons-vue'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
@ -100,6 +100,9 @@ const navItems = computed(() => {
const items = [ const items = [
{ path: '/dashboard', label: '控制台', icon: Odometer }, { path: '/dashboard', label: '控制台', icon: Odometer },
{ path: '/apps', label: '我的应用', icon: Grid }, { path: '/apps', label: '我的应用', icon: Grid },
{ path: '/security', label: '安全中心', icon: Lock },
{ path: '/docs', label: '接入文档', icon: Document },
{ path: '/packages', label: '服务套餐', icon: Coin },
{ path: '/operation-logs', label: '操作日志', icon: Document }, { path: '/operation-logs', label: '操作日志', icon: Document },
] ]
if (auth.user?.type === 'MAIN') { if (auth.user?.type === 'MAIN') {

查看文件

@ -0,0 +1,213 @@
<template>
<div>
<div class="page-header">
<div>
<h2>安全中心</h2>
<p class="subtitle">管理登录安全应用密钥和子账号风险控制</p>
</div>
<el-button @click="$router.push('/accounts')">子账号管理</el-button>
</div>
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :xs="24" :md="8">
<el-card shadow="hover" class="summary-card">
<el-statistic title="当前账号" :value="auth.user?.nickname || auth.user?.username || '-'" />
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card shadow="hover" class="summary-card">
<el-statistic title="应用数量" :value="apps.length" />
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card shadow="hover" class="summary-card">
<el-statistic title="子账号数量" :value="subAccountCount" />
</el-card>
</el-col>
</el-row>
<el-card style="margin-bottom: 16px">
<template #header>账号保护建议</template>
<el-space wrap>
<el-tag type="success">强密码</el-tag>
<el-tag type="warning">邮箱验证</el-tag>
<el-tag type="info">子账号最小权限</el-tag>
<el-tag type="info">密钥定期轮换</el-tag>
</el-space>
<el-divider />
<el-button type="primary" plain @click="$router.push('/forgot-password')">重置当前账号密码</el-button>
</el-card>
<el-card>
<template #header>应用密钥管理</template>
<el-table :data="apps" v-loading="loading" border stripe>
<el-table-column prop="name" label="应用名称" min-width="160" />
<el-table-column prop="packageName" label="包名" min-width="180" />
<el-table-column prop="appKey" label="AppKey" min-width="220" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openSecretDialog(row, 'REVEAL_SECRET')">查看密钥</el-button>
<el-button link type="warning" @click="openSecretDialog(row, 'RESET_SECRET')">重置密钥</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="showDialog"
:title="dialogMode === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'"
width="460px"
@closed="closeDialog"
>
<div v-if="!codeSent">
<p class="dialog-text">
{{ dialogMode === 'REVEAL_SECRET'
? '系统将向租户邮箱发送验证码,验证后可查看密钥。'
: '系统将向租户邮箱发送验证码,验证后会立即重置密钥。' }}
</p>
<el-button type="primary" :loading="sendingCode" @click="sendVerifyCode">发送验证码</el-button>
</div>
<div v-else>
<p class="dialog-text">请输入邮箱验证码</p>
<el-input v-model="verifyCode" maxlength="6" placeholder="6位验证码" />
</div>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button v-if="codeSent" type="primary" :loading="submitting" @click="submitVerify">
确认
</el-button>
</template>
</el-dialog>
<el-dialog v-model="showResult" title="AppSecret" width="420px">
<el-alert type="success" :closable="false" show-icon>
<template #title>操作已完成</template>
<template #default>
<div class="secret-box">{{ secretResult || '无结果' }}</div>
</template>
</el-alert>
<template #footer>
<el-button type="primary" @click="showResult = false">知道了</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { accountApi } from '@/api/account'
import { appApi, type App } from '@/api/app'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const apps = ref<App[]>([])
const loading = ref(false)
const subAccountCount = ref(0)
const showDialog = ref(false)
const showResult = ref(false)
const sendingCode = ref(false)
const submitting = ref(false)
const codeSent = ref(false)
const verifyCode = ref('')
const selectedApp = ref<App | null>(null)
const dialogMode = ref<'REVEAL_SECRET' | 'RESET_SECRET'>('REVEAL_SECRET')
const secretResult = ref('')
async function loadData() {
loading.value = true
try {
const [appsRes, subRes] = await Promise.all([appApi.list(), accountApi.list()])
apps.value = appsRes.data.data
subAccountCount.value = subRes.data.data.length
} finally {
loading.value = false
}
}
function openSecretDialog(app: App, mode: 'REVEAL_SECRET' | 'RESET_SECRET') {
selectedApp.value = app
dialogMode.value = mode
showDialog.value = true
codeSent.value = false
verifyCode.value = ''
secretResult.value = ''
}
async function sendVerifyCode() {
if (!selectedApp.value) return
sendingCode.value = true
try {
await appApi.requestSecretVerify(selectedApp.value.id, dialogMode.value)
codeSent.value = true
ElMessage.success('验证码已发送到邮箱')
} finally {
sendingCode.value = false
}
}
async function submitVerify() {
if (!selectedApp.value) return
if (!verifyCode.value.trim()) {
ElMessage.warning('请输入验证码')
return
}
submitting.value = true
try {
if (dialogMode.value === 'REVEAL_SECRET') {
const res = await appApi.revealSecret(selectedApp.value.id, verifyCode.value.trim())
secretResult.value = res.data.data.appSecret
} else {
const res = await appApi.resetSecret(selectedApp.value.id, verifyCode.value.trim())
secretResult.value = res.data.data.appSecret
}
showDialog.value = false
showResult.value = true
ElMessage.success(dialogMode.value === 'REVEAL_SECRET' ? '密钥已查看' : '密钥已重置')
} finally {
submitting.value = false
}
}
function closeDialog() {
selectedApp.value = null
verifyCode.value = ''
codeSent.value = false
}
function fmt(value: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
onMounted(loadData)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.subtitle {
margin: 6px 0 0;
color: #606266;
}
.summary-card {
min-height: 110px;
}
.dialog-text {
color: #606266;
margin: 0 0 16px;
}
.secret-box {
margin-top: 8px;
font-family: monospace;
word-break: break-all;
}
</style>

查看文件

@ -1112,6 +1112,14 @@ eastasianwidth@^0.2.0:
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
echarts@^6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz"
integrity sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==
dependencies:
tslib "2.3.0"
zrender "6.0.0"
editorconfig@^1.0.4: editorconfig@^1.0.4:
version "1.0.7" version "1.0.7"
resolved "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz" resolved "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz"
@ -2226,6 +2234,11 @@ trim-lines@^3.0.0:
resolved "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz" resolved "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz"
integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
typescript@*, typescript@^5.8.2, typescript@>=4.5.0, typescript@>=5.0.0: typescript@*, typescript@^5.8.2, typescript@>=4.5.0, typescript@>=5.0.0:
version "5.9.3" version "5.9.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz" resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz"
@ -2512,6 +2525,13 @@ ws@^8.18.3:
resolved "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz" resolved "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz"
integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA== integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==
zrender@6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz"
integrity sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==
dependencies:
tslib "2.3.0"
zwitch@^2.0.4: zwitch@^2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz" resolved "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz"