| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735 |
- import {
- Alert,
- Button,
- Checkbox,
- Col,
- Descriptions,
- Form,
- Input,
- InputNumber,
- Modal,
- Row,
- Select,
- Space,
- Switch,
- Table,
- Tag,
- Tooltip,
- Typography,
- message,
- } from 'antd';
- import {
- Bot,
- CirclePlus,
- KeyRound,
- Pencil,
- PlugZap,
- RefreshCw,
- Save,
- ShieldCheck,
- Trash2,
- } from 'lucide-react';
- import { useCallback, useEffect, useState } from 'react';
- import { PageHeader, Surface } from '@/components/Page';
- import { apiRequest, jsonOptions, setCsrfToken, setSelectedBotId } from '@/services/api';
- import type {
- AdminUser,
- BotPermissionDefinition,
- BotProfile,
- BotRole,
- TelegramSettings,
- } from '@/types';
- interface SystemSettings {
- web_address: string;
- bind_host: string;
- upload_limit_mb: number;
- supervisor_mode: boolean;
- bot: { connected: boolean; id: string; username: string; name: string };
- }
- interface BotFormValues {
- label: string;
- bot_token?: string;
- enabled: boolean;
- sudo_users_id?: string;
- log_group_id?: string;
- gban_log_group_id?: string;
- message_dump_chat?: string;
- role_ids: string[];
- }
- interface RoleFormValues {
- name: string;
- description?: string;
- permissions: string[];
- }
- const TELEGRAM_APP_URL = 'https://my.telegram.org/apps';
- const API_HASH_PATTERN = /^[A-Fa-f0-9]{32}$/;
- function runtimeTag(profile: BotProfile) {
- if (!profile.enabled) return <Tag>已停用</Tag>;
- if (!profile.ready_to_connect) return <Tag color="warning">配置未完成</Tag>;
- const state = profile.runtime?.state || 'stopped';
- const values: Record<string, { color: string; text: string }> = {
- running: { color: 'success', text: '运行中' },
- starting: { color: 'processing', text: '启动中' },
- stopping: { color: 'processing', text: '停止中' },
- failed: { color: 'error', text: '启动失败' },
- stopped: { color: 'default', text: '已停止' },
- };
- const current = values[state] || values.stopped;
- return (
- <Tooltip title={profile.runtime?.error || undefined}>
- <Tag color={current.color}>{current.text}</Tag>
- </Tooltip>
- );
- }
- export default function SettingsPage() {
- const [settings, setSettings] = useState<SystemSettings | null>(null);
- const [telegram, setTelegram] = useState<TelegramSettings | null>(null);
- const [editingBot, setEditingBot] = useState<BotProfile | null>(null);
- const [botModalOpen, setBotModalOpen] = useState(false);
- const [editingRole, setEditingRole] = useState<BotRole | null>(null);
- const [roleModalOpen, setRoleModalOpen] = useState(false);
- const [globalForm] = Form.useForm();
- const [botForm] = Form.useForm<BotFormValues>();
- const [roleForm] = Form.useForm<RoleFormValues>();
- const [passwordForm] = Form.useForm();
- const reloadTelegram = useCallback(async () => {
- const current = await apiRequest<TelegramSettings>('/settings/telegram');
- setTelegram(current);
- globalForm.setFieldsValue({ api_id: current.api_id || undefined, api_hash: '' });
- window.dispatchEvent(new Event('bot-profiles-changed'));
- }, [globalForm]);
- useEffect(() => {
- apiRequest<SystemSettings>('/settings').then(setSettings).catch((error) => message.error(error.message));
- reloadTelegram().catch((error) => message.error(error.message));
- }, [reloadTelegram]);
- const openBotModal = (profile?: BotProfile) => {
- const target = profile || null;
- setEditingBot(target);
- botForm.setFieldsValue(
- target
- ? {
- label: target.label,
- bot_token: '',
- enabled: target.enabled,
- sudo_users_id: target.sudo_users_id.join(' '),
- log_group_id: target.log_group_id,
- gban_log_group_id: target.gban_log_group_id,
- message_dump_chat: target.message_dump_chat,
- role_ids: target.role_ids || ['full_access'],
- }
- : {
- label: '',
- bot_token: '',
- enabled: true,
- sudo_users_id: '',
- log_group_id: '0',
- gban_log_group_id: '0',
- message_dump_chat: '0',
- role_ids: ['full_access'],
- },
- );
- setBotModalOpen(true);
- };
- const saveBot = (values: BotFormValues) => {
- Modal.confirm({
- title: editingBot ? '保存并重启该机器人?' : '创建并启动该机器人?',
- content: '启用且配置完整时,系统会立即连接 Telegram。',
- okText: '确认保存',
- cancelText: '取消',
- onOk: async () => {
- const path = editingBot ? `/bots/${editingBot.bot_id}` : '/bots';
- const method = editingBot ? 'PUT' : 'POST';
- const saved = await apiRequest<BotProfile>(
- path,
- jsonOptions(method, { ...values, confirm: true }),
- );
- setBotModalOpen(false);
- message.success(editingBot ? '机器人配置已更新' : '机器人已创建');
- await reloadTelegram();
- if (!editingBot) setSelectedBotId(saved.bot_id);
- },
- });
- };
- const openRoleModal = (role?: BotRole) => {
- const target = role || null;
- setEditingRole(target);
- roleForm.setFieldsValue(
- target
- ? {
- name: target.name,
- description: target.description,
- permissions: target.permissions,
- }
- : {
- name: '',
- description: '',
- permissions: [],
- },
- );
- setRoleModalOpen(true);
- };
- const saveRole = (values: RoleFormValues) => {
- Modal.confirm({
- title: editingRole ? '保存角色权限?' : '创建职责角色?',
- content: editingRole
- ? '使用该角色的机器人会自动重启并应用新权限。'
- : '创建后可将角色分配给一个或多个机器人。',
- okText: '确认保存',
- cancelText: '取消',
- onOk: async () => {
- const path = editingRole ? `/roles/${editingRole.role_id}` : '/roles';
- const method = editingRole ? 'PUT' : 'POST';
- await apiRequest<BotRole>(
- path,
- jsonOptions(method, { ...values, confirm: true }),
- );
- setRoleModalOpen(false);
- message.success(editingRole ? '角色权限已更新' : '职责角色已创建');
- await reloadTelegram();
- },
- });
- };
- const roleNames = new Map(
- (telegram?.roles || []).map((role) => [role.role_id, role.name]),
- );
- const permissionNames = new Map(
- (telegram?.permission_catalog || []).map((permission) => [
- permission.key,
- permission.name,
- ]),
- );
- const permissionGroups = (telegram?.permission_catalog || []).reduce<
- Record<string, BotPermissionDefinition[]>
- >((groups, permission) => {
- (groups[permission.group] ||= []).push(permission);
- return groups;
- }, {});
- const botColumns = [
- {
- title: '机器人',
- key: 'bot',
- render: (_: unknown, profile: BotProfile) => (
- <div>
- <strong>{profile.label}</strong>
- <div style={{ color: '#52606d', marginTop: 2 }}>
- {profile.identity?.username ? `@${profile.identity.username}` : '尚未验证身份'}
- </div>
- </div>
- ),
- },
- {
- title: '令牌',
- dataIndex: 'bot_token_configured',
- width: 96,
- render: (configured: boolean) => (
- <Tag color={configured ? 'success' : 'warning'}>{configured ? '已配置' : '未配置'}</Tag>
- ),
- },
- {
- title: '连接状态',
- key: 'runtime',
- width: 120,
- render: (_: unknown, profile: BotProfile) => runtimeTag(profile),
- },
- {
- title: '职责角色',
- dataIndex: 'role_ids',
- minWidth: 180,
- render: (roleIds: string[]) => (
- <Space size={[4, 4]} wrap>
- {(roleIds || []).map((roleId) => (
- <Tag key={roleId}>{roleNames.get(roleId) || roleId}</Tag>
- ))}
- {!roleIds?.length ? <Tag color="warning">未分配职责</Tag> : null}
- </Space>
- ),
- },
- {
- title: '媒体中转群',
- dataIndex: 'message_dump_chat',
- width: 150,
- render: (value: string) => (value && value !== '0' ? value : '未配置'),
- },
- {
- title: '操作',
- key: 'actions',
- width: 190,
- fixed: 'right' as const,
- render: (_: unknown, profile: BotProfile) => (
- <Space size={4}>
- <Tooltip title="验证机器人令牌">
- <Button
- aria-label="验证机器人令牌"
- icon={<PlugZap size={16} />}
- disabled={!profile.bot_token_configured}
- onClick={() =>
- Modal.confirm({
- title: `验证 ${profile.label} 的机器人令牌?`,
- okText: '开始验证',
- cancelText: '取消',
- onOk: async () => {
- const identity = await apiRequest<{ username: string }>(
- `/bots/${profile.bot_id}/test`,
- jsonOptions('POST', { confirm: true }),
- );
- message.success(`机器人令牌有效:@${identity.username}`);
- await reloadTelegram();
- },
- })
- }
- />
- </Tooltip>
- <Tooltip title="重启机器人">
- <Button
- aria-label="重启机器人"
- icon={<RefreshCw size={16} />}
- disabled={!profile.enabled || !profile.ready_to_connect}
- onClick={() =>
- Modal.confirm({
- title: `重启 ${profile.label}?`,
- okText: '确认重启',
- cancelText: '取消',
- onOk: async () => {
- await apiRequest(
- `/bots/${profile.bot_id}/restart`,
- jsonOptions('POST', { confirm: true }),
- );
- message.success('机器人正在重启');
- await reloadTelegram();
- },
- })
- }
- />
- </Tooltip>
- <Tooltip title="编辑机器人">
- <Button
- aria-label="编辑机器人"
- icon={<Pencil size={16} />}
- onClick={() => openBotModal(profile)}
- />
- </Tooltip>
- <Tooltip title="删除机器人">
- <Button
- danger
- aria-label="删除机器人"
- icon={<Trash2 size={16} />}
- onClick={() =>
- Modal.confirm({
- title: `删除 ${profile.label}?`,
- content: '工作进程会停止,群数据和操作日志不会被删除。',
- okText: '确认删除',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- await apiRequest(
- `/bots/${profile.bot_id}`,
- jsonOptions('DELETE', { confirm: true }),
- );
- message.success('机器人配置已删除');
- await reloadTelegram();
- },
- })
- }
- />
- </Tooltip>
- </Space>
- ),
- },
- ];
- const roleColumns = [
- {
- title: '角色',
- key: 'role',
- minWidth: 180,
- render: (_: unknown, role: BotRole) => (
- <div>
- <Space size={6}>
- <strong>{role.name}</strong>
- {role.builtin ? <Tag color="processing">内置</Tag> : <Tag>自定义</Tag>}
- </Space>
- <div style={{ color: '#52606d', marginTop: 4 }}>{role.description || '无说明'}</div>
- </div>
- ),
- },
- {
- title: '权限',
- dataIndex: 'permissions',
- minWidth: 360,
- render: (permissions: string[]) => (
- <Space size={[4, 4]} wrap>
- {permissions.map((permission) => (
- <Tag key={permission}>{permissionNames.get(permission) || permission}</Tag>
- ))}
- {!permissions.length ? <Tag color="warning">无权限</Tag> : null}
- </Space>
- ),
- },
- {
- title: '操作',
- key: 'actions',
- width: 110,
- fixed: 'right' as const,
- render: (_: unknown, role: BotRole) =>
- role.builtin ? null : (
- <Space size={4}>
- <Tooltip title="编辑角色">
- <Button
- aria-label={`编辑角色 ${role.name}`}
- icon={<Pencil size={16} />}
- onClick={() => openRoleModal(role)}
- />
- </Tooltip>
- <Tooltip title="删除角色">
- <Button
- danger
- aria-label={`删除角色 ${role.name}`}
- icon={<Trash2 size={16} />}
- onClick={() =>
- Modal.confirm({
- title: `删除角色 ${role.name}?`,
- content: '已分配给机器人的角色不能删除。',
- okText: '确认删除',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- await apiRequest(
- `/roles/${role.role_id}`,
- jsonOptions('DELETE', { confirm: true }),
- );
- message.success('职责角色已删除');
- await reloadTelegram();
- },
- })
- }
- />
- </Tooltip>
- </Space>
- ),
- },
- ];
- return (
- <>
- <PageHeader title="系统设置" />
- <Surface
- title="机器人配置中心"
- actions={
- <Button type="primary" icon={<CirclePlus size={16} />} onClick={() => openBotModal()}>
- 添加机器人
- </Button>
- }
- >
- <Form
- form={globalForm}
- layout="vertical"
- onFinish={(values) =>
- Modal.confirm({
- title: '更新 Telegram 应用接口凭据?',
- content: '所有启用的机器人会依次重启并应用新配置。',
- okText: '确认更新',
- cancelText: '取消',
- onOk: async () => {
- await apiRequest(
- '/settings/telegram',
- jsonOptions('PUT', { ...values, confirm: true }),
- );
- message.success('Telegram 应用接口配置已更新');
- await reloadTelegram();
- },
- })
- }
- >
- <Row gutter={16} align="bottom">
- <Col xs={24} md={8}>
- <Form.Item
- name="api_id"
- label="Telegram 应用编号(API ID)"
- extra={
- <span>
- 来自{' '}
- <a href={TELEGRAM_APP_URL} target="_blank" rel="noreferrer">
- my.telegram.org/apps
- </a>
- ,不是机器人编号。
- </span>
- }
- rules={[{ required: true, message: '请输入 Telegram 应用编号' }]}
- >
- <InputNumber min={1} precision={0} style={{ width: '100%' }} />
- </Form.Item>
- </Col>
- <Col xs={24} md={10}>
- <Form.Item
- name="api_hash"
- label="Telegram 应用密钥(API Hash)"
- extra="32 位十六进制字符串,不是机器人令牌。"
- rules={[
- {
- required: !telegram?.api_hash_configured,
- message: '请输入 Telegram 应用密钥',
- },
- {
- pattern: API_HASH_PATTERN,
- message: 'Telegram 应用密钥必须是 32 位十六进制字符串',
- },
- ]}
- >
- <Input.Password
- autoComplete="new-password"
- placeholder={
- telegram?.api_hash_configured ? '已配置,留空保持不变' : '请输入 Telegram 应用密钥'
- }
- />
- </Form.Item>
- </Col>
- <Col xs={24} md={6}>
- <Form.Item>
- <Button type="primary" htmlType="submit" icon={<Save size={16} />} block>
- 保存 API 配置
- </Button>
- </Form.Item>
- </Col>
- </Row>
- </Form>
- {!telegram?.api_ready ? (
- <Alert
- type="warning"
- showIcon
- message="Telegram 应用接口凭据尚未配置"
- description="填写 my.telegram.org/apps 提供的应用编号和应用密钥后,已启用的机器人才会启动。"
- />
- ) : null}
- <div className="table-wrap" style={{ marginTop: 16 }}>
- <Table<BotProfile>
- rowKey="bot_id"
- columns={botColumns}
- dataSource={telegram?.bots || []}
- pagination={false}
- scroll={{ x: 980 }}
- locale={{ emptyText: '尚未添加机器人' }}
- />
- </div>
- </Surface>
- <div style={{ height: 16 }} />
- <Surface
- title="职责角色"
- actions={
- <Button type="primary" icon={<CirclePlus size={16} />} onClick={() => openRoleModal()}>
- 新建角色
- </Button>
- }
- >
- <Alert
- type="info"
- showIcon
- message="机器人可同时分配多个角色,最终权限为所有角色权限的并集。"
- style={{ marginBottom: 16 }}
- />
- <div className="table-wrap">
- <Table<BotRole>
- rowKey="role_id"
- columns={roleColumns}
- dataSource={telegram?.roles || []}
- pagination={false}
- scroll={{ x: 760 }}
- />
- </div>
- </Surface>
- <div style={{ height: 16 }} />
- <div className="split-grid">
- <Surface title="运行信息">
- {settings ? (
- <Descriptions column={1} size="small" bordered>
- <Descriptions.Item label="本地地址">{settings.web_address}</Descriptions.Item>
- <Descriptions.Item label="监听地址">{settings.bind_host}</Descriptions.Item>
- <Descriptions.Item label="上传限制">{settings.upload_limit_mb} MB</Descriptions.Item>
- <Descriptions.Item label="运行模式">
- <Tag color={settings.supervisor_mode ? 'processing' : 'success'}>
- {settings.supervisor_mode ? '多机器人监管' : '机器人工作进程'}
- </Tag>
- </Descriptions.Item>
- </Descriptions>
- ) : null}
- </Surface>
- <Surface title="管理员密码" className="danger-zone">
- <Form
- form={passwordForm}
- layout="vertical"
- onFinish={async (values) => {
- const user = await apiRequest<AdminUser>(
- '/auth/password',
- jsonOptions('PUT', values),
- );
- setCsrfToken(user.csrf_token);
- passwordForm.resetFields();
- message.success('管理员密码已更新,其他会话已失效');
- }}
- >
- <Form.Item name="current_password" label="当前密码" rules={[{ required: true }]}>
- <Input.Password autoComplete="current-password" />
- </Form.Item>
- <Form.Item
- name="new_password"
- label="新密码"
- rules={[
- { required: true },
- { min: 10, message: '至少 10 个字符' },
- { pattern: /[A-Za-z]/, message: '至少包含一个字母' },
- { pattern: /\d/, message: '至少包含一个数字' },
- ]}
- >
- <Input.Password autoComplete="new-password" />
- </Form.Item>
- <Button type="primary" danger htmlType="submit" icon={<KeyRound size={16} />}>
- 修改密码
- </Button>
- </Form>
- </Surface>
- </div>
- <div style={{ height: 16 }} />
- <Surface title="安全策略">
- <Descriptions column={{ xs: 1, sm: 2, md: 3 }} size="small">
- <Descriptions.Item label="机器人凭据">
- <Bot size={16} /> 本地 0600 文件
- </Descriptions.Item>
- <Descriptions.Item label="密码摘要">
- <ShieldCheck size={16} /> scrypt + 独立盐
- </Descriptions.Item>
- <Descriptions.Item label="写操作">跨站请求防护校验</Descriptions.Item>
- </Descriptions>
- </Surface>
- <Modal
- title={editingBot ? `编辑 ${editingBot.label}` : '添加机器人'}
- open={botModalOpen}
- onCancel={() => setBotModalOpen(false)}
- onOk={() => botForm.submit()}
- okText="继续"
- cancelText="取消"
- destroyOnHidden
- >
- <Form form={botForm} layout="vertical" onFinish={saveBot} preserve={false}>
- <Form.Item name="label" label="机器人名称" rules={[{ required: true }, { max: 60 }]}>
- <Input maxLength={60} />
- </Form.Item>
- <Form.Item
- name="bot_token"
- label="机器人令牌"
- extra="填写 BotFather 提供的完整令牌,每个机器人单独配置。"
- rules={editingBot ? [] : [{ required: true }]}
- >
- <Input.Password
- autoComplete="new-password"
- placeholder={editingBot?.bot_token_configured ? '已配置,留空保持不变' : ''}
- />
- </Form.Item>
- <Form.Item name="enabled" label="启用" valuePropName="checked">
- <Switch />
- </Form.Item>
- <Form.Item
- name="role_ids"
- label="职责角色"
- extra="可多选;不选择角色时机器人只保持连接,不执行管理职责。"
- >
- <Select
- mode="multiple"
- allowClear
- placeholder="选择一个或多个职责角色"
- options={(telegram?.roles || []).map((role) => ({
- value: role.role_id,
- label: role.name,
- }))}
- />
- </Form.Item>
- <Form.Item name="sudo_users_id" label="超级管理员用户 ID">
- <Input placeholder="多个 ID 使用空格分隔" />
- </Form.Item>
- <Row gutter={12}>
- <Col xs={24} sm={12}>
- <Form.Item name="log_group_id" label="日志群 ID">
- <Input />
- </Form.Item>
- </Col>
- <Col xs={24} sm={12}>
- <Form.Item name="gban_log_group_id" label="封禁日志群 ID">
- <Input />
- </Form.Item>
- </Col>
- </Row>
- <Form.Item name="message_dump_chat" label="媒体中转群 ID">
- <Input />
- </Form.Item>
- </Form>
- </Modal>
- <Modal
- title={editingRole ? `编辑角色 ${editingRole.name}` : '新建职责角色'}
- open={roleModalOpen}
- onCancel={() => setRoleModalOpen(false)}
- onOk={() => roleForm.submit()}
- okText="继续"
- cancelText="取消"
- width={720}
- destroyOnHidden
- >
- <Form form={roleForm} layout="vertical" onFinish={saveRole} preserve={false}>
- <Form.Item
- name="name"
- label="角色名称"
- rules={[{ required: true, message: '请输入角色名称' }, { max: 60 }]}
- >
- <Input maxLength={60} />
- </Form.Item>
- <Form.Item name="description" label="角色说明" rules={[{ max: 200 }]}>
- <Input.TextArea maxLength={200} rows={2} />
- </Form.Item>
- <Form.Item name="permissions" label="职责权限">
- <Checkbox.Group style={{ width: '100%' }}>
- <Space direction="vertical" size={14} style={{ width: '100%' }}>
- {Object.entries(permissionGroups).map(([group, permissions]) => (
- <div key={group}>
- <Typography.Text strong>{group}</Typography.Text>
- <Row gutter={[12, 8]} style={{ marginTop: 8 }}>
- {permissions.map((permission) => (
- <Col xs={24} sm={12} key={permission.key}>
- <Checkbox value={permission.key}>
- <span>{permission.name}</span>
- <Typography.Text
- type="secondary"
- style={{ display: 'block', fontSize: 12 }}
- >
- {permission.description}
- </Typography.Text>
- </Checkbox>
- </Col>
- ))}
- </Row>
- </div>
- ))}
- </Space>
- </Checkbox.Group>
- </Form.Item>
- </Form>
- </Modal>
- </>
- );
- }
|