feat(sdk): 更新 SDK 设计文档和 API 重构
- 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制
这个提交包含在:
父节点
8853ee2d65
当前提交
7b7989525f
16
README.md
16
README.md
@ -42,6 +42,9 @@ yarn workspace ops-platform dev
|
||||
| `/dashboard` | DashboardView | 控制台首页(需登录) |
|
||||
| `/apps` | AppListView | 应用列表(需登录) |
|
||||
| `/apps/:id` | AppDetailView | 应用详情(需登录) |
|
||||
| `/security` | SecurityCenterView | 安全中心(需登录) |
|
||||
| `/docs` | DocsCenterView | 接入文档(需登录) |
|
||||
| `/packages` | BillingView | 服务套餐 / 配额(需登录) |
|
||||
| `/accounts` | SubAccountView | 子账号管理(需登录) |
|
||||
|
||||
### 认证流程
|
||||
@ -91,6 +94,12 @@ JWT Payload 由 `atob(token.split('.')[1])` 解析,无需额外请求。
|
||||
- 每个平台下显示 `IM` / `推送` / `版本管理` 三个服务卡片
|
||||
- 支持一键开关服务、复制 secretKey、重新生成 secretKey
|
||||
|
||||
### 安全中心 / 接入文档 / 服务套餐
|
||||
|
||||
- 安全中心提供 AppSecret 查看/重置入口,直接复用邮箱验证码流程
|
||||
- 接入文档页提供 RN、Android / iOS 和服务端的最短接入示例
|
||||
- 服务套餐页展示当前租户的配额和服务开通概览,不涉及费用计费
|
||||
|
||||
### 子账号管理(SubAccountView)
|
||||
|
||||
创建子账号**两步流程**:
|
||||
@ -109,7 +118,12 @@ JWT Payload 由 `atob(token.split('.')[1])` 解析,无需额外请求。
|
||||
|------|------|------|
|
||||
| `/login` | LoginView | 运营管理员登录 |
|
||||
| `/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 接入指南
|
||||
|
||||
**版本**:0.2.x(v0.4.0 将引入 UserSig 鉴权)· **最低 Android 版本**:API 24 (Android 7.0) · **语言**:Kotlin
|
||||
|
||||
> **注意**:v0.4.0 将是 Breaking 版本,`initialize()` 将保持不变,`ImSDK.login()` 将改为 UserSig 鉴权模式。
|
||||
**版本**:0.4.x(UserSig 鉴权)· **最低 Android 版本**:API 24 (Android 7.0) · **语言**:Kotlin
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | Artifact | 功能 |
|
||||
|------|----------|------|
|
||||
| sdk-core | `com.xuqm:sdk-core` | 初始化、网络、鉴权(EncryptedSharedPreferences)|
|
||||
| sdk-core | `com.xuqm:sdk-core` | 初始化、网络、鉴权、UserSig 续签 |
|
||||
| sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 |
|
||||
| sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册 |
|
||||
| sdk-update | `com.xuqm:sdk-update` | App 更新检查、RN 热更新 |
|
||||
| sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册(华为/小米/OPPO/vivo/荣耀/FCM) |
|
||||
| sdk-update | `com.xuqm:sdk-update` | App 更新检查、下载安装 |
|
||||
|
||||
## 快速接入
|
||||
|
||||
@ -30,10 +28,10 @@ dependencyResolutionManagement {
|
||||
```kotlin
|
||||
// app/build.gradle.kts
|
||||
dependencies {
|
||||
implementation("com.xuqm:sdk-core:0.2.0")
|
||||
implementation("com.xuqm:sdk-im:0.2.0")
|
||||
implementation("com.xuqm:sdk-push:0.2.0") // 按需
|
||||
implementation("com.xuqm:sdk-update:0.2.0") // 按需
|
||||
implementation("com.xuqm:sdk-core:0.4.0")
|
||||
implementation("com.xuqm:sdk-im:0.4.0")
|
||||
implementation("com.xuqm:sdk-push:0.4.0") // 按需
|
||||
implementation("com.xuqm:sdk-update:0.4.0") // 按需
|
||||
}
|
||||
```
|
||||
|
||||
@ -50,12 +48,18 @@ XuqmSDK.initialize(
|
||||
)
|
||||
```
|
||||
|
||||
### 3. IM 登录与收消息
|
||||
### 3. IM 登录与收消息(UserSig 模式)
|
||||
|
||||
```kotlin
|
||||
// 登录(协程 suspend 函数)
|
||||
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 onMessage(msg: ImMessage) { /* 单聊消息 */ }
|
||||
override fun onGroupMessage(msg: ImMessage) { /* 群聊消息 */ }
|
||||
override fun onRead(msg: ImMessage) { /* 对方已读回执 */ }
|
||||
override fun onRevoke(msg: ImMessage) { /* 消息被撤回 */ }
|
||||
override fun onDisconnected(reason: String?) { /* 断线处理 */ }
|
||||
override fun onError(error: String) { /* 错误回调 */ }
|
||||
})
|
||||
```
|
||||
|
||||
### 4. 发送消息
|
||||
|
||||
```kotlin
|
||||
// 发送文本(通过 WebSocket 实时发送)
|
||||
ImSDK.sendMessage(
|
||||
// 发送文本
|
||||
ImSDK.sendTextMessage(
|
||||
toId = "user_002",
|
||||
chatType = "SINGLE",
|
||||
msgType = "TEXT",
|
||||
content = """{"text":"Hello!"}""",
|
||||
content = "Hello!",
|
||||
)
|
||||
|
||||
// 发送图片(需先调用 FileSDK 上传)
|
||||
ImSDK.sendImageMessage(
|
||||
toId = "user_002",
|
||||
chatType = "SINGLE",
|
||||
file = uploadResult,
|
||||
width = 800,
|
||||
height = 600,
|
||||
)
|
||||
```
|
||||
|
||||
@ -84,7 +99,7 @@ ImSDK.sendMessage(
|
||||
```kotlin
|
||||
// 群组
|
||||
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.leaveGroup(groupId)
|
||||
|
||||
@ -95,23 +110,88 @@ ImSDK.addFriend("user_002")
|
||||
// 会话
|
||||
val conversations = ImSDK.listConversations()
|
||||
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
|
||||
// 登录后调用;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
|
||||
// 检查 App 更新
|
||||
val update = UpdateSDK.checkAppUpdate(context)
|
||||
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)
|
||||
2. 注册租户账号,创建应用
|
||||
3. 记录 `appKey`
|
||||
3. 记录 `appKey`(Android)或 `appId + appSecret`(iOS)
|
||||
|
||||
## 2. 选择你的平台
|
||||
|
||||
@ -38,14 +38,85 @@ WS 地址:wss://dev.xuqinmin.com/ws/im
|
||||
```
|
||||
你的业务服务端
|
||||
→ 持有 appKey/appSecret
|
||||
→ 调用 IM 登录接口换取 IM Token
|
||||
→ 调用 IM 登录接口换取 IM Token(或签发 UserSig JWT)
|
||||
→ 平台内部协议字段由 SDK 和后端自动处理,业务方无需感知
|
||||
→ 返回 Token 给客户端
|
||||
→ 返回 Token / UserSig 给客户端
|
||||
|
||||
客户端 SDK
|
||||
→ 使用 Token 初始化 IM 连接
|
||||
→ 使用 Token / UserSig 初始化 IM 连接
|
||||
→ 建立 WebSocket 长连接
|
||||
→ 开始收发消息
|
||||
```
|
||||
|
||||
> **安全提示**:appSecret 应仅在你的服务端持有,不应下发给客户端。
|
||||
|
||||
---
|
||||
|
||||
## 6. Android Demo 运行说明
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Android Studio Ladybug(2024.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
|
||||
主 Activity:com.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 | 初始化、网络、鉴权 |
|
||||
| XuqmIM | 单聊、群聊、消息收发(13 种类型)|
|
||||
| XuqmPush | APNs 设备 Token 注册、通知处理 |
|
||||
| XuqmUpdate | App 版本检查、RN Bundle 热更新 |
|
||||
| XuqmCore | 初始化、网络、鉴权、UserSig 过期检测 |
|
||||
| XuqmIM | 单聊、群聊、消息收发(15 种类型)、连接状态监听 |
|
||||
| XuqmPush | APNs 设备 Token 注册、FCM 备选、通知处理 |
|
||||
| XuqmUpdate | App 版本检查、App Store 跳转 |
|
||||
|
||||
## 安装
|
||||
|
||||
@ -35,8 +35,9 @@ dependencies: [
|
||||
.target(
|
||||
name: "MyApp",
|
||||
dependencies: [
|
||||
.product(name: "XuqmCore", package: "XuqmGroup-iOSSDK"),
|
||||
.product(name: "XuqmIM", package: "XuqmGroup-iOSSDK"),
|
||||
.product(name: "XuqmCore", package: "XuqmGroup-iOSSDK"),
|
||||
.product(name: "XuqmIM", package: "XuqmGroup-iOSSDK"),
|
||||
.product(name: "XuqmPush", package: "XuqmGroup-iOSSDK"),
|
||||
.product(name: "XuqmUpdate", package: "XuqmGroup-iOSSDK"),
|
||||
]
|
||||
)
|
||||
@ -51,91 +52,169 @@ dependencies: [
|
||||
```swift
|
||||
import XuqmCore
|
||||
|
||||
XuqmSDK.shared.initialize(
|
||||
appKey: "your_app_key",
|
||||
appSecret: "your_app_secret",
|
||||
debug: false
|
||||
)
|
||||
let config = SDKConfig(appId: "your_app_id", appSecret: "your_app_secret")
|
||||
XuqmSDK.shared.initialize(config: config)
|
||||
```
|
||||
|
||||
### 2. IM 登录与监听消息
|
||||
### 2. IM 登录与监听消息(UserSig 模式)
|
||||
|
||||
```swift
|
||||
import XuqmIM
|
||||
|
||||
// 登录(appKey 已在 init 时指定)
|
||||
try await ImSDK.shared.login(userId: "user_001", nickname: "张三")
|
||||
// 方式一:使用 UserSig 登录(推荐生产环境)
|
||||
try await XuqmSDK.shared.login(userId: "user_001", userSig: "your_user_sig_jwt")
|
||||
|
||||
// 监听事件
|
||||
ImSDK.shared.addListener(self)
|
||||
// 方式二:Demo 环境快速登录
|
||||
try await ImSDK.shared.loginWithDemo(userId: "user_001", password: "123456")
|
||||
|
||||
extension ViewController: ImEventListener {
|
||||
func onConnected() { print("WS connected") }
|
||||
func onMessage(_ msg: ImMessage) { /* 处理消息 */ }
|
||||
func onDisconnected(reason: String?) { /* 断线 */ }
|
||||
// 设置事件代理
|
||||
ImSDK.shared.setDelegate(self)
|
||||
|
||||
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
|
||||
// 发文本
|
||||
let sent = try await ImSDK.shared.sendMessage(
|
||||
toId: "user_002",
|
||||
let msg = ImSDK.shared.sendTextMessage(
|
||||
toId: "user_002",
|
||||
chatType: .single,
|
||||
msgType: .text,
|
||||
content: "Hello!"
|
||||
content: "Hello!"
|
||||
)
|
||||
|
||||
// 发图片(content 为 JSON 字符串)
|
||||
// 发图片
|
||||
let imgContent = try JSONSerialization.data(withJSONObject: [
|
||||
"url": "https://cdn.example.com/img.jpg",
|
||||
"width": 800, "height": 600
|
||||
])
|
||||
let sent = try await ImSDK.shared.sendMessage(
|
||||
let msg2 = try await ImSDK.shared.sendMessage(
|
||||
toId: "user_002", chatType: .single,
|
||||
msgType: .image, content: String(data: imgContent, encoding: .utf8)!
|
||||
)
|
||||
```
|
||||
|
||||
### 4. 撤回消息
|
||||
### 5. 撤回与编辑
|
||||
|
||||
```swift
|
||||
// 撤回消息
|
||||
let revoked = try await ImSDK.shared.revokeMessage(messageId: msg.id)
|
||||
|
||||
// 编辑消息
|
||||
let edited = try await ImSDK.shared.editMessage(messageId: msg.id, content: "新内容")
|
||||
```
|
||||
|
||||
### 5. 群聊
|
||||
### 6. 群聊
|
||||
|
||||
```swift
|
||||
// 创建群组
|
||||
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(
|
||||
toId: group.id, chatType: .group, msgType: .text, content: "大家好"
|
||||
)
|
||||
ImSDK.shared.sendTextMessage(toId: group.id, chatType: .group, 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
|
||||
import XuqmUpdate
|
||||
|
||||
// App 整包更新
|
||||
let appInfo = try await UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)
|
||||
if let info = appInfo, info.forceUpdate {
|
||||
// 强制更新:跳转 App Store 或下载链接
|
||||
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 加载
|
||||
if let info = appInfo, info.forceUpdate == true {
|
||||
UpdateSDK.shared.openAppStore(url: info.appStoreUrl ?? info.downloadUrl!)
|
||||
}
|
||||
```
|
||||
|
||||
@ -150,8 +229,11 @@ if let bundle = bundle {
|
||||
| `.file` | 文件 | `{url, name, size, mimeType}` |
|
||||
| `.location` | 位置 | `{lat, lng, address, title}` |
|
||||
| `.custom` | 自定义 | 任意 JSON |
|
||||
| `.notify` | 系统通知 | `{title, content, level}` |
|
||||
| `.notify` | 系统通知 | `{title, content}` |
|
||||
| `.richText` | 富文本 | `{html}` |
|
||||
| `.callAudio` | 语音通话信令 | `{callId, action, callerName}` |
|
||||
| `.callVideo` | 视频通话信令 | `{callId, action, callerName}` |
|
||||
| `.forward` | 转发 | `{originalMsgId, originalContent, originalSender}` |
|
||||
| `.quote` | 引用 | `{quotedMsgId, quotedContent, text}` |
|
||||
| `.merge` | 合并转发 | `{title, msgList}` |
|
||||
| `.revoked` | 撤回 | 系统内部填充 |
|
||||
|
||||
5
ops-platform/components.d.ts
vendored
5
ops-platform/components.d.ts
vendored
@ -12,6 +12,8 @@ declare module 'vue' {
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
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']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
@ -19,14 +21,17 @@ declare module 'vue' {
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
|
||||
@ -9,9 +9,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.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",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
@ -19,9 +20,9 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.2",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"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
|
||||
}
|
||||
|
||||
export interface TenantDetail extends TenantItem {
|
||||
appCount: number
|
||||
subAccountCount: number
|
||||
activeServiceCount: number
|
||||
apps: AppItem[]
|
||||
}
|
||||
|
||||
export interface TenantPage {
|
||||
content: TenantItem[]
|
||||
total: number
|
||||
@ -43,6 +50,80 @@ export interface ServiceRequestPage {
|
||||
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 = {
|
||||
listTenants: (keyword = '', page = 0, size = 20) =>
|
||||
client.get<{ data: TenantPage }>('/ops/tenants', { params: { keyword, page, size } }),
|
||||
@ -50,6 +131,12 @@ export const opsApi = {
|
||||
toggleStatus: (id: string) =>
|
||||
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: () =>
|
||||
client.get<{ data: Statistics }>('/ops/statistics'),
|
||||
|
||||
@ -61,4 +148,38 @@ export const opsApi = {
|
||||
|
||||
rejectRequest: (requestId: string, 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 } }),
|
||||
|
||||
// 风控相关 API(mock 实现,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: [
|
||||
{ path: '', redirect: '/tenants' },
|
||||
{ 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: '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">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
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 isMobile = ref(false)
|
||||
@ -74,8 +74,11 @@ const drawerVisible = ref(false)
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ path: '/tenants', label: '租户管理', icon: Avatar },
|
||||
{ path: '/statistics', label: '数据统计', icon: TrendCharts },
|
||||
{ path: '/statistics', label: '服务运营总览', icon: TrendCharts },
|
||||
{ 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() {
|
||||
|
||||
@ -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>
|
||||
<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-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-statistic :title="item.label" :value="item.value" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card style="margin-top:24px">
|
||||
<template #header>近7天注册趋势</template>
|
||||
<div style="text-align:center;padding:40px;color:#999">
|
||||
图表区域 — 可集成 ECharts 或 Chart.js
|
||||
</div>
|
||||
</el-card>
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card>
|
||||
<template #header>近7天注册趋势</template>
|
||||
<div ref="trendChartRef" class="chart-box" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { opsApi } from '@/api/ops'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const stats = ref([
|
||||
{ label: '总租户数', value: 0 },
|
||||
@ -29,6 +50,95 @@ const stats = ref([
|
||||
{ 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 () => {
|
||||
try {
|
||||
const res = await opsApi.statistics()
|
||||
@ -38,5 +148,24 @@ onMounted(async () => {
|
||||
stats.value[2].value = d.activeApps ?? 0
|
||||
stats.value[3].value = d.onlineUsers ?? 0
|
||||
} catch {}
|
||||
|
||||
initTrendChart()
|
||||
initPieChart()
|
||||
initBarChart()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
trendChart?.dispose()
|
||||
pieChart?.dispose()
|
||||
barChart?.dispose()
|
||||
})
|
||||
</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 label="操作" width="120">
|
||||
<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'"
|
||||
@click="toggleStatus(row)">
|
||||
{{ row.status === 'ACTIVE' ? '禁用' : '启用' }}
|
||||
|
||||
26
package-lock.json
自动生成的
26
package-lock.json
自动生成的
@ -1852,6 +1852,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
|
||||
@ -3769,6 +3779,12 @@
|
||||
"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": {
|
||||
"version": "5.9.3",
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz",
|
||||
@ -4674,6 +4699,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
1
tenant-platform/components.d.ts
vendored
1
tenant-platform/components.d.ts
vendored
@ -48,6 +48,7 @@ declare module 'vue' {
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
|
||||
@ -33,6 +33,18 @@ const router = createRouter({
|
||||
path: 'apps',
|
||||
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',
|
||||
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 { useAuthStore } from '@/stores/auth'
|
||||
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 route = useRoute()
|
||||
@ -100,6 +100,9 @@ const navItems = computed(() => {
|
||||
const items = [
|
||||
{ path: '/dashboard', label: '控制台', icon: Odometer },
|
||||
{ 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 },
|
||||
]
|
||||
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>
|
||||
20
yarn.lock
20
yarn.lock
@ -1112,6 +1112,14 @@ eastasianwidth@^0.2.0:
|
||||
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
|
||||
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:
|
||||
version "1.0.7"
|
||||
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"
|
||||
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:
|
||||
version "5.9.3"
|
||||
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"
|
||||
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:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz"
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户