From 30acdb91e891dd2dd7850190452887c2109311e2 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 12:23:44 +0800 Subject: [PATCH] feat(message-gateway): add profile bot pairing card --- .../common/settings/bot-binding-card.tsx | 202 ++++++++++++++++++ .../components/common/settings/profile.tsx | 2 + frontend/lib/services/index.ts | 11 +- .../lib/services/message-gateway/index.ts | 1 + .../services/message-gateway/user.service.ts | 29 +++ frontend/messages/en.json | 24 +++ frontend/messages/zh-CN.json | 24 +++ 7 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 frontend/components/common/settings/bot-binding-card.tsx create mode 100644 frontend/lib/services/message-gateway/user.service.ts diff --git a/frontend/components/common/settings/bot-binding-card.tsx b/frontend/components/common/settings/bot-binding-card.tsx new file mode 100644 index 00000000..5adaf3e3 --- /dev/null +++ b/frontend/components/common/settings/bot-binding-card.tsx @@ -0,0 +1,202 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import * as React from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Bot, Loader2, Unlink } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { UserMessageGatewayService } from '@/lib/services/message-gateway'; + +export function BotBindingCard() { + const t = useTranslations('settings.botBinding'); + const queryClient = useQueryClient(); + const [open, setOpen] = React.useState(false); + const [channelId, setChannelId] = React.useState(''); + const [code, setCode] = React.useState(''); + + const bindingsQuery = useQuery({ + queryKey: ['message-gateway', 'bindings'], + queryFn: () => UserMessageGatewayService.listBindings(), + }); + const channelsQuery = useQuery({ + queryKey: ['message-gateway', 'channels'], + queryFn: () => UserMessageGatewayService.listChannels(), + enabled: open, + }); + + const bindMutation = useMutation({ + mutationFn: () => + UserMessageGatewayService.bind({ channel_id: channelId, code }), + onSuccess: () => { + toast.success(t('bindSuccess')); + queryClient.invalidateQueries({ queryKey: ['message-gateway', 'bindings'] }); + setOpen(false); + setChannelId(''); + setCode(''); + }, + onError: (err: unknown) => { + toast.error(t('bindFailed') + ': ' + (err as Error).message); + }, + }); + + const unbindMutation = useMutation({ + mutationFn: (id: string) => UserMessageGatewayService.unbind(id), + onSuccess: () => { + toast.success(t('unbindSuccess')); + queryClient.invalidateQueries({ queryKey: ['message-gateway', 'bindings'] }); + }, + onError: (err: unknown) => { + toast.error(t('unbindFailed') + ': ' + (err as Error).message); + }, + }); + + const typeLabel = (typ: string) => + typ === 'qq' ? t('typeQQ') : t('typeTelegram'); + + return ( +
+
+
+ +
+
+

{t('title')}

+

{t('description')}

+
+
+ +
+

+ {t('bound')} +

+ {bindingsQuery.isPending ? ( +
+ +
+ ) : bindingsQuery.isError ? ( +

{t('loadFailed')}

+ ) : (bindingsQuery.data ?? []).length > 0 ? ( +
+ {(bindingsQuery.data ?? []).map((binding) => ( +
+
+ + {binding.channel_name || typeLabel(binding.channel_type)} + + + {t('platformUserId')}: {binding.platform_user_id} + +
+ +
+ ))} +
+ ) : ( +
+ {t('empty')} +
+ )} +
+ + + + + + + {t('bindDialogTitle')} + {t('bindDialogDescription')} + +
+
+ + + {!channelsQuery.isPending && + (channelsQuery.data ?? []).length === 0 ? ( +

{t('noChannels')}

+ ) : null} +
+
+ + setCode(e.target.value.toUpperCase())} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index df52d8db..9f2a00fa 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -65,6 +65,7 @@ import { } from '@/components/ui/image-crop'; import { toast } from 'sonner'; import { useTranslations } from 'next-intl'; +import { BotBindingCard } from '@/components/common/settings/bot-binding-card'; export function ProfileMain() { const { user, loading, refetch } = useUser(); @@ -651,6 +652,7 @@ export function ProfileMain() { + {/* 编辑资料 Dialog */} diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index c1956438..3720d1c1 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -23,7 +23,10 @@ import { import { AuthService } from './auth'; import { ConfigService } from './config'; import { DbManageService } from './db-manage'; -import { AdminMessageGatewayService } from './message-gateway'; +import { + AdminMessageGatewayService, + UserMessageGatewayService, +} from './message-gateway'; import { PushService } from './push'; import { AdminUploadService, UploadService } from './upload'; import { UserService } from './user'; @@ -45,6 +48,7 @@ const services = { dbManage: DbManageService, push: PushService, adminMessageGateway: AdminMessageGatewayService, + userMessageGateway: UserMessageGatewayService, } as const; export default services; @@ -169,7 +173,10 @@ export type { } from './db-manage'; export { PushService } from './push'; -export { AdminMessageGatewayService } from './message-gateway'; +export { + AdminMessageGatewayService, + UserMessageGatewayService, +} from './message-gateway'; export type { MessageChannel, MessageChannelDefinition, diff --git a/frontend/lib/services/message-gateway/index.ts b/frontend/lib/services/message-gateway/index.ts index a72eb34b..871e2d3e 100644 --- a/frontend/lib/services/message-gateway/index.ts +++ b/frontend/lib/services/message-gateway/index.ts @@ -3,3 +3,4 @@ export * from './types'; export * from './admin.service'; +export * from './user.service'; diff --git a/frontend/lib/services/message-gateway/user.service.ts b/frontend/lib/services/message-gateway/user.service.ts new file mode 100644 index 00000000..bedfbe82 --- /dev/null +++ b/frontend/lib/services/message-gateway/user.service.ts @@ -0,0 +1,29 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +import { BaseService } from '@/lib/services/core'; +import type { + BindMessageChannelRequest, + MessageBinding, + PublicMessageChannel, +} from './types'; + +export class UserMessageGatewayService extends BaseService { + protected static readonly basePath = '/api/v1/message-gateway'; + + static async listBindings(): Promise { + return this.get('/bindings'); + } + + static async listChannels(): Promise { + return this.get('/channels'); + } + + static async bind(data: BindMessageChannelRequest): Promise { + return this.post('/bindings', data); + } + + static async unbind(id: string): Promise { + return this.delete(`/bindings/${id}`); + } +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cfccee50..65145ad2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -338,6 +338,30 @@ "cropAvatar": "Crop avatar", "cropAvatarDesc": "Adjust the avatar position and size to fit within the centered circular area." }, + "botBinding": { + "title": "Bind a bot", + "description": "Link your Telegram or QQ private-chat identity to this account so messages you send the bot can be saved.", + "bound": "Bound", + "empty": "No bots bound yet", + "unbind": "Unbind", + "bind": "Enter pairing code", + "bindDialogTitle": "Bind a bot", + "bindDialogDescription": "Choose an enabled channel and enter the pairing code the bot sent you.", + "selectChannel": "Select a channel", + "pairingCode": "Pairing code", + "pairingCodePlaceholder": "ABCD-EFGH", + "submit": "Bind", + "cancel": "Cancel", + "bindSuccess": "Bound successfully", + "bindFailed": "Failed to bind", + "unbindSuccess": "Unbound", + "unbindFailed": "Failed to unbind", + "platformUserId": "Platform user ID", + "noChannels": "No channels available. Ask an admin to add one.", + "typeTelegram": "Telegram", + "typeQQ": "QQ", + "loadFailed": "Failed to load bindings" + }, "security": { "passwordLoginEnabled": "Allow password login", "passwordLoginEnabledDesc": "When disabled, only third-party OIDC authentication sources are available for login.", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index ba9401a9..c936f0b8 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -338,6 +338,30 @@ "cropAvatar": "裁剪头像", "cropAvatarDesc": "调整头像位置和大小,使其处于居中圆形区域内。" }, + "botBinding": { + "title": "绑定机器人", + "description": "把 Telegram / QQ 私聊身份绑定到此账号,向机器人发消息即可入库。", + "bound": "已绑定", + "empty": "尚未绑定任何机器人", + "unbind": "解绑", + "bind": "绑定配对码", + "bindDialogTitle": "绑定机器人", + "bindDialogDescription": "选择一个已启用的通道,并输入机器人发给你的配对码。", + "selectChannel": "选择通道", + "pairingCode": "配对码", + "pairingCodePlaceholder": "ABCD-EFGH", + "submit": "绑定", + "cancel": "取消", + "bindSuccess": "绑定成功", + "bindFailed": "绑定失败", + "unbindSuccess": "已解绑", + "unbindFailed": "解绑失败", + "platformUserId": "平台用户 ID", + "noChannels": "暂无可用通道,请联系管理员添加。", + "typeTelegram": "Telegram", + "typeQQ": "QQ", + "loadFailed": "加载绑定失败" + }, "security": { "passwordLoginEnabled": "允许密码登录", "passwordLoginEnabledDesc": "关闭后仅保留第三方 OIDC 认证源进行系统登录。",