diff --git a/docs-site/docs/.vitepress/config.ts b/docs-site/docs/.vitepress/config.ts index fdbd7a5..0a8c932 100644 --- a/docs-site/docs/.vitepress/config.ts +++ b/docs-site/docs/.vitepress/config.ts @@ -13,6 +13,7 @@ export default defineConfig({ nav: [ { text: '快速开始', link: '/guide/quickstart' }, + { text: '演示项目', link: '/demo/' }, { text: 'SDK', items: [ @@ -33,6 +34,9 @@ export default defineConfig({ { text: '平台概念', link: '/guide/concepts' }, { text: '接入流程', link: '/guide/flow' }, ], + '/demo/': [ + { text: '演示项目', link: '/demo/' }, + ], '/android/': [ { text: '概览', link: '/android/' }, { text: '安装配置', link: '/android/setup' }, diff --git a/docs-site/docs/demo/index.md b/docs-site/docs/demo/index.md new file mode 100644 index 0000000..25d415d --- /dev/null +++ b/docs-site/docs/demo/index.md @@ -0,0 +1,81 @@ +# 演示项目 + +下面这些入口对应当前仓库里的可用演示物料。 + +## 移动端 + +
+
+

Android SDK Sample App

+

适合验证 Android SDK 的 IM、推送和更新能力。

+ Android SDK Sample App 下载二维码 +

下载 APK

+
+ +
+

RN Chat Demo

+

适合验证 React Native 演示项目和服务端 demo 数据。

+ RN Chat Demo 下载二维码 +

下载 APK

+
+
+ +## Web + +
+
+

tenant-platform

+

租户开放平台,登录后可直接进入应用、IM、版本管理等页面。

+

+ 打开控制台 + · + 打开 IM 演示页 +

+
+ +
+

docs-site 快速入口

+

先看快速开始,再按平台页接入。

+

+ 快速开始 + · + API 速查 +

+
+
+ + diff --git a/docs-site/docs/guide/quickstart.md b/docs-site/docs/guide/quickstart.md index 5e6226d..0644773 100644 --- a/docs-site/docs/guide/quickstart.md +++ b/docs-site/docs/guide/quickstart.md @@ -29,7 +29,11 @@ WS 地址:wss://dev.xuqinmin.com/ws/im 演示用户:demo_alice / demo_bob ``` -## 4. 接入流程 +## 4. 演示项目 + +手机端演示包和 Web 演示入口单独放在 [演示项目](/demo/) 页面,便于直接扫码或跳转验证。 + +## 5. 接入流程 ``` 你的业务服务端 diff --git a/docs-site/docs/index.md b/docs-site/docs/index.md index ded13a4..a79c173 100644 --- a/docs-site/docs/index.md +++ b/docs-site/docs/index.md @@ -8,6 +8,9 @@ hero: - theme: brand text: 快速开始 link: /guide/quickstart + - theme: alt + text: 演示项目 + link: /demo/ - theme: alt text: 平台控制台 link: https://dev.xuqinmin.com @@ -37,4 +40,8 @@ features: title: 服务端 API details: 完整 REST API 速查,WebSocket STOMP 协议说明 link: /server/api + - icon: 📱 + title: 演示项目 + details: 手机端扫码下载演示包,Web 端直接跳转到对应页面 + link: /demo/ --- diff --git a/docs-site/docs/public/demo/android-sdk-sample-app.apk b/docs-site/docs/public/demo/android-sdk-sample-app.apk new file mode 100644 index 0000000..319f55e Binary files /dev/null and b/docs-site/docs/public/demo/android-sdk-sample-app.apk differ diff --git a/docs-site/docs/public/demo/rn-chat-demo.apk b/docs-site/docs/public/demo/rn-chat-demo.apk new file mode 100644 index 0000000..d1b1df6 --- /dev/null +++ b/docs-site/docs/public/demo/rn-chat-demo.apk @@ -0,0 +1 @@ +This is a placeholder APK payload for demo update flow. diff --git a/tenant-platform/components.d.ts b/tenant-platform/components.d.ts index 1092e50..03b9d96 100644 --- a/tenant-platform/components.d.ts +++ b/tenant-platform/components.d.ts @@ -7,10 +7,13 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ElAlert: typeof import('element-plus/es')['ElAlert'] ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCol: typeof import('element-plus/es')['ElCol'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] @@ -28,6 +31,7 @@ declare module 'vue' { ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElLink: typeof import('element-plus/es')['ElLink'] ElMain: typeof import('element-plus/es')['ElMain'] ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] diff --git a/tenant-platform/src/api/app.ts b/tenant-platform/src/api/app.ts index 04f1a7d..f3b7451 100644 --- a/tenant-platform/src/api/app.ts +++ b/tenant-platform/src/api/app.ts @@ -29,6 +29,18 @@ export interface FeatureService { createdAt: string } +export interface ImServiceConfig { + allowStrangerMessage: boolean + allowFriendRequest: boolean + friendRequestMode: 'REQUIRE_CONFIRM' | 'DIRECT_ACCEPT' | 'DISALLOW' + allowGroupJoinRequest: boolean + blacklistSendSuccess: boolean + messageRecallMinutes: number + historyRetentionDays: number + conversationPullLimit: number + multiClientConversationDeleteSync: boolean +} + export const appApi = { list: () => client.get<{ data: App[] }>('/apps'), @@ -48,9 +60,14 @@ export const appApi = { params: { platform, serviceType, enable }, }), - updateServiceConfig: (appId: string, platform: string, serviceType: string, allowStrangerMessage: boolean) => - client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, null, { - params: { platform, serviceType, allowStrangerMessage }, + updateServiceConfig: ( + appId: string, + platform: string, + serviceType: string, + config: Partial, + ) => + client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, { + params: { platform, serviceType }, }), requestSecretVerify: (appId: string, purpose: 'REVEAL_SECRET' | 'RESET_SECRET') => diff --git a/tenant-platform/src/api/im.ts b/tenant-platform/src/api/im.ts index 9fa16a2..30c5e2c 100644 --- a/tenant-platform/src/api/im.ts +++ b/tenant-platform/src/api/im.ts @@ -114,6 +114,32 @@ export interface FriendRequest { reviewedAt?: number | null } +export interface BlacklistEntry { + id: string + appId: string + userId: string + blockedUserId: string + createdAt: number +} + +export interface KeywordFilter { + id: string + appId: string + pattern: string + replacement?: string | null + action: 'REPLACE' | 'BLOCK' + enabled: boolean + createdAt: number +} + +export interface GlobalMute { + id: string + appId: string + enabled: boolean + createdAt: number + updatedAt: number +} + export interface WebhookConfig { id: string appId: string @@ -201,9 +227,9 @@ export const imAdminApi = { }) }, - listFriendRequests(appId: string, direction: 'incoming' | 'outgoing' = 'incoming') { - return imClient.get<{ data: FriendRequest[] }>('/api/im/friend-requests', { - params: { appId, direction }, + listFriendRequests(appId: string) { + return imClient.get<{ data: FriendRequest[] }>('/api/im/admin/friend-requests', { + params: { appId }, }) }, @@ -221,9 +247,23 @@ export const imAdminApi = { ) }, + createFriendRequest(appId: string, fromUserId: string, toUserId: string, remark?: string) { + return imClient.post<{ data: FriendRequest }>( + '/api/im/admin/friend-requests', + { + fromUserId, + toUserId, + ...(remark ? { remark } : {}), + }, + { + params: { appId }, + }, + ) + }, + acceptFriendRequest(appId: string, requestId: string) { return imClient.post<{ data: FriendRequest }>( - `/api/im/friend-requests/${encodeURIComponent(requestId)}/accept`, + `/api/im/admin/friend-requests/${encodeURIComponent(requestId)}/accept`, null, { params: { appId } }, ) @@ -231,15 +271,123 @@ export const imAdminApi = { rejectFriendRequest(appId: string, requestId: string) { return imClient.post<{ data: FriendRequest }>( - `/api/im/friend-requests/${encodeURIComponent(requestId)}/reject`, + `/api/im/admin/friend-requests/${encodeURIComponent(requestId)}/reject`, null, { params: { appId } }, ) }, + listBlacklist(appId: string) { + return imClient.get<{ data: BlacklistEntry[] }>('/api/im/admin/blacklist', { + params: { appId }, + }) + }, + + addBlacklist(appId: string, userId: string, blockedUserId: string) { + return imClient.post<{ data: BlacklistEntry }>( + '/api/im/admin/blacklist', + { userId, blockedUserId }, + { params: { appId } }, + ) + }, + + removeBlacklist(appId: string, userId: string, blockedUserId: string) { + return imClient.delete<{ data: null }>('/api/im/admin/blacklist', { + params: { appId, userId, blockedUserId }, + }) + }, + + listKeywordFilters(appId: string) { + return imClient.get<{ data: KeywordFilter[] }>('/api/im/admin/keyword-filters', { + params: { appId }, + }) + }, + + createKeywordFilter( + appId: string, + form: { pattern: string; replacement?: string; action: 'REPLACE' | 'BLOCK'; enabled: boolean }, + ) { + return imClient.post<{ data: KeywordFilter }>('/api/im/admin/keyword-filters', form, { + params: { appId }, + }) + }, + + updateKeywordFilter( + appId: string, + filterId: string, + form: { pattern: string; replacement?: string; action: 'REPLACE' | 'BLOCK'; enabled: boolean }, + ) { + return imClient.put<{ data: KeywordFilter }>(`/api/im/admin/keyword-filters/${encodeURIComponent(filterId)}`, form, { + params: { appId }, + }) + }, + + deleteKeywordFilter(appId: string, filterId: string) { + return imClient.delete<{ data: null }>(`/api/im/admin/keyword-filters/${encodeURIComponent(filterId)}`, { + params: { appId }, + }) + }, + + getGlobalMute(appId: string) { + return imClient.get<{ data: GlobalMute }>('/api/im/admin/global-mute', { + params: { appId }, + }) + }, + + setGlobalMute(appId: string, enabled: boolean) { + return imClient.put<{ data: GlobalMute }>('/api/im/admin/global-mute', null, { + params: { appId, enabled }, + }) + }, + + listGroupMembers(appId: string, groupId: string) { + return imClient.get<{ data: ImUser[] }>( + `/api/im/admin/groups/${encodeURIComponent(groupId)}/members`, + { params: { appId } }, + ) + }, + + searchGroupMembers(appId: string, groupId: string, keyword: string, size = 20) { + return imClient.get<{ data: ImUser[] }>( + `/api/im/admin/groups/${encodeURIComponent(groupId)}/members/search`, + { params: { appId, keyword, size } }, + ) + }, + + addGroupMember(appId: string, groupId: string, userId: string) { + return imClient.post<{ data: ImGroup }>( + `/api/im/admin/groups/${encodeURIComponent(groupId)}/members`, + { userId }, + { params: { appId } }, + ) + }, + + removeGroupMember(appId: string, groupId: string, userId: string) { + return imClient.delete<{ data: ImGroup }>( + `/api/im/admin/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}`, + { params: { appId } }, + ) + }, + + setGroupRole(appId: string, groupId: string, userId: string, role: 'ADMIN' | 'MEMBER') { + return imClient.post<{ data: ImGroup }>( + `/api/im/admin/groups/${encodeURIComponent(groupId)}/roles`, + { userId, role }, + { params: { appId } }, + ) + }, + + muteGroupMember(appId: string, groupId: string, userId: string, minutes: number) { + return imClient.post<{ data: ImGroup }>( + `/api/im/admin/groups/${encodeURIComponent(groupId)}/mute`, + { userId, minutes }, + { params: { appId } }, + ) + }, + listGroupJoinRequests(appId: string, groupId: string) { return imClient.get<{ data: GroupJoinRequest[] }>( - `/api/im/groups/${encodeURIComponent(groupId)}/join-requests`, + `/api/im/admin/groups/${encodeURIComponent(groupId)}/join-requests`, { params: { appId } }, ) }, @@ -259,7 +407,7 @@ export const imAdminApi = { acceptGroupJoinRequest(appId: string, groupId: string, requestId: string) { return imClient.post<{ data: GroupJoinRequest }>( - `/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/accept`, + `/api/im/admin/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/accept`, null, { params: { appId } }, ) @@ -267,7 +415,7 @@ export const imAdminApi = { rejectGroupJoinRequest(appId: string, groupId: string, requestId: string) { return imClient.post<{ data: GroupJoinRequest }>( - `/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/reject`, + `/api/im/admin/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/reject`, null, { params: { appId } }, ) diff --git a/tenant-platform/src/api/update.ts b/tenant-platform/src/api/update.ts index a20b121..4c6d445 100644 --- a/tenant-platform/src/api/update.ts +++ b/tenant-platform/src/api/update.ts @@ -64,6 +64,15 @@ export interface AppVersion { createdAt: string } +export interface AppPackageInspectResult { + platform: 'ANDROID' | 'IOS' + packageName?: string + versionName?: string + versionCode?: number + fileName?: string + detected: boolean +} + export interface RnBundle { id: string appId: string @@ -79,6 +88,15 @@ export interface RnBundle { createdAt: string } +export interface RnBundleInspectResult { + moduleId?: string + platform?: 'ANDROID' | 'IOS' + version?: string + minCommonVersion?: string + fileName?: string + detected: boolean +} + export interface UnifiedAppUploadItem { fileKey: string platform: 'ANDROID' | 'IOS' @@ -129,6 +147,12 @@ export const updateAdminApi = { }) }, + inspectAppPackage(formData: FormData) { + return updateClient.post<{ data: AppPackageInspectResult }>('/api/v1/updates/app/inspect', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + listRnBundles(appId: string, moduleId?: string, platform?: string) { return updateClient.get<{ data: RnBundle[] }>('/api/v1/rn/list', { params: { appId, ...(moduleId && { moduleId }), ...(platform && { platform }) }, @@ -153,6 +177,12 @@ export const updateAdminApi = { }) }, + inspectRnBundle(formData: FormData) { + return updateClient.post<{ data: RnBundleInspectResult }>('/api/v1/rn/inspect', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + uploadUnifiedRelease(formData: FormData) { return updateClient.post('/api/v1/updates/unified/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, diff --git a/tenant-platform/src/assets/update-store/honor/01.png b/tenant-platform/src/assets/update-store/honor/01.png new file mode 100644 index 0000000..a14538d Binary files /dev/null and b/tenant-platform/src/assets/update-store/honor/01.png differ diff --git a/tenant-platform/src/assets/update-store/huawei/01.png b/tenant-platform/src/assets/update-store/huawei/01.png new file mode 100644 index 0000000..f2da8ff Binary files /dev/null and b/tenant-platform/src/assets/update-store/huawei/01.png differ diff --git a/tenant-platform/src/assets/update-store/mi/01.png b/tenant-platform/src/assets/update-store/mi/01.png new file mode 100644 index 0000000..71181b4 Binary files /dev/null and b/tenant-platform/src/assets/update-store/mi/01.png differ diff --git a/tenant-platform/src/assets/update-store/oppo/01.png b/tenant-platform/src/assets/update-store/oppo/01.png new file mode 100644 index 0000000..7fdf305 Binary files /dev/null and b/tenant-platform/src/assets/update-store/oppo/01.png differ diff --git a/tenant-platform/src/assets/update-store/vivo/01.png b/tenant-platform/src/assets/update-store/vivo/01.png new file mode 100644 index 0000000..866c7af Binary files /dev/null and b/tenant-platform/src/assets/update-store/vivo/01.png differ diff --git a/tenant-platform/src/router/index.ts b/tenant-platform/src/router/index.ts index 304fd98..f8c3680 100644 --- a/tenant-platform/src/router/index.ts +++ b/tenant-platform/src/router/index.ts @@ -37,6 +37,14 @@ const router = createRouter({ path: 'apps/:id', component: () => import('@/views/apps/AppDetailView.vue'), }, + { + path: 'apps/:appId/im-config', + component: () => import('@/views/im/ImConfigView.vue'), + }, + { + path: 'apps/:appId/im-webhooks', + component: () => import('@/views/im/ImWebhookView.vue'), + }, { path: 'apps/:appId/im', component: () => import('@/views/im/ImManagementView.vue'), diff --git a/tenant-platform/src/views/apps/AppDetailView.vue b/tenant-platform/src/views/apps/AppDetailView.vue index 4eb844f..3dc0f60 100644 --- a/tenant-platform/src/views/apps/AppDetailView.vue +++ b/tenant-platform/src/views/apps/AppDetailView.vue @@ -24,8 +24,38 @@ + + +
+ +
+ {{ serviceLabel('IM') }} + +
+ + +
+
+
+ - + @@ -33,7 +63,7 @@
- +
{{ serviceLabel(svcType) }} + + diff --git a/tenant-platform/src/views/im/ImManagementView.vue b/tenant-platform/src/views/im/ImManagementView.vue index 1e15830..6d703a3 100644 --- a/tenant-platform/src/views/im/ImManagementView.vue +++ b/tenant-platform/src/views/im/ImManagementView.vue @@ -1,6 +1,6 @@ + + + {{ managedGroup.id }} + {{ managedGroup.groupType || 'WORK' }} + {{ managedGroup.creatorId }} + {{ parseIdList(managedGroup.adminIds).length }} + {{ parseIdList(managedGroup.memberIds).length }} + {{ managedGroup.announcement || '-' }} + + + + +
+
+ + 刷新 +
+ 点击搜索结果可直接加入群组 +
+ +
+
+ {{ user.nickname || user.userId }} + {{ user.userId }} +
+
+ + + + + + + + + + + + + + + + + +
+ + +
+
+ 刷新 +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ 保存 + + + + + POST 到你配置的回调地址,`Content-Type: application/json`。 + `X-App-Id`、`X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature`。 + 统一 envelope:`callbackId`、`callbackType`、`callbackEvent`、`requestTime`、`payload`、`appId`。 + `HMAC-SHA256(appSecret, appId + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`。 + 回调发送失败只记录日志,不会中断消息发送、撤回等主流程。 + 接收方建议按 `callbackId` 去重。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -410,18 +803,28 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Search } from '@element-plus/icons-vue' import { imAdminApi, + type BlacklistEntry, + type GlobalMute, type FriendRequest, type GroupJoinRequest, type ImGroup, type ImMessage, type ImStats, + type KeywordFilter, type ImUser, type OperationLog, type WebhookConfig, } from '@/api/im' const route = useRoute() -const appId = route.params.appId as string +// IM 管理页按 appKey 作用域查询;tenant-service 的应用主键不要传到这里。 +const appKey = computed(() => { + const queryAppKey = route.query.appKey + if (typeof queryAppKey === 'string' && queryAppKey.trim()) { + return queryAppKey.trim() + } + return String(route.params['appId'] ?? '') +}) const genderLabel: Record = { UNKNOWN: '未知', @@ -459,7 +862,22 @@ const operationLogPageSize = 20 const operationLogTotal = ref(0) const webhooks = ref([]) +const webhookEvents = [ + { event: 'message.sent', payload: 'ImMessageEntity', description: '消息发送成功后触发。' }, + { event: 'message.revoked', payload: 'ImMessageEntity', description: '消息撤回后触发。' }, + { event: 'message.edited', payload: 'ImMessageEntity', description: '文本消息编辑后触发。' }, + { event: 'message.read', payload: 'MessageReadCallbackPayload', description: '已读回执同步后触发。' }, + { event: 'friend.request.sent', payload: 'FriendRequestCallbackPayload', description: '好友申请创建后触发。' }, + { event: 'friend.request.accepted', payload: 'FriendRequestCallbackPayload', description: '好友申请通过后触发。' }, + { event: 'friend.request.rejected', payload: 'FriendRequestCallbackPayload', description: '好友申请拒绝后触发。' }, + { event: 'group.join.request.sent', payload: 'GroupJoinRequestCallbackPayload', description: '入群申请创建后触发。' }, + { event: 'group.join.request.accepted', payload: 'GroupJoinRequestCallbackPayload', description: '入群申请通过后触发。' }, + { event: 'group.join.request.rejected', payload: 'GroupJoinRequestCallbackPayload', description: '入群申请拒绝后触发。' }, + { event: 'blacklist.added', payload: 'BlacklistCallbackPayload', description: '黑名单记录新增后触发。' }, + { event: 'blacklist.removed', payload: 'BlacklistCallbackPayload', description: '黑名单记录移除后触发。' }, +] const loadingWebhooks = ref(false) +const showWebhookGuideDialog = ref(false) const showWebhookDialog = ref(false) const submittingWebhook = ref(false) const editingWebhookId = ref(null) @@ -467,11 +885,33 @@ const webhookForm = ref({ url: '', secret: '', enabled: true }) const friendRequests = ref([]) const loadingFriendRequests = ref(false) -const friendRequestDirection = ref<'incoming' | 'outgoing'>('incoming') +const friendRequestStatusFilter = ref<'ALL' | 'PENDING' | 'ACCEPTED' | 'REJECTED'>('ALL') const showSendFriendRequest = ref(false) const submittingSendFriendRequest = ref(false) const sendFriendRequestForm = ref({ toUserId: '', remark: '' }) +const blacklist = ref([]) +const loadingBlacklist = ref(false) +const showBlacklistDialog = ref(false) +const submittingBlacklist = ref(false) +const blacklistForm = ref({ userId: '', blockedUserId: '' }) + +const keywordFilters = ref([]) +const loadingKeywordFilters = ref(false) +const showKeywordFilterDialog = ref(false) +const submittingKeywordFilter = ref(false) +const editingKeywordFilterId = ref(null) +const keywordFilterForm = ref({ + pattern: '', + replacement: '', + action: 'REPLACE' as 'REPLACE' | 'BLOCK', + enabled: true, +}) + +const globalMute = ref(null) +const loadingGlobalMute = ref(false) +const savingGlobalMute = ref(false) + const groupJoinRequests = ref([]) const loadingGroupRequests = ref(false) const groupRequestGroupId = ref('') @@ -479,6 +919,18 @@ const showSendGroupJoinRequest = ref(false) const submittingSendGroupJoinRequest = ref(false) const sendGroupJoinRequestForm = ref({ groupId: '', remark: '' }) +const showGroupManageDialog = ref(false) +const managedGroup = ref(null) +const groupManageTab = ref<'members' | 'requests'>('members') +const managedGroupMembers = ref([]) +const loadingManagedGroupMembers = ref(false) +const managedGroupJoinRequests = ref([]) +const loadingManagedGroupJoinRequests = ref(false) +const groupMemberSearchKeyword = ref('') +const groupMemberSearchResults = ref([]) +const searchingGroupMembers = ref(false) +let groupMemberSearchTimer: ReturnType | null = null + const historyForm = ref({ keyword: '', chatType: '', @@ -528,6 +980,22 @@ const statCards = computed(() => [ { label: '今日消息', value: stats.value?.todayMessages ?? '-' }, ]) +const filteredFriendRequests = computed(() => { + if (friendRequestStatusFilter.value === 'ALL') { + return friendRequests.value + } + return friendRequests.value.filter(item => item.status === friendRequestStatusFilter.value) +}) + +const globalMuteEnabled = computed({ + get: () => globalMute.value?.enabled ?? false, + set: (enabled: boolean) => { + if (globalMute.value) { + globalMute.value.enabled = enabled + } + }, +}) + function formatTime(value: number | string | null | undefined) { if (value === null || value === undefined || value === '') return '-' const date = new Date(value) @@ -555,6 +1023,14 @@ function groupRequestTagType(status: string) { return { PENDING: 'warning', ACCEPTED: 'success', REJECTED: 'danger' }[status] ?? 'info' } +function keywordActionLabel(action: string) { + return { REPLACE: '替换', BLOCK: '拦截' }[action] ?? action +} + +function keywordActionTagType(action: string) { + return { REPLACE: 'primary', BLOCK: 'danger' }[action] ?? 'info' +} + function parseIdList(value: string) { try { const parsed = JSON.parse(value) @@ -580,7 +1056,7 @@ function onMemberSearchInput() { memberSearchTimer = setTimeout(async () => { searchingMembers.value = true try { - const res = await imAdminApi.searchUsers(appId, keyword) + const res = await imAdminApi.searchUsers(appKey.value, keyword) const selectedIds = new Set(selectedMembers.value.map(item => item.userId)) memberSearchResults.value = res.data.data.filter(user => !selectedIds.has(user.userId)) } catch { @@ -673,6 +1149,60 @@ function resetSendFriendRequestForm() { sendFriendRequestForm.value = { toUserId: '', remark: '' } } +function resetBlacklistForm() { + blacklistForm.value = { userId: '', blockedUserId: '' } +} + +function openBlacklistDialog() { + resetBlacklistForm() + showBlacklistDialog.value = true +} + +function resetKeywordFilterForm() { + editingKeywordFilterId.value = null + keywordFilterForm.value = { + pattern: '', + replacement: '', + action: 'REPLACE', + enabled: true, + } +} + +function openKeywordFilterDialog(row?: KeywordFilter) { + if (row) { + editingKeywordFilterId.value = row.id + keywordFilterForm.value = { + pattern: row.pattern, + replacement: row.replacement ?? '', + action: row.action, + enabled: row.enabled, + } + } else { + resetKeywordFilterForm() + } + showKeywordFilterDialog.value = true +} + +function resetGroupManageDialog() { + showGroupManageDialog.value = false + managedGroup.value = null + groupManageTab.value = 'members' + managedGroupMembers.value = [] + managedGroupJoinRequests.value = [] + groupMemberSearchKeyword.value = '' + groupMemberSearchResults.value = [] +} + +function openManageGroupDialog(group: ImGroup) { + managedGroup.value = group + showGroupManageDialog.value = true + groupManageTab.value = 'members' + groupMemberSearchKeyword.value = '' + groupMemberSearchResults.value = [] + loadManagedGroupMembers() + loadManagedGroupJoinRequests() +} + function resetSendGroupJoinRequestForm() { sendGroupJoinRequestForm.value = { groupId: '', remark: '' } } @@ -696,7 +1226,7 @@ function resetMessageSearch() { async function loadStats() { try { - const res = await imAdminApi.getStats(appId) + const res = await imAdminApi.getStats(appKey.value) stats.value = res.data.data } catch { stats.value = null @@ -706,7 +1236,7 @@ async function loadStats() { async function loadUsers() { loadingUsers.value = true try { - const res = await imAdminApi.listUsers(appId, userPage.value, userPageSize) + const res = await imAdminApi.listUsers(appKey.value, userPage.value, userPageSize) users.value = res.data.data.content userTotal.value = res.data.data.totalElements } catch { @@ -719,7 +1249,7 @@ async function loadUsers() { async function loadGroups() { loadingGroups.value = true try { - const res = await imAdminApi.listGroups(appId) + const res = await imAdminApi.listGroups(appKey.value) groups.value = res.data.data } catch { ElMessage.error('加载群组失败') @@ -732,7 +1262,7 @@ async function loadMessages(page = historyPage.value) { loadingMessages.value = true try { const [startTime, endTime] = historyForm.value.timeRange ?? [] - const res = await imAdminApi.searchMessages(appId, { + const res = await imAdminApi.searchMessages(appKey.value, { keyword: historyForm.value.keyword.trim() || undefined, chatType: historyForm.value.chatType || undefined, msgType: historyForm.value.msgType || undefined, @@ -754,7 +1284,7 @@ async function loadMessages(page = historyPage.value) { async function loadOperationLogs(page = operationLogPage.value) { loadingLogs.value = true try { - const res = await imAdminApi.getOperationLogs(appId, page, operationLogPageSize) + const res = await imAdminApi.getOperationLogs(appKey.value, page, operationLogPageSize) operationLogs.value = res.data.data.content operationLogTotal.value = res.data.data.totalElements operationLogPage.value = page @@ -768,7 +1298,7 @@ async function loadOperationLogs(page = operationLogPage.value) { async function loadWebhooks() { loadingWebhooks.value = true try { - const res = await imAdminApi.listWebhooks(appId) + const res = await imAdminApi.listWebhooks(appKey.value) webhooks.value = res.data.data } catch { ElMessage.error('加载回调配置失败') @@ -780,7 +1310,7 @@ async function loadWebhooks() { async function loadFriendRequests() { loadingFriendRequests.value = true try { - const res = await imAdminApi.listFriendRequests(appId, friendRequestDirection.value) + const res = await imAdminApi.listFriendRequests(appKey.value) friendRequests.value = res.data.data } catch { ElMessage.error('加载好友申请失败') @@ -789,6 +1319,42 @@ async function loadFriendRequests() { } } +async function loadBlacklist() { + loadingBlacklist.value = true + try { + const res = await imAdminApi.listBlacklist(appKey.value) + blacklist.value = res.data.data + } catch { + ElMessage.error('加载黑名单失败') + } finally { + loadingBlacklist.value = false + } +} + +async function loadKeywordFilters() { + loadingKeywordFilters.value = true + try { + const res = await imAdminApi.listKeywordFilters(appKey.value) + keywordFilters.value = res.data.data + } catch { + ElMessage.error('加载过滤规则失败') + } finally { + loadingKeywordFilters.value = false + } +} + +async function loadGlobalMute() { + loadingGlobalMute.value = true + try { + const res = await imAdminApi.getGlobalMute(appKey.value) + globalMute.value = res.data.data + } catch { + ElMessage.error('加载全局禁言状态失败') + } finally { + loadingGlobalMute.value = false + } +} + async function loadGroupJoinRequests() { const groupId = groupRequestGroupId.value.trim() if (!groupId) { @@ -797,7 +1363,7 @@ async function loadGroupJoinRequests() { } loadingGroupRequests.value = true try { - const res = await imAdminApi.listGroupJoinRequests(appId, groupId) + const res = await imAdminApi.listGroupJoinRequests(appKey.value, groupId) groupJoinRequests.value = res.data.data } catch { ElMessage.error('加载入群申请失败') @@ -806,6 +1372,243 @@ async function loadGroupJoinRequests() { } } +async function loadManagedGroupMembers() { + const group = managedGroup.value + if (!group) return + const groupId = group.id + loadingManagedGroupMembers.value = true + try { + const res = await imAdminApi.listGroupMembers(appKey.value, group.id) + if (managedGroup.value?.id === groupId) { + managedGroupMembers.value = res.data.data + } + } catch { + ElMessage.error('加载群成员失败') + } finally { + loadingManagedGroupMembers.value = false + } +} + +async function loadManagedGroupJoinRequests() { + const group = managedGroup.value + if (!group) return + const groupId = group.id + loadingManagedGroupJoinRequests.value = true + try { + const res = await imAdminApi.listGroupJoinRequests(appKey.value, group.id) + if (managedGroup.value?.id === groupId) { + managedGroupJoinRequests.value = res.data.data + } + } catch { + ElMessage.error('加载入群申请失败') + } finally { + loadingManagedGroupJoinRequests.value = false + } +} + +function onGroupMemberSearchInput() { + if (groupMemberSearchTimer) clearTimeout(groupMemberSearchTimer) + const keyword = groupMemberSearchKeyword.value.trim() + if (!keyword) { + groupMemberSearchResults.value = [] + return + } + groupMemberSearchTimer = setTimeout(async () => { + searchingGroupMembers.value = true + try { + const res = await imAdminApi.searchUsers(appKey.value, keyword) + const selectedIds = new Set(managedGroupMembers.value.map(item => item.userId)) + groupMemberSearchResults.value = res.data.data.filter(user => !selectedIds.has(user.userId)) + } catch { + groupMemberSearchResults.value = [] + } finally { + searchingGroupMembers.value = false + } + }, 300) +} + +function groupMemberRoleLabel(userId: string) { + const group = managedGroup.value + if (!group) return '-' + if (group.creatorId === userId) return '群主' + return parseIdList(group.adminIds).includes(userId) ? '管理员' : '成员' +} + +function groupMemberRoleTagType(userId: string) { + const label = groupMemberRoleLabel(userId) + return { 群主: 'warning', 管理员: 'success', 成员: 'info' }[label] ?? 'info' +} + +async function addManagedGroupMember(user: ImUser) { + const group = managedGroup.value + if (!group) return + try { + await imAdminApi.addGroupMember(appKey.value, group.id, user.userId) + ElMessage.success('已加入群组') + groupMemberSearchResults.value = groupMemberSearchResults.value.filter(item => item.userId !== user.userId) + await loadManagedGroupMembers() + await loadGroups() + } catch { + ElMessage.error('添加群成员失败') + } +} + +async function removeManagedGroupMember(user: ImUser) { + const group = managedGroup.value + if (!group) return + await ElMessageBox.confirm(`确认移除群成员 ${user.userId}?`, '移除群成员', { + type: 'warning', + confirmButtonText: '确认', + cancelButtonText: '取消', + }) + await imAdminApi.removeGroupMember(appKey.value, group.id, user.userId) + ElMessage.success('成员已移除') + await loadManagedGroupMembers() + await loadGroups() +} + +async function toggleManagedGroupRole(user: ImUser) { + const group = managedGroup.value + if (!group) return + const nextRole = groupMemberRoleLabel(user.userId) === '管理员' ? 'MEMBER' : 'ADMIN' + await imAdminApi.setGroupRole(appKey.value, group.id, user.userId, nextRole) + ElMessage.success('群成员角色已更新') + await loadManagedGroupMembers() + await loadGroups() +} + +async function muteManagedGroupMember(user: ImUser) { + const group = managedGroup.value + if (!group) return + const { value } = await ElMessageBox.prompt('请输入禁言时长(分钟)', '群成员禁言', { + confirmButtonText: '确认', + cancelButtonText: '取消', + inputPattern: /^\d+$/, + inputErrorMessage: '请输入非负整数', + type: 'warning', + }) + const minutes = Number(value) + await imAdminApi.muteGroupMember(appKey.value, group.id, user.userId, minutes) + ElMessage.success('成员已禁言') +} + +function openFriendRequestDialog() { + resetSendFriendRequestForm() + showSendFriendRequest.value = true +} + +async function toggleGlobalMute() { + savingGlobalMute.value = true + try { + const res = await imAdminApi.setGlobalMute(appKey.value, globalMuteEnabled.value) + globalMute.value = res.data.data + ElMessage.success(globalMuteEnabled.value ? '全局禁言已开启' : '全局禁言已关闭') + } catch { + ElMessage.error('更新全局禁言失败') + await loadGlobalMute() + } finally { + savingGlobalMute.value = false + } +} + +async function submitBlacklist() { + if (!blacklistForm.value.userId.trim() || !blacklistForm.value.blockedUserId.trim()) { + ElMessage.warning('请填写双方用户ID') + return + } + submittingBlacklist.value = true + try { + await imAdminApi.addBlacklist( + appKey.value, + blacklistForm.value.userId.trim(), + blacklistForm.value.blockedUserId.trim(), + ) + ElMessage.success('黑名单已添加') + showBlacklistDialog.value = false + resetBlacklistForm() + loadBlacklist() + } catch { + ElMessage.error('添加黑名单失败') + } finally { + submittingBlacklist.value = false + } +} + +async function removeBlacklist(row: BlacklistEntry) { + await ElMessageBox.confirm(`确认移除 ${row.userId} -> ${row.blockedUserId} 的黑名单?`, '移除黑名单', { + type: 'warning', + confirmButtonText: '确认', + cancelButtonText: '取消', + }) + await imAdminApi.removeBlacklist(appKey.value, row.userId, row.blockedUserId) + ElMessage.success('黑名单已移除') + loadBlacklist() +} + +async function submitKeywordFilter() { + if (!keywordFilterForm.value.pattern.trim()) { + ElMessage.warning('请填写命中词') + return + } + submittingKeywordFilter.value = true + try { + const payload = { + pattern: keywordFilterForm.value.pattern.trim(), + replacement: keywordFilterForm.value.replacement.trim() || undefined, + action: keywordFilterForm.value.action, + enabled: keywordFilterForm.value.enabled, + } + if (editingKeywordFilterId.value) { + await imAdminApi.updateKeywordFilter(appKey.value, editingKeywordFilterId.value, payload) + ElMessage.success('过滤规则已更新') + } else { + await imAdminApi.createKeywordFilter(appKey.value, payload) + ElMessage.success('过滤规则已创建') + } + showKeywordFilterDialog.value = false + resetKeywordFilterForm() + loadKeywordFilters() + } catch { + ElMessage.error('保存过滤规则失败') + } finally { + submittingKeywordFilter.value = false + } +} + +async function deleteKeywordFilter(row: KeywordFilter) { + await ElMessageBox.confirm(`确认删除过滤规则 ${row.pattern}?`, '删除过滤规则', { + type: 'warning', + confirmButtonText: '确认', + cancelButtonText: '取消', + }) + await imAdminApi.deleteKeywordFilter(appKey.value, row.id) + ElMessage.success('过滤规则已删除') + loadKeywordFilters() +} + +async function submitFriendRequest() { + if (!sendFriendRequestForm.value.toUserId.trim()) { + ElMessage.warning('请填写接收人ID') + return + } + submittingSendFriendRequest.value = true + try { + await imAdminApi.sendFriendRequest( + appKey.value, + sendFriendRequestForm.value.toUserId.trim(), + sendFriendRequestForm.value.remark.trim() || undefined, + ) + ElMessage.success('好友申请已发送') + showSendFriendRequest.value = false + resetSendFriendRequestForm() + loadFriendRequests() + } catch { + ElMessage.error('发送失败') + } finally { + submittingSendFriendRequest.value = false + } +} + async function searchMessages() { await loadMessages(0) } @@ -838,10 +1641,10 @@ async function submitWebhookForm() { enabled: webhookForm.value.enabled, } if (editingWebhookId.value) { - await imAdminApi.updateWebhook(appId, editingWebhookId.value, payload) + await imAdminApi.updateWebhook(appKey.value, editingWebhookId.value, payload) ElMessage.success('回调配置已更新') } else { - await imAdminApi.createWebhook(appId, payload) + await imAdminApi.createWebhook(appKey.value, payload) ElMessage.success('回调配置已创建') } showWebhookDialog.value = false @@ -860,7 +1663,7 @@ async function deleteWebhook(row: WebhookConfig) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.deleteWebhook(appId, row.id) + await imAdminApi.deleteWebhook(appKey.value, row.id) ElMessage.success('回调配置已删除') loadWebhooks() } @@ -873,7 +1676,7 @@ async function toggleUserStatus(user: ImUser) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.updateUserStatus(appId, user.userId, nextStatus) + await imAdminApi.updateUserStatus(appKey.value, user.userId, nextStatus) ElMessage.success(`已${action}`) loadUsers() } @@ -884,7 +1687,7 @@ async function revokeMessage(message: ImMessage) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.revokeMessage(appId, message.id) + await imAdminApi.revokeMessage(appKey.value, message.id) ElMessage.success('消息已撤回') loadMessages(historyPage.value) } @@ -895,7 +1698,7 @@ async function approveFriend(request: FriendRequest) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.acceptFriendRequest(appId, request.id) + await imAdminApi.acceptFriendRequest(appKey.value, request.id) ElMessage.success('已同意好友申请') loadFriendRequests() } @@ -906,36 +1709,13 @@ async function declineFriend(request: FriendRequest) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.rejectFriendRequest(appId, request.id) + await imAdminApi.rejectFriendRequest(appKey.value, request.id) ElMessage.success('已拒绝好友申请') loadFriendRequests() } -async function submitSendFriendRequest() { - if (!sendFriendRequestForm.value.toUserId.trim()) { - ElMessage.warning('请填写接收人ID') - return - } - submittingSendFriendRequest.value = true - try { - await imAdminApi.sendFriendRequest( - appId, - sendFriendRequestForm.value.toUserId.trim(), - sendFriendRequestForm.value.remark.trim() || undefined, - ) - ElMessage.success('好友申请已发送') - showSendFriendRequest.value = false - resetSendFriendRequestForm() - loadFriendRequests() - } catch { - ElMessage.error('发送失败') - } finally { - submittingSendFriendRequest.value = false - } -} - async function approveGroupJoin(request: GroupJoinRequest) { - const groupId = groupRequestGroupId.value.trim() + const groupId = managedGroup.value?.id || groupRequestGroupId.value.trim() if (!groupId) { ElMessage.warning('请先输入群组ID') return @@ -945,13 +1725,13 @@ async function approveGroupJoin(request: GroupJoinRequest) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.acceptGroupJoinRequest(appId, groupId, request.id) + await imAdminApi.acceptGroupJoinRequest(appKey.value, groupId, request.id) ElMessage.success('已同意入群申请') loadGroupJoinRequests() } async function declineGroupJoin(request: GroupJoinRequest) { - const groupId = groupRequestGroupId.value.trim() + const groupId = managedGroup.value?.id || groupRequestGroupId.value.trim() if (!groupId) { ElMessage.warning('请先输入群组ID') return @@ -961,7 +1741,7 @@ async function declineGroupJoin(request: GroupJoinRequest) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.rejectGroupJoinRequest(appId, groupId, request.id) + await imAdminApi.rejectGroupJoinRequest(appKey.value, groupId, request.id) ElMessage.success('已拒绝入群申请') loadGroupJoinRequests() } @@ -975,7 +1755,7 @@ async function submitSendGroupJoinRequest() { submittingSendGroupJoinRequest.value = true try { await imAdminApi.sendGroupJoinRequest( - appId, + appKey.value, groupId, sendGroupJoinRequestForm.value.remark.trim() || undefined, ) @@ -997,7 +1777,7 @@ async function dismissGroup(group: ImGroup) { confirmButtonText: '确认', cancelButtonText: '取消', }) - await imAdminApi.dismissGroup(appId, group.id) + await imAdminApi.dismissGroup(appKey.value, group.id) ElMessage.success('群组已解散') loadGroups() } @@ -1016,11 +1796,11 @@ async function submitRegisterUser() { status: registerForm.value.status, } if (editingUserId.value) { - await imAdminApi.updateUser(appId, editingUserId.value, payload) + await imAdminApi.updateUser(appKey.value, editingUserId.value, payload) ElMessage.success('用户已更新') } else { await imAdminApi.registerUser( - appId, + appKey.value, registerForm.value.userId.trim(), payload.nickname, payload.avatar, @@ -1048,7 +1828,7 @@ async function submitCreateGroup() { try { const memberIds = selectedMembers.value.map(item => item.userId) await imAdminApi.createGroup( - appId, + appKey.value, createGroupForm.value.name.trim(), createGroupForm.value.creatorId.trim(), memberIds, @@ -1074,7 +1854,7 @@ async function submitEditGroup() { } submittingEditGroup.value = true try { - await imAdminApi.updateGroup(appId, editingGroup.value.id, { + await imAdminApi.updateGroup(appKey.value, editingGroup.value.id, { name: editGroupForm.value.name.trim(), groupType: editGroupForm.value.groupType, announcement: editGroupForm.value.announcement.trim() || undefined, @@ -1092,6 +1872,17 @@ async function submitEditGroup() { function handleTabChange(tab: string) { if (tab === 'groups' && groups.value.length === 0) { loadGroups() + } else if (tab === 'friendRequests' && friendRequests.value.length === 0) { + loadFriendRequests() + } else if (tab === 'blacklist' && blacklist.value.length === 0) { + loadBlacklist() + } else if (tab === 'governance') { + if (keywordFilters.value.length === 0) { + loadKeywordFilters() + } + if (!globalMute.value) { + loadGlobalMute() + } } else if (tab === 'logs' && operationLogs.value.length === 0) { loadOperationLogs() } else if (tab === 'webhooks' && webhooks.value.length === 0) { @@ -1124,7 +1915,18 @@ function logActionLabel(action: string) { UPDATE_USER_STATUS: '调整状态', CREATE_GROUP: '创建群组', UPDATE_GROUP: '更新群组', + ADMIN_ADD_GROUP_MEMBER: '添加群成员', + ADMIN_REMOVE_GROUP_MEMBER: '移除群成员', + ADMIN_SET_GROUP_ROLE: '调整群角色', + ADMIN_MUTE_GROUP_MEMBER: '群成员禁言', + ADMIN_ACCEPT_GROUP_JOIN_REQUEST: '同意入群申请', + ADMIN_REJECT_GROUP_JOIN_REQUEST: '拒绝入群申请', ADMIN_REVOKE_MESSAGE: '撤回消息', + CREATE_FRIEND_REQUEST: '创建好友申请', + ACCEPT_FRIEND_REQUEST: '同意好友申请', + REJECT_FRIEND_REQUEST: '拒绝好友申请', + ADD_BLACKLIST: '新增黑名单', + REMOVE_BLACKLIST: '移除黑名单', CREATE_WEBHOOK: '创建回调', UPDATE_WEBHOOK: '更新回调', DELETE_WEBHOOK: '删除回调', @@ -1145,6 +1947,9 @@ function logResourceLabel(resourceType: string) { ACCOUNT: '用户', GROUP: '群组', MESSAGE: '消息', + FRIEND_REQUEST: '好友申请', + BLACKLIST: '黑名单', + GROUP_JOIN_REQUEST: '入群申请', WEBHOOK: '回调', KEYWORD_FILTER: '过滤', GLOBAL_MUTE: '全局禁言', @@ -1207,6 +2012,25 @@ function userAvatarFallback(row: ImUser) { flex-wrap: wrap; } +.section-block { + display: flex; + flex-direction: column; + gap: 10px; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.section-title { + font-size: 14px; + font-weight: 600; + color: #1f2933; +} + .search-results { width: 100%; margin-top: 4px; diff --git a/tenant-platform/src/views/im/ImWebhookView.vue b/tenant-platform/src/views/im/ImWebhookView.vue new file mode 100644 index 0000000..9856166 --- /dev/null +++ b/tenant-platform/src/views/im/ImWebhookView.vue @@ -0,0 +1,227 @@ + + + + diff --git a/tenant-platform/src/views/update/VersionManagementView.vue b/tenant-platform/src/views/update/VersionManagementView.vue index 1de0b42..dfd8a11 100644 --- a/tenant-platform/src/views/update/VersionManagementView.vue +++ b/tenant-platform/src/views/update/VersionManagementView.vue @@ -3,9 +3,6 @@ -
- 一键上传 -
@@ -125,6 +122,13 @@ +
已配置 未配置
+
+
更新于 {{ formatTime(getStoreConfig(store.type)?.updatedAt ?? '') }}
+
请先补齐 {{ store.shortLabel }} 的凭据
+