feat(message-gateway): add profile bot pairing card

This commit is contained in:
ryan
2026-08-16 12:23:44 +08:00
parent 124ce9bebb
commit 30acdb91e8
7 changed files with 291 additions and 2 deletions
@@ -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 (
<div className='space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col'>
<div className='border-b pb-4 flex items-center gap-2'>
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
<Bot className='size-4' />
</div>
<div>
<h2 className='text-base font-semibold tracking-tight'>{t('title')}</h2>
<p className='text-[11px] text-muted-foreground'>{t('description')}</p>
</div>
</div>
<div className='space-y-2'>
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
{t('bound')}
</h3>
{bindingsQuery.isPending ? (
<div className='flex items-center justify-center py-4'>
<Loader2 className='size-4 animate-spin text-primary' />
</div>
) : bindingsQuery.isError ? (
<p className='text-[11px] text-destructive'>{t('loadFailed')}</p>
) : (bindingsQuery.data ?? []).length > 0 ? (
<div className='space-y-2'>
{(bindingsQuery.data ?? []).map((binding) => (
<div
key={binding.id}
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-3 bg-card'
>
<div className='space-y-0.5 min-w-0'>
<span className='font-semibold text-xs block truncate'>
{binding.channel_name || typeLabel(binding.channel_type)}
</span>
<span className='text-[10px] text-muted-foreground font-mono block truncate'>
{t('platformUserId')}: {binding.platform_user_id}
</span>
</div>
<Button
type='button'
variant='ghost'
size='sm'
className='text-[11px] text-muted-foreground hover:text-rose-500 h-7 px-2'
disabled={unbindMutation.isPending}
onClick={() => unbindMutation.mutate(binding.id)}
>
<Unlink className='size-3 mr-1' />
{t('unbind')}
</Button>
</div>
))}
</div>
) : (
<div className='rounded-xl border border-dashed px-4 py-6 text-center text-[11px] text-muted-foreground'>
{t('empty')}
</div>
)}
</div>
<Button
type='button'
variant='outline'
className='w-full border-dashed'
onClick={() => setOpen(true)}
>
{t('bind')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('bindDialogTitle')}</DialogTitle>
<DialogDescription>{t('bindDialogDescription')}</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('selectChannel')}</Label>
<Select value={channelId} onValueChange={setChannelId}>
<SelectTrigger>
<SelectValue placeholder={t('selectChannel')} />
</SelectTrigger>
<SelectContent>
{(channelsQuery.data ?? []).map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
{ch.name} ({typeLabel(ch.type)})
</SelectItem>
))}
</SelectContent>
</Select>
{!channelsQuery.isPending &&
(channelsQuery.data ?? []).length === 0 ? (
<p className='text-xs text-muted-foreground'>{t('noChannels')}</p>
) : null}
</div>
<div className='space-y-2'>
<Label htmlFor='mg-code'>{t('pairingCode')}</Label>
<Input
id='mg-code'
value={code}
placeholder={t('pairingCodePlaceholder')}
autoCapitalize='characters'
onChange={(e) => setCode(e.target.value.toUpperCase())}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button
disabled={
!channelId || code.trim() === '' || bindMutation.isPending
}
onClick={() => bindMutation.mutate()}
>
{bindMutation.isPending ? (
<Loader2 className='size-4 animate-spin' />
) : null}
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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() {
</div>
</div>
</div>
<BotBindingCard />
</div>
{/* 编辑资料 Dialog */}
+9 -2
View File
@@ -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,
@@ -3,3 +3,4 @@
export * from './types';
export * from './admin.service';
export * from './user.service';
@@ -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<MessageBinding[]> {
return this.get<MessageBinding[]>('/bindings');
}
static async listChannels(): Promise<PublicMessageChannel[]> {
return this.get<PublicMessageChannel[]>('/channels');
}
static async bind(data: BindMessageChannelRequest): Promise<MessageBinding> {
return this.post<MessageBinding>('/bindings', data);
}
static async unbind(id: string): Promise<void> {
return this.delete<void>(`/bindings/${id}`);
}
}
+24
View File
@@ -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.",
+24
View File
@@ -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 认证源进行系统登录。",