feat(message-gateway): add admin channel cards and per-type forms

This commit is contained in:
ryan
2026-08-16 12:22:28 +08:00
parent 635c1760ad
commit 124ce9bebb
12 changed files with 707 additions and 0 deletions
@@ -0,0 +1,57 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface QQFormValue {
app_id: string;
app_secret: string;
portal_host: string;
}
interface QQFormProps {
value: QQFormValue;
onChange: (value: QQFormValue) => void;
keepSecretHint?: boolean;
}
export function QQForm({ value, onChange, keepSecretHint }: QQFormProps) {
const t = useTranslations('admin.messageGateway');
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='mg-app-id'>{t('appId')}</Label>
<Input
id='mg-app-id'
value={value.app_id}
onChange={(e) => onChange({ ...value, app_id: e.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='mg-app-secret'>{t('appSecret')}</Label>
<Input
id='mg-app-secret'
type='password'
autoComplete='off'
value={value.app_secret}
placeholder={keepSecretHint ? t('keepSecretHint') : undefined}
onChange={(e) => onChange({ ...value, app_secret: e.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='mg-portal-host'>{t('portalHost')}</Label>
<Input
id='mg-portal-host'
value={value.portal_host}
placeholder={t('portalHostPlaceholder')}
onChange={(e) => onChange({ ...value, portal_host: e.target.value })}
/>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface TelegramFormValue {
bot_token: string;
base_url: string;
}
interface TelegramFormProps {
value: TelegramFormValue;
onChange: (value: TelegramFormValue) => void;
keepSecretHint?: boolean;
}
export function TelegramForm({
value,
onChange,
keepSecretHint,
}: TelegramFormProps) {
const t = useTranslations('admin.messageGateway');
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='mg-bot-token'>{t('botToken')}</Label>
<Input
id='mg-bot-token'
type='password'
autoComplete='off'
value={value.bot_token}
placeholder={
keepSecretHint ? t('keepSecretHint') : t('botTokenPlaceholder')
}
onChange={(e) => onChange({ ...value, bot_token: e.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='mg-base-url'>{t('baseUrl')}</Label>
<Input
id='mg-base-url'
value={value.base_url}
placeholder={t('baseUrlPlaceholder')}
onChange={(e) => onChange({ ...value, base_url: e.target.value })}
/>
</div>
</div>
);
}
@@ -0,0 +1,137 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { TelegramForm, type TelegramFormValue } from '../channels/telegram/form';
import { QQForm, type QQFormValue } from '../channels/qq/form';
import type { CreateMessageChannelRequest } from '@/lib/services/message-gateway';
interface AddChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateMessageChannelRequest) => void;
submitting?: boolean;
}
export function AddChannelDialog({
open,
onOpenChange,
onSubmit,
submitting,
}: AddChannelDialogProps) {
const t = useTranslations('admin.messageGateway');
const [name, setName] = React.useState('');
const [type, setType] = React.useState<'telegram' | 'qq' | ''>('');
const [telegram, setTelegram] = React.useState<TelegramFormValue>({
bot_token: '',
base_url: '',
});
const [qq, setQQ] = React.useState<QQFormValue>({
app_id: '',
app_secret: '',
portal_host: '',
});
React.useEffect(() => {
if (!open) {
setName('');
setType('');
setTelegram({ bot_token: '', base_url: '' });
setQQ({ app_id: '', app_secret: '', portal_host: '' });
}
}, [open]);
const canSubmit =
name.trim() !== '' &&
((type === 'telegram' && telegram.bot_token.trim() !== '') ||
(type === 'qq' && qq.app_id.trim() !== '' && qq.app_secret.trim() !== ''));
const handleSubmit = () => {
if (!canSubmit || !type) return;
const data: CreateMessageChannelRequest = {
name: name.trim(),
type,
enabled: true,
};
if (type === 'telegram') {
data.bot_token = telegram.bot_token.trim();
if (telegram.base_url.trim()) data.base_url = telegram.base_url.trim();
} else {
data.app_id = qq.app_id.trim();
data.app_secret = qq.app_secret.trim();
if (qq.portal_host.trim()) data.portal_host = qq.portal_host.trim();
}
onSubmit(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('addChannel')}</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='mg-name'>{t('name')}</Label>
<Input
id='mg-name'
value={name}
placeholder={t('namePlaceholder')}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>{t('type')}</Label>
<Select
value={type}
onValueChange={(v) => setType(v as 'telegram' | 'qq')}
>
<SelectTrigger>
<SelectValue placeholder={t('selectType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='telegram'>{t('typeTelegram')}</SelectItem>
<SelectItem value='qq'>{t('typeQQ')}</SelectItem>
</SelectContent>
</Select>
</div>
{type === 'telegram' ? (
<TelegramForm value={telegram} onChange={setTelegram} />
) : null}
{type === 'qq' ? <QQForm value={qq} onChange={setQQ} /> : null}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button disabled={!canSubmit || submitting} onClick={handleSubmit}>
{submitting ? <Loader2 className='size-4 animate-spin' /> : null}
{t('create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,104 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Loader2, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { MessageChannel } from '@/lib/services/message-gateway';
interface ChannelCardProps {
channel: MessageChannel;
onToggle: (enabled: boolean) => void;
onDelete: () => void;
toggling?: boolean;
deleting?: boolean;
}
export function ChannelCard({
channel,
onToggle,
onDelete,
toggling,
deleting,
}: ChannelCardProps) {
const t = useTranslations('admin.messageGateway');
const [confirmOpen, setConfirmOpen] = React.useState(false);
const typeLabel =
channel.type === 'qq' ? t('typeQQ') : t('typeTelegram');
return (
<div className='rounded-xl border bg-card p-4 space-y-4'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h2 className='font-medium truncate'>{channel.name}</h2>
<Badge variant='secondary' className='mt-1'>
{typeLabel}
</Badge>
</div>
<div className='flex items-center gap-2 shrink-0'>
<span className='text-xs text-muted-foreground'>
{channel.enabled ? t('enabled') : t('disabled')}
</span>
<Switch
checked={channel.enabled}
disabled={toggling}
onCheckedChange={onToggle}
/>
</div>
</div>
<div className='flex justify-end'>
<Button
variant='ghost'
size='sm'
className='text-destructive'
disabled={deleting}
onClick={() => setConfirmOpen(true)}
>
{deleting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Trash2 className='size-4' />
)}
{t('delete')}
</Button>
</div>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteConfirmDescription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmOpen(false);
onDelete();
}}
>
{t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,140 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { motion } from 'motion/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { MessagesSquare, Plus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { AdminMessageGatewayService } from '@/lib/services/message-gateway';
import type { CreateMessageChannelRequest } from '@/lib/services/message-gateway';
import { AddChannelDialog } from './components/add-channel-dialog';
import { ChannelCard } from './components/channel-card';
export default function MessageGatewayAdminPage() {
const t = useTranslations('admin.messageGateway');
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = React.useState(false);
const channelsQuery = useQuery({
queryKey: ['admin', 'message-gateway-channels'],
queryFn: () => AdminMessageGatewayService.list(),
});
const createMutation = useMutation({
mutationFn: (data: CreateMessageChannelRequest) =>
AdminMessageGatewayService.create(data),
onSuccess: () => {
toast.success(t('createSuccess'));
queryClient.invalidateQueries({
queryKey: ['admin', 'message-gateway-channels'],
});
setAddOpen(false);
},
onError: (err: unknown) => {
toast.error(t('createFailed') + ': ' + (err as Error).message);
},
});
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
AdminMessageGatewayService.update(id, { enabled }),
onSuccess: () => {
toast.success(t('updateSuccess'));
queryClient.invalidateQueries({
queryKey: ['admin', 'message-gateway-channels'],
});
},
onError: (err: unknown) => {
toast.error(t('updateFailed') + ': ' + (err as Error).message);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => AdminMessageGatewayService.remove(id),
onSuccess: () => {
toast.success(t('deleteSuccess'));
queryClient.invalidateQueries({
queryKey: ['admin', 'message-gateway-channels'],
});
},
onError: (err: unknown) => {
toast.error(t('deleteFailed') + ': ' + (err as Error).message);
},
});
const channels = channelsQuery.data ?? [];
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className='w-full py-6 space-y-6'
>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start gap-2'>
<MessagesSquare className='size-5 text-primary mt-1' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
<p className='text-sm text-muted-foreground mt-1'>
{t('pageDescription')}
</p>
</div>
</div>
<Button onClick={() => setAddOpen(true)}>
<Plus className='size-4' />
{t('addChannel')}
</Button>
</div>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder />
) : channelsQuery.isError ? (
<ErrorInline message={t('loadFailed')} />
) : channels.length === 0 ? (
<div className='rounded-xl border border-dashed p-10 text-center space-y-2'>
<h2 className='font-medium'>{t('emptyTitle')}</h2>
<p className='text-sm text-muted-foreground'>
{t('emptyDescription')}
</p>
<Button variant='outline' onClick={() => setAddOpen(true)}>
<Plus className='size-4' />
{t('addChannel')}
</Button>
</div>
) : (
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-3'>
{channels.map((ch) => (
<ChannelCard
key={ch.id}
channel={ch}
toggling={toggleMutation.isPending}
deleting={deleteMutation.isPending && deleteMutation.variables === ch.id}
onToggle={(enabled) =>
toggleMutation.mutate({ id: ch.id, enabled })
}
onDelete={() => deleteMutation.mutate(ch.id)}
/>
))}
</div>
)}
<AddChannelDialog
open={addOpen}
onOpenChange={setAddOpen}
submitting={createMutation.isPending}
onSubmit={(data) => createMutation.mutate(data)}
/>
</motion.div>
);
}