mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
feat(message-gateway): add profile bot pairing card
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 认证源进行系统登录。",
|
||||
|
||||
Reference in New Issue
Block a user