mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 22:46:38 +08:00
feat(message-gateway): add admin channel cards and per-type forms
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
Layers,
|
Layers,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
MessagesSquare,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -84,6 +85,7 @@ const adminItems: NavItem[] = [
|
|||||||
{ titleKey: 'storage', url: '/admin/files', icon: FolderOpen },
|
{ titleKey: 'storage', url: '/admin/files', icon: FolderOpen },
|
||||||
{ titleKey: 'database', url: '/admin/database', icon: Database },
|
{ titleKey: 'database', url: '/admin/database', icon: Database },
|
||||||
{ titleKey: 'push', url: '/admin/push', icon: Bell },
|
{ titleKey: 'push', url: '/admin/push', icon: Bell },
|
||||||
|
{ titleKey: 'messageGateway', url: '/admin/message-gateway', icon: MessagesSquare },
|
||||||
{ titleKey: 'logs', url: '/admin/logs', icon: Terminal },
|
{ titleKey: 'logs', url: '/admin/logs', icon: Terminal },
|
||||||
{ titleKey: 'system', url: '/admin/system', icon: ShieldCheck },
|
{ titleKey: 'system', url: '/admin/system', icon: ShieldCheck },
|
||||||
{ titleKey: 'adminSettings', url: '/admin/settings', icon: Settings },
|
{ titleKey: 'adminSettings', url: '/admin/settings', icon: Settings },
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { AuthService } from './auth';
|
import { AuthService } from './auth';
|
||||||
import { ConfigService } from './config';
|
import { ConfigService } from './config';
|
||||||
import { DbManageService } from './db-manage';
|
import { DbManageService } from './db-manage';
|
||||||
|
import { AdminMessageGatewayService } from './message-gateway';
|
||||||
import { PushService } from './push';
|
import { PushService } from './push';
|
||||||
import { AdminUploadService, UploadService } from './upload';
|
import { AdminUploadService, UploadService } from './upload';
|
||||||
import { UserService } from './user';
|
import { UserService } from './user';
|
||||||
@@ -43,6 +44,7 @@ const services = {
|
|||||||
adminUpload: AdminUploadService,
|
adminUpload: AdminUploadService,
|
||||||
dbManage: DbManageService,
|
dbManage: DbManageService,
|
||||||
push: PushService,
|
push: PushService,
|
||||||
|
adminMessageGateway: AdminMessageGatewayService,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default services;
|
export default services;
|
||||||
@@ -167,6 +169,16 @@ export type {
|
|||||||
} from './db-manage';
|
} from './db-manage';
|
||||||
|
|
||||||
export { PushService } from './push';
|
export { PushService } from './push';
|
||||||
|
export { AdminMessageGatewayService } from './message-gateway';
|
||||||
|
export type {
|
||||||
|
MessageChannel,
|
||||||
|
MessageChannelDefinition,
|
||||||
|
CreateMessageChannelRequest,
|
||||||
|
UpdateMessageChannelRequest,
|
||||||
|
PublicMessageChannel,
|
||||||
|
MessageBinding,
|
||||||
|
BindMessageChannelRequest,
|
||||||
|
} from './message-gateway';
|
||||||
export type {
|
export type {
|
||||||
PushEvent,
|
PushEvent,
|
||||||
PushHistory,
|
PushHistory,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { BaseService } from '@/lib/services/core';
|
||||||
|
import type {
|
||||||
|
CreateMessageChannelRequest,
|
||||||
|
MessageChannel,
|
||||||
|
MessageChannelDefinition,
|
||||||
|
UpdateMessageChannelRequest,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export class AdminMessageGatewayService extends BaseService {
|
||||||
|
protected static readonly basePath = '/api/v1/admin/message-gateway';
|
||||||
|
|
||||||
|
static async list(): Promise<MessageChannel[]> {
|
||||||
|
return this.get<MessageChannel[]>('/channels');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async definitions(): Promise<MessageChannelDefinition[]> {
|
||||||
|
return this.get<MessageChannelDefinition[]>('/channels/definitions');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(
|
||||||
|
data: CreateMessageChannelRequest,
|
||||||
|
): Promise<MessageChannel> {
|
||||||
|
return this.post<MessageChannel>('/channels', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(
|
||||||
|
id: string,
|
||||||
|
data: UpdateMessageChannelRequest,
|
||||||
|
): Promise<MessageChannel> {
|
||||||
|
return this.patch<MessageChannel>(`/channels/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id: string): Promise<void> {
|
||||||
|
return this.delete<void>(`/channels/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async test(id: string): Promise<void> {
|
||||||
|
return this.post<void>(`/channels/${id}/test`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './admin.service';
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
export type MessageChannelType = 'telegram' | 'qq';
|
||||||
|
|
||||||
|
export interface MessageChannelField {
|
||||||
|
key: string;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageChannelDefinition {
|
||||||
|
type: MessageChannelType | string;
|
||||||
|
name: string;
|
||||||
|
fields: MessageChannelField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: MessageChannelType | string;
|
||||||
|
owner_scope: string;
|
||||||
|
enabled: boolean;
|
||||||
|
bot_token?: string;
|
||||||
|
app_id?: string;
|
||||||
|
app_secret?: string;
|
||||||
|
base_url?: string;
|
||||||
|
portal_host?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMessageChannelRequest {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
bot_token?: string;
|
||||||
|
app_id?: string;
|
||||||
|
app_secret?: string;
|
||||||
|
base_url?: string;
|
||||||
|
portal_host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMessageChannelRequest {
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
bot_token?: string;
|
||||||
|
app_id?: string;
|
||||||
|
app_secret?: string;
|
||||||
|
base_url?: string;
|
||||||
|
portal_host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicMessageChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: MessageChannelType | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageBinding {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
channel_name: string;
|
||||||
|
channel_type: string;
|
||||||
|
platform_user_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BindMessageChannelRequest {
|
||||||
|
channel_id: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"database": "Database",
|
"database": "Database",
|
||||||
"push": "Notifications",
|
"push": "Notifications",
|
||||||
|
"messageGateway": "Messaging",
|
||||||
"logs": "System Logs",
|
"logs": "System Logs",
|
||||||
"system": "System Config",
|
"system": "System Config",
|
||||||
"adminSettings": "System Settings",
|
"adminSettings": "System Settings",
|
||||||
@@ -1021,6 +1022,45 @@
|
|||||||
"close": "Close"
|
"close": "Close"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"messageGateway": {
|
||||||
|
"pageTitle": "Messaging channels",
|
||||||
|
"pageDescription": "Configure instance-level Telegram and QQ bots. Users bind their private-chat identity with a pairing code.",
|
||||||
|
"addChannel": "Add channel",
|
||||||
|
"emptyTitle": "No messaging channels yet",
|
||||||
|
"emptyDescription": "Add a shared bot so users can bind it on their profile and send private messages.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirmTitle": "Delete this channel?",
|
||||||
|
"deleteConfirmDescription": "Bindings and unused pairing codes for this channel will also be removed. This cannot be undone.",
|
||||||
|
"createSuccess": "Channel created",
|
||||||
|
"createFailed": "Failed to create channel",
|
||||||
|
"updateSuccess": "Channel updated",
|
||||||
|
"updateFailed": "Failed to update channel",
|
||||||
|
"deleteSuccess": "Channel deleted",
|
||||||
|
"deleteFailed": "Failed to delete channel",
|
||||||
|
"testSuccess": "Connectivity test passed",
|
||||||
|
"testFailed": "Connectivity test failed",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "e.g. Ops bot",
|
||||||
|
"type": "Type",
|
||||||
|
"typeTelegram": "Telegram",
|
||||||
|
"typeQQ": "QQ",
|
||||||
|
"selectType": "Select a channel type",
|
||||||
|
"botToken": "Bot token",
|
||||||
|
"botTokenPlaceholder": "From BotFather",
|
||||||
|
"baseUrl": "API base URL",
|
||||||
|
"baseUrlPlaceholder": "https://api.telegram.org",
|
||||||
|
"appId": "App ID",
|
||||||
|
"appSecret": "App Secret",
|
||||||
|
"portalHost": "Portal host",
|
||||||
|
"portalHostPlaceholder": "q.qq.com",
|
||||||
|
"keepSecretHint": "Leave blank to keep the saved secret",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create",
|
||||||
|
"testConnection": "Test connection",
|
||||||
|
"loadFailed": "Failed to load channels"
|
||||||
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"pageTitle": "Database",
|
"pageTitle": "Database",
|
||||||
"refreshData": "Refresh data",
|
"refreshData": "Refresh data",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"storage": "存储管理",
|
"storage": "存储管理",
|
||||||
"database": "数据管理",
|
"database": "数据管理",
|
||||||
"push": "通知推送",
|
"push": "通知推送",
|
||||||
|
"messageGateway": "消息通道",
|
||||||
"logs": "系统日志",
|
"logs": "系统日志",
|
||||||
"system": "系统配置",
|
"system": "系统配置",
|
||||||
"adminSettings": "系统设置",
|
"adminSettings": "系统设置",
|
||||||
@@ -1021,6 +1022,45 @@
|
|||||||
"close": "关闭"
|
"close": "关闭"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"messageGateway": {
|
||||||
|
"pageTitle": "消息通道",
|
||||||
|
"pageDescription": "配置实例级 Telegram / QQ 机器人,用户通过配对码绑定私聊身份。",
|
||||||
|
"addChannel": "添加通道",
|
||||||
|
"emptyTitle": "还没有消息通道",
|
||||||
|
"emptyDescription": "添加一个共享机器人,用户即可在资料页绑定并发送私聊消息。",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteConfirmTitle": "删除通道?",
|
||||||
|
"deleteConfirmDescription": "将同时删除该通道下的绑定和未使用的配对码。此操作不可撤销。",
|
||||||
|
"createSuccess": "通道已创建",
|
||||||
|
"createFailed": "创建通道失败",
|
||||||
|
"updateSuccess": "通道已更新",
|
||||||
|
"updateFailed": "更新通道失败",
|
||||||
|
"deleteSuccess": "通道已删除",
|
||||||
|
"deleteFailed": "删除通道失败",
|
||||||
|
"testSuccess": "连通性测试通过",
|
||||||
|
"testFailed": "连通性测试失败",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "例如:运营机器人",
|
||||||
|
"type": "类型",
|
||||||
|
"typeTelegram": "Telegram",
|
||||||
|
"typeQQ": "QQ",
|
||||||
|
"selectType": "选择通道类型",
|
||||||
|
"botToken": "Bot Token",
|
||||||
|
"botTokenPlaceholder": "从 BotFather 获取",
|
||||||
|
"baseUrl": "API 地址",
|
||||||
|
"baseUrlPlaceholder": "https://api.telegram.org",
|
||||||
|
"appId": "App ID",
|
||||||
|
"appSecret": "App Secret",
|
||||||
|
"portalHost": "开放平台域名",
|
||||||
|
"portalHostPlaceholder": "q.qq.com",
|
||||||
|
"keepSecretHint": "留空则保留已保存的密钥",
|
||||||
|
"cancel": "取消",
|
||||||
|
"create": "创建",
|
||||||
|
"testConnection": "测试连通性",
|
||||||
|
"loadFailed": "加载通道失败"
|
||||||
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"pageTitle": "数据管理",
|
"pageTitle": "数据管理",
|
||||||
"refreshData": "刷新数据",
|
"refreshData": "刷新数据",
|
||||||
|
|||||||
Reference in New Issue
Block a user