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,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>
);
}