From 124ce9bebb766013e8ba3cc059bf87f73a3e2ca4 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 12:22:28 +0800 Subject: [PATCH] feat(message-gateway): add admin channel cards and per-type forms --- .../message-gateway/channels/qq/form.tsx | 57 +++++++ .../channels/telegram/form.tsx | 54 +++++++ .../components/add-channel-dialog.tsx | 137 +++++++++++++++++ .../components/channel-card.tsx | 104 +++++++++++++ .../app/(main)/admin/message-gateway/page.tsx | 140 ++++++++++++++++++ frontend/components/layout/sidebar.tsx | 2 + frontend/lib/services/index.ts | 12 ++ .../services/message-gateway/admin.service.ts | 43 ++++++ .../lib/services/message-gateway/index.ts | 5 + .../lib/services/message-gateway/types.ts | 73 +++++++++ frontend/messages/en.json | 40 +++++ frontend/messages/zh-CN.json | 40 +++++ 12 files changed, 707 insertions(+) create mode 100644 frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx create mode 100644 frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx create mode 100644 frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx create mode 100644 frontend/app/(main)/admin/message-gateway/components/channel-card.tsx create mode 100644 frontend/app/(main)/admin/message-gateway/page.tsx create mode 100644 frontend/lib/services/message-gateway/admin.service.ts create mode 100644 frontend/lib/services/message-gateway/index.ts create mode 100644 frontend/lib/services/message-gateway/types.ts diff --git a/frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx b/frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx new file mode 100644 index 00000000..eec64dc5 --- /dev/null +++ b/frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx @@ -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 ( +
+
+ + onChange({ ...value, app_id: e.target.value })} + /> +
+
+ + onChange({ ...value, app_secret: e.target.value })} + /> +
+
+ + onChange({ ...value, portal_host: e.target.value })} + /> +
+
+ ); +} diff --git a/frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx b/frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx new file mode 100644 index 00000000..802cf65a --- /dev/null +++ b/frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx @@ -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 ( +
+
+ + onChange({ ...value, bot_token: e.target.value })} + /> +
+
+ + onChange({ ...value, base_url: e.target.value })} + /> +
+
+ ); +} diff --git a/frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx b/frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx new file mode 100644 index 00000000..e1742606 --- /dev/null +++ b/frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx @@ -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({ + bot_token: '', + base_url: '', + }); + const [qq, setQQ] = React.useState({ + 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 ( + + + + {t('addChannel')} + +
+
+ + setName(e.target.value)} + /> +
+
+ + +
+ {type === 'telegram' ? ( + + ) : null} + {type === 'qq' ? : null} +
+ + + + +
+
+ ); +} diff --git a/frontend/app/(main)/admin/message-gateway/components/channel-card.tsx b/frontend/app/(main)/admin/message-gateway/components/channel-card.tsx new file mode 100644 index 00000000..62652942 --- /dev/null +++ b/frontend/app/(main)/admin/message-gateway/components/channel-card.tsx @@ -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 ( +
+
+
+

{channel.name}

+ + {typeLabel} + +
+
+ + {channel.enabled ? t('enabled') : t('disabled')} + + +
+
+
+ +
+ + + + {t('deleteConfirmTitle')} + + {t('deleteConfirmDescription')} + + + + {t('cancel')} + { + setConfirmOpen(false); + onDelete(); + }} + > + {t('delete')} + + + + +
+ ); +} diff --git a/frontend/app/(main)/admin/message-gateway/page.tsx b/frontend/app/(main)/admin/message-gateway/page.tsx new file mode 100644 index 00000000..fbb95093 --- /dev/null +++ b/frontend/app/(main)/admin/message-gateway/page.tsx @@ -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 ( + +
+
+ +
+

+ {t('pageTitle')} +

+

+ {t('pageDescription')} +

+
+
+ +
+ + {channelsQuery.isLoading ? ( + + ) : channelsQuery.isError ? ( + + ) : channels.length === 0 ? ( +
+

{t('emptyTitle')}

+

+ {t('emptyDescription')} +

+ +
+ ) : ( +
+ {channels.map((ch) => ( + + toggleMutation.mutate({ id: ch.id, enabled }) + } + onDelete={() => deleteMutation.mutate(ch.id)} + /> + ))} +
+ )} + + createMutation.mutate(data)} + /> +
+ ); +} diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 2afbdf05..7c8c6293 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -56,6 +56,7 @@ import { Home, Layers, LogOut, + MessagesSquare, Settings, ShieldCheck, Terminal, @@ -84,6 +85,7 @@ const adminItems: NavItem[] = [ { titleKey: 'storage', url: '/admin/files', icon: FolderOpen }, { titleKey: 'database', url: '/admin/database', icon: Database }, { titleKey: 'push', url: '/admin/push', icon: Bell }, + { titleKey: 'messageGateway', url: '/admin/message-gateway', icon: MessagesSquare }, { titleKey: 'logs', url: '/admin/logs', icon: Terminal }, { titleKey: 'system', url: '/admin/system', icon: ShieldCheck }, { titleKey: 'adminSettings', url: '/admin/settings', icon: Settings }, diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index fd2f0240..c1956438 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -23,6 +23,7 @@ import { import { AuthService } from './auth'; import { ConfigService } from './config'; import { DbManageService } from './db-manage'; +import { AdminMessageGatewayService } from './message-gateway'; import { PushService } from './push'; import { AdminUploadService, UploadService } from './upload'; import { UserService } from './user'; @@ -43,6 +44,7 @@ const services = { adminUpload: AdminUploadService, dbManage: DbManageService, push: PushService, + adminMessageGateway: AdminMessageGatewayService, } as const; export default services; @@ -167,6 +169,16 @@ export type { } from './db-manage'; export { PushService } from './push'; +export { AdminMessageGatewayService } from './message-gateway'; +export type { + MessageChannel, + MessageChannelDefinition, + CreateMessageChannelRequest, + UpdateMessageChannelRequest, + PublicMessageChannel, + MessageBinding, + BindMessageChannelRequest, +} from './message-gateway'; export type { PushEvent, PushHistory, diff --git a/frontend/lib/services/message-gateway/admin.service.ts b/frontend/lib/services/message-gateway/admin.service.ts new file mode 100644 index 00000000..9852dbf8 --- /dev/null +++ b/frontend/lib/services/message-gateway/admin.service.ts @@ -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 { + return this.get('/channels'); + } + + static async definitions(): Promise { + return this.get('/channels/definitions'); + } + + static async create( + data: CreateMessageChannelRequest, + ): Promise { + return this.post('/channels', data); + } + + static async update( + id: string, + data: UpdateMessageChannelRequest, + ): Promise { + return this.patch(`/channels/${id}`, data); + } + + static async remove(id: string): Promise { + return this.delete(`/channels/${id}`); + } + + static async test(id: string): Promise { + return this.post(`/channels/${id}/test`); + } +} diff --git a/frontend/lib/services/message-gateway/index.ts b/frontend/lib/services/message-gateway/index.ts new file mode 100644 index 00000000..a72eb34b --- /dev/null +++ b/frontend/lib/services/message-gateway/index.ts @@ -0,0 +1,5 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +export * from './types'; +export * from './admin.service'; diff --git a/frontend/lib/services/message-gateway/types.ts b/frontend/lib/services/message-gateway/types.ts new file mode 100644 index 00000000..02a9cd9c --- /dev/null +++ b/frontend/lib/services/message-gateway/types.ts @@ -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; +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8cdb2f2e..cfccee50 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -21,6 +21,7 @@ "storage": "Storage", "database": "Database", "push": "Notifications", + "messageGateway": "Messaging", "logs": "System Logs", "system": "System Config", "adminSettings": "System Settings", @@ -1021,6 +1022,45 @@ "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": { "pageTitle": "Database", "refreshData": "Refresh data", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 19fc88be..ba9401a9 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -21,6 +21,7 @@ "storage": "存储管理", "database": "数据管理", "push": "通知推送", + "messageGateway": "消息通道", "logs": "系统日志", "system": "系统配置", "adminSettings": "系统设置", @@ -1021,6 +1022,45 @@ "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": { "pageTitle": "数据管理", "refreshData": "刷新数据",