Переглянути джерело

feat(web): TG protocol-number import UI with webkitdirectory upload

Also fix Settings.tsx ref type (HTMLInputElement → InputRef) to unblock TS build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 тижнів тому
батько
коміт
45a64c4f47
2 змінених файлів з 601 додано та 0 видалено
  1. 327 0
      web/src/pages/Settings.tsx
  2. 274 0
      web/src/pages/TgAccounts.tsx

+ 327 - 0
web/src/pages/Settings.tsx

@@ -0,0 +1,327 @@
+import { useEffect, useState, useRef } from 'react'
+import { Card, Form, Input, Button, Tag, Space, message, Popconfirm, Typography, Row, Col, Collapse, Switch, InputNumber, Tooltip, Tabs, Descriptions, Modal, type InputRef } from 'antd'
+import { PlusOutlined, DeleteOutlined, UndoOutlined, SaveOutlined, QuestionCircleOutlined, DownloadOutlined } from '@ant-design/icons'
+import { getGradingConfig, updateGradingConfig, resetGradingConfig, getSystemHealth, getBackupStats, type GradingConfig, type LevelDef, type GradeRule, type SystemHealth } from '../api'
+import { useAppStore } from '../store'
+
+const { Text, Title } = Typography
+const { TextArea } = Input
+
+function GradingTab() {
+  const [config, setConfig] = useState<GradingConfig | null>(null)
+  const [loading, setLoading] = useState(false)
+  const [saving, setSaving] = useState(false)
+  const { hasAction } = useAppStore()
+  const canEdit = hasAction('setting_manage')
+
+  const fetchConfig = async () => {
+    setLoading(true)
+    try {
+      const res = await getGradingConfig()
+      setConfig(res.data)
+    } catch {
+      message.error('加载设置失败')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => { fetchConfig() }, [])
+
+  const handleSave = async () => {
+    if (!config) return
+    setSaving(true)
+    try {
+      await updateGradingConfig(config)
+      message.success('设置已保存,下次清洗时生效')
+    } catch {
+      message.error('保存失败')
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  const handleReset = async () => {
+    try {
+      const res = await resetGradingConfig()
+      setConfig(res.data)
+      message.success('已恢复默认设置')
+    } catch {
+      message.error('重置失败')
+    }
+  }
+
+  const updateLevel = (idx: number, patch: Partial<LevelDef>) => {
+    if (!config) return
+    const levels = [...config.levels]
+    levels[idx] = { ...levels[idx], ...patch }
+    setConfig({ ...config, levels })
+  }
+
+  const addLevel = () => {
+    if (!config) return
+    const newLevel: LevelDef = { key: `Level${config.levels.length + 1}`, label: '新等级', color: 'default', description: '', rules: [] }
+    setConfig({ ...config, levels: [...config.levels, newLevel] })
+  }
+
+  const removeLevel = (idx: number) => {
+    if (!config || config.levels.length <= 1) return
+    setConfig({ ...config, levels: config.levels.filter((_, i) => i !== idx) })
+  }
+
+  const addRule = (levelIdx: number) => {
+    if (!config) return
+    const levels = [...config.levels]
+    levels[levelIdx] = { ...levels[levelIdx], rules: [...levels[levelIdx].rules, {}] }
+    setConfig({ ...config, levels })
+  }
+
+  const removeRule = (levelIdx: number, ruleIdx: number) => {
+    if (!config) return
+    const levels = [...config.levels]
+    levels[levelIdx] = { ...levels[levelIdx], rules: levels[levelIdx].rules.filter((_, i) => i !== ruleIdx) }
+    setConfig({ ...config, levels })
+  }
+
+  const updateRule = (levelIdx: number, ruleIdx: number, patch: Partial<GradeRule>) => {
+    if (!config) return
+    const levels = [...config.levels]
+    const rules = [...levels[levelIdx].rules]
+    rules[ruleIdx] = { ...rules[ruleIdx], ...patch }
+    levels[levelIdx] = { ...levels[levelIdx], rules }
+    setConfig({ ...config, levels })
+  }
+
+  const updateIndustryKeywords = (industry: string, text: string) => {
+    if (!config) return
+    const keywords = text.split(/[,,\n]/).map(s => s.trim()).filter(Boolean)
+    setConfig({ ...config, industry_keywords: { ...config.industry_keywords, [industry]: keywords } })
+  }
+
+  const [addIndustryVisible, setAddIndustryVisible] = useState(false)
+  const [newIndustryName, setNewIndustryName] = useState('')
+  const industryInputRef = useRef<InputRef>(null)
+
+  const addIndustry = () => {
+    setNewIndustryName('')
+    setAddIndustryVisible(true)
+    setTimeout(() => industryInputRef.current?.focus(), 100)
+  }
+
+  const confirmAddIndustry = () => {
+    const name = newIndustryName.trim()
+    if (!name || !config) return
+    if (config.industry_keywords[name] !== undefined) {
+      message.warning('该行业已存在')
+      return
+    }
+    setConfig({ ...config, industry_keywords: { ...config.industry_keywords, [name]: [] } })
+    setAddIndustryVisible(false)
+    setNewIndustryName('')
+  }
+
+  const removeIndustry = (industry: string) => {
+    if (!config) return
+    const kw = { ...config.industry_keywords }
+    delete kw[industry]
+    setConfig({ ...config, industry_keywords: kw })
+  }
+
+  if (loading || !config) return <div>加载中...</div>
+
+  const colorOptions = ['red', 'orange', 'blue', 'green', 'purple', 'magenta', 'gold', 'cyan', 'default']
+
+  return (
+    <div>
+      {canEdit && (
+        <Row justify="end" style={{ marginBottom: 16 }}>
+          <Space>
+            <Popconfirm title="确认恢复默认设置?" onConfirm={handleReset}>
+              <Button icon={<UndoOutlined />}>恢复默认</Button>
+            </Popconfirm>
+            <Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>保存设置</Button>
+          </Space>
+        </Row>
+      )}
+
+      <Card title="商户等级定义" style={{ marginBottom: 16 }}>
+        <Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+          等级从上到下优先匹配,第一个满足任意规则的等级生效。最后一个等级为兜底。
+        </Text>
+        {config.levels.map((level, levelIdx) => (
+          <Card key={levelIdx} size="small" style={{ marginBottom: 12, borderLeft: `4px solid ${getAntdColor(level.color)}` }}
+            title={<Space><Tag color={level.color}>{level.label || level.key}</Tag><Text type="secondary">标识: {level.key}</Text></Space>}
+            extra={config.levels.length > 1 && canEdit && (
+              <Popconfirm title="确认删除?" onConfirm={() => removeLevel(levelIdx)}>
+                <Button size="small" danger icon={<DeleteOutlined />} />
+              </Popconfirm>
+            )}>
+            <Row gutter={12} style={{ marginBottom: 8 }}>
+              <Col span={4}><Form.Item label="标识" style={{ marginBottom: 0 }}><Input size="small" value={level.key} disabled={!canEdit} onChange={e => updateLevel(levelIdx, { key: e.target.value })} /></Form.Item></Col>
+              <Col span={4}><Form.Item label="名称" style={{ marginBottom: 0 }}><Input size="small" value={level.label} disabled={!canEdit} onChange={e => updateLevel(levelIdx, { label: e.target.value })} /></Form.Item></Col>
+              <Col span={4}><Form.Item label="颜色" style={{ marginBottom: 0 }}>
+                <Space wrap size={4}>{colorOptions.map(c => (
+                  <Tag key={c} color={c} style={{ cursor: canEdit ? 'pointer' : 'default', border: level.color === c ? '2px solid #333' : undefined }}
+                    onClick={() => canEdit && updateLevel(levelIdx, { color: c })}>{c === level.color ? '✓' : ' '}</Tag>
+                ))}</Space></Form.Item></Col>
+              <Col span={12}><Form.Item label="说明" style={{ marginBottom: 0 }}><Input size="small" value={level.description} disabled={!canEdit} onChange={e => updateLevel(levelIdx, { description: e.target.value })} /></Form.Item></Col>
+            </Row>
+            <Text strong style={{ fontSize: 12 }}>匹配规则 <Tooltip title="规则间是【或】关系,规则内是【且】关系"><QuestionCircleOutlined /></Tooltip></Text>
+            {level.rules.length === 0 && <div style={{ padding: '4px 0', color: '#999', fontSize: 12 }}>兜底等级</div>}
+            {level.rules.map((rule, ruleIdx) => (
+              <div key={ruleIdx} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '4px 0', borderBottom: '1px solid #f5f5f5' }}>
+                <Text type="secondary" style={{ width: 30, fontSize: 12 }}>#{ruleIdx + 1}</Text>
+                <Switch size="small" checkedChildren="行业" unCheckedChildren="行业" checked={!!rule.has_industry} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_industry: v || undefined })} />
+                <Switch size="small" checkedChildren="网站" unCheckedChildren="网站" checked={!!rule.has_website} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_website: v || undefined })} />
+                <Switch size="small" checkedChildren="邮箱" unCheckedChildren="邮箱" checked={!!rule.has_email} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_email: v || undefined })} />
+                <Switch size="small" checkedChildren="电话" unCheckedChildren="电话" checked={!!rule.has_phone} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { has_phone: v || undefined })} />
+                <Space size={4}><Text style={{ fontSize: 12 }}>来源≥</Text><InputNumber size="small" min={1} max={99} style={{ width: 50 }} value={rule.min_source_count} disabled={!canEdit} onChange={v => updateRule(levelIdx, ruleIdx, { min_source_count: v || undefined })} /></Space>
+                {canEdit && <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => removeRule(levelIdx, ruleIdx)} />}
+              </div>
+            ))}
+            {canEdit && <Button size="small" type="dashed" icon={<PlusOutlined />} onClick={() => addRule(levelIdx)} style={{ marginTop: 4 }}>添加规则</Button>}
+          </Card>
+        ))}
+        {canEdit && <Button type="dashed" block icon={<PlusOutlined />} onClick={addLevel}>添加等级</Button>}
+      </Card>
+
+      <Card title="行业关键词">
+        <Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+          商户名称或原始内容中包含任意一个关键词即匹配该行业。
+        </Text>
+        <Collapse items={Object.entries(config.industry_keywords).map(([industry, keywords]) => ({
+          key: industry,
+          label: <Space><Tag color="blue">{industry}</Tag><Text type="secondary">{keywords.length} 个关键词</Text></Space>,
+          extra: canEdit && (
+            <Popconfirm title={`删除行业「${industry}」?`} onConfirm={(e) => { e?.stopPropagation(); removeIndustry(industry) }}>
+              <Button size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()} />
+            </Popconfirm>
+          ),
+          children: <TextArea rows={3} value={keywords.join(', ')} disabled={!canEdit} onChange={e => updateIndustryKeywords(industry, e.target.value)} placeholder="逗号或换行分隔" />,
+        }))} />
+        {canEdit && <Button type="dashed" block icon={<PlusOutlined />} onClick={addIndustry} style={{ marginTop: 12 }}>添加行业</Button>}
+      </Card>
+
+      <Modal
+        title="添加行业"
+        open={addIndustryVisible}
+        onOk={confirmAddIndustry}
+        onCancel={() => setAddIndustryVisible(false)}
+        okText="确定"
+        cancelText="取消"
+      >
+        <Input
+          ref={industryInputRef}
+          placeholder="输入行业名称"
+          value={newIndustryName}
+          onChange={e => setNewIndustryName(e.target.value)}
+          onPressEnter={confirmAddIndustry}
+          style={{ marginTop: 8 }}
+        />
+      </Modal>
+    </div>
+  )
+}
+
+function SystemInfoTab() {
+  const [health, setHealth] = useState<SystemHealth | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [backupStats, setBackupStats] = useState<{ table: string; rows: number }[]>([])
+
+  useEffect(() => {
+    Promise.all([
+      getSystemHealth().then(r => setHealth(r.data)).catch(() => {}),
+      getBackupStats().then(r => setBackupStats(r.data || [])).catch(() => {}),
+    ]).finally(() => setLoading(false))
+  }, [])
+
+  if (loading) return <div>加载中...</div>
+  if (!health) return <div>无法获取系统信息</div>
+
+  const tgAccounts = health.tg_accounts || {}
+  const tasks24h = health.tasks_24h || {}
+
+  return (
+    <div>
+      <Card title="系统状态" size="small" style={{ marginBottom: 16 }}>
+        <Descriptions column={2} size="small">
+          <Descriptions.Item label="MySQL">
+            <Tag color={health.mysql.status === 'ok' ? 'green' : 'red'}>{health.mysql.status}</Tag>
+            {health.mysql.status === 'ok' && <Text type="secondary"> 连接 {health.mysql.in_use}/{health.mysql.max_open}</Text>}
+          </Descriptions.Item>
+          <Descriptions.Item label="Redis">
+            <Tag color={health.redis.status === 'ok' ? 'green' : 'red'}>{health.redis.status}</Tag>
+          </Descriptions.Item>
+        </Descriptions>
+      </Card>
+
+      <Card title="TG 账号状态" size="small" style={{ marginBottom: 16 }}>
+        <Descriptions column={3} size="small">
+          {Object.entries(tgAccounts).map(([status, count]) => (
+            <Descriptions.Item key={status} label={status}>
+              <Text strong>{count as number}</Text>
+            </Descriptions.Item>
+          ))}
+        </Descriptions>
+      </Card>
+
+      <Card title="最近24小时任务" size="small" style={{ marginBottom: 16 }}>
+        <Descriptions column={3} size="small">
+          {Object.entries(tasks24h).map(([status, count]) => (
+            <Descriptions.Item key={status} label={status}>
+              <Tag color={status === 'completed' ? 'green' : status === 'failed' ? 'red' : 'blue'}>{count as number}</Tag>
+            </Descriptions.Item>
+          ))}
+        </Descriptions>
+      </Card>
+
+      <Card title="数据量" size="small" style={{ marginBottom: 16 }}>
+        <Descriptions column={3} size="small">
+          <Descriptions.Item label="原始商户">{health.data.merchants_raw.toLocaleString()}</Descriptions.Item>
+          <Descriptions.Item label="清洗商户">{health.data.merchants_clean.toLocaleString()}</Descriptions.Item>
+          <Descriptions.Item label="任务详情">{health.data.task_details.toLocaleString()}</Descriptions.Item>
+        </Descriptions>
+      </Card>
+
+      <Card title="数据备份" size="small">
+        <div style={{ marginBottom: 12 }}>
+          <Text type="secondary">导出系统核心数据为 JSON 文件(商户、关键词、频道、定时任务、用户、权限配置)。</Text>
+        </div>
+        {backupStats.length > 0 && (
+          <div style={{ marginBottom: 12 }}>
+            <Row gutter={[8, 4]}>
+              {backupStats.map(s => (
+                <Col span={6} key={s.table}>
+                  <Text style={{ fontSize: 12 }}>{s.table}: <Text strong>{s.rows.toLocaleString()}</Text></Text>
+                </Col>
+              ))}
+            </Row>
+          </div>
+        )}
+        <Button type="primary" icon={<DownloadOutlined />}
+          onClick={() => window.open('/api/v1/backup/export', '_blank')}>
+          下载备份
+        </Button>
+      </Card>
+    </div>
+  )
+}
+
+export default function Settings() {
+  const { isAdmin } = useAppStore()
+
+  const tabs = [
+    { key: 'grading', label: '分级设置', children: <GradingTab /> },
+    ...(isAdmin() ? [{ key: 'system', label: '系统信息', children: <SystemInfoTab /> }] : []),
+  ]
+
+  return <Tabs defaultActiveKey="grading" items={tabs} />
+}
+
+function getAntdColor(color: string): string {
+  const map: Record<string, string> = {
+    red: '#ff4d4f', orange: '#fa8c16', blue: '#1890ff', green: '#52c41a',
+    purple: '#722ed1', magenta: '#eb2f96', gold: '#faad14', cyan: '#13c2c2',
+  }
+  return map[color] || '#d9d9d9'
+}

+ 274 - 0
web/src/pages/TgAccounts.tsx

@@ -0,0 +1,274 @@
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { Table, Tag, Button, Modal, Form, Input, InputNumber, Switch, Space, message, Popconfirm, Tooltip } from 'antd'
+import { PlusOutlined, DeleteOutlined, CheckCircleOutlined, CloudUploadOutlined, EyeOutlined } from '@ant-design/icons'
+import api from '../api/client'
+
+interface TgAccount {
+  id: number
+  phone: string
+  session_file: string
+  app_id: number
+  app_hash: string
+  remark: string
+  enabled: boolean
+  status: string
+  device?: string
+  app_version?: string
+  sdk?: string
+  import_status?: string
+  source?: string
+  first_name?: string
+  last_name?: string
+  tg_username?: string
+  created_at: string
+}
+
+const statusColors: Record<string, string> = {
+  idle: 'default',
+  online: 'green',
+  cooling: 'orange',
+  dead: 'red',
+}
+
+const sourceColors: Record<string, string> = {
+  protocol: 'purple',
+  manual: 'default',
+}
+
+const importStatusColors: Record<string, string> = {
+  ok: 'green',
+  session_invalid: 'red',
+  dead: 'orange',
+}
+
+export default function TgAccounts() {
+  const [accounts, setAccounts] = useState<TgAccount[]>([])
+  const [loading, setLoading] = useState(false)
+  const [manualOpen, setManualOpen] = useState(false)
+  const [importOpen, setImportOpen] = useState(false)
+  const [importing, setImporting] = useState(false)
+  const [form] = Form.useForm()
+  const dirInputRef = useRef<HTMLInputElement | null>(null)
+
+  const fetchAccounts = useCallback(async () => {
+    setLoading(true)
+    try {
+      const res = await api.get('/tg-accounts')
+      setAccounts(res.data || [])
+    } catch { message.error('获取TG账号列表失败') }
+    finally { setLoading(false) }
+  }, [])
+
+  useEffect(() => { fetchAccounts() }, [fetchAccounts])
+
+  const handleManualCreate = async () => {
+    try {
+      const values = await form.validateFields()
+      await api.post('/tg-accounts', values)
+      message.success('TG账号已添加')
+      setManualOpen(false)
+      fetchAccounts()
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.response?.data?.message || '添加失败')
+    }
+  }
+
+  const handleImport = async () => {
+    const files = dirInputRef.current?.files
+    if (!files || files.length === 0) {
+      message.warning('请先选择协议号文件夹')
+      return
+    }
+    const fd = new FormData()
+    for (let i = 0; i < files.length; i++) {
+      const f = files[i]
+      // The third arg preserves webkitRelativePath as the multipart filename.
+      fd.append('files', f, (f as any).webkitRelativePath || f.name)
+    }
+    setImporting(true)
+    try {
+      const res = await api.post('/tg-accounts/import', fd, {
+        headers: { 'Content-Type': 'multipart/form-data' },
+      })
+      const tr = res.data?.test_result
+      if (tr?.status === 'ok') message.success('导入成功并测试通过')
+      else if (tr?.status === 'cooling') message.warning(`导入成功,但账号冷却中:${tr.message}`)
+      else message.error(`导入成功但连接失败:${tr?.error || '未知错误'}(账号已禁用)`)
+      setImportOpen(false)
+      fetchAccounts()
+    } catch (err: any) {
+      message.error(err?.response?.data?.message || '导入失败')
+    } finally {
+      setImporting(false)
+    }
+  }
+
+  const handleToggle = async (acc: TgAccount, enabled: boolean) => {
+    await api.put(`/tg-accounts/${acc.id}`, { enabled })
+    message.success('状态已更新')
+    fetchAccounts()
+  }
+
+  const [testingId, setTestingId] = useState<number | null>(null)
+
+  const handleTest = async (id: number) => {
+    setTestingId(id)
+    try {
+      const res = await api.post(`/tg-accounts/${id}/test`)
+      const data = res.data as { status: string; message?: string; error?: string }
+      if (data.status === 'ok') message.success(data.message || '连接成功')
+      else if (data.status === 'cooling') message.warning(data.message || 'FloodWait 冷却中')
+      else message.error(`连接失败: ${data.error || '未知错误'}`)
+    } catch {
+      message.error('测试请求失败')
+    }
+    setTestingId(null)
+  }
+
+  const handleReveal2FA = async (id: number) => {
+    try {
+      const res = await api.post(`/tg-accounts/${id}/reveal-2fa`)
+      Modal.info({
+        title: '2FA 密码',
+        content: res.data?.password ?? '(空)',
+      })
+    } catch (err: any) {
+      message.error(err?.response?.data?.message || '无法获取 2FA')
+    }
+  }
+
+  const handleDelete = async (id: number) => {
+    await api.delete(`/tg-accounts/${id}`)
+    message.success('已删除')
+    fetchAccounts()
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', width: 60 },
+    { title: '手机号', dataIndex: 'phone' },
+    {
+      title: '来源',
+      dataIndex: 'source',
+      width: 80,
+      render: (v: string) => v ? <Tag color={sourceColors[v] ?? 'default'}>{v === 'protocol' ? '协议号' : '手填'}</Tag> : '-',
+    },
+    {
+      title: '设备',
+      dataIndex: 'device',
+      width: 160,
+      ellipsis: true,
+      render: (v: string) => v ? <Tooltip title={v}>{v}</Tooltip> : '-',
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      render: (v: string) => <Tag color={statusColors[v] ?? 'default'}>{v}</Tag>,
+    },
+    {
+      title: '导入状态',
+      dataIndex: 'import_status',
+      render: (v: string) => v ? <Tag color={importStatusColors[v] ?? 'default'}>{v}</Tag> : '-',
+    },
+    { title: '备注', dataIndex: 'remark', render: (v: string) => v || '-' },
+    {
+      title: '启用',
+      dataIndex: 'enabled',
+      render: (v: boolean, record: TgAccount) => (
+        <Switch checked={v} onChange={(c) => handleToggle(record, c)} checkedChildren="启用" unCheckedChildren="禁用" />
+      ),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 260,
+      render: (_: unknown, record: TgAccount) => (
+        <Space>
+          <Button
+            size="small"
+            icon={<CheckCircleOutlined />}
+            loading={testingId === record.id}
+            onClick={() => handleTest(record.id)}
+          >
+            测试
+          </Button>
+          <Button size="small" icon={<EyeOutlined />} onClick={() => handleReveal2FA(record.id)}>2FA</Button>
+          <Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
+            <Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      <Space style={{ marginBottom: 16 }}>
+        <Button
+          type="primary"
+          icon={<CloudUploadOutlined />}
+          onClick={() => setImportOpen(true)}
+        >
+          导入协议号
+        </Button>
+        <Button
+          icon={<PlusOutlined />}
+          onClick={() => { form.resetFields(); setManualOpen(true) }}
+        >
+          手填添加
+        </Button>
+      </Space>
+
+      <Table dataSource={accounts} columns={columns} rowKey="id" loading={loading} pagination={false} />
+
+      <Modal
+        title="导入协议号"
+        open={importOpen}
+        onOk={handleImport}
+        confirmLoading={importing}
+        onCancel={() => setImportOpen(false)}
+        okText="导入"
+        width={500}
+      >
+        <div style={{ marginTop: 16 }}>
+          <p>选择一个协议号目录(如 <code>D:\spider\tgs\13252753163\</code>),目录下应包含 <code>&lt;phone&gt;.json</code>、<code>&lt;phone&gt;.session</code>、<code>2fa_password.txt</code> 和(可选) <code>tdata/</code>。</p>
+          <input
+            ref={dirInputRef}
+            type="file"
+            multiple
+            /* @ts-expect-error non-standard HTML5 folder picker */
+            webkitdirectory=""
+            directory=""
+          />
+        </div>
+      </Modal>
+
+      <Modal
+        title="手填 TG 账号"
+        open={manualOpen}
+        onOk={handleManualCreate}
+        onCancel={() => setManualOpen(false)}
+        okText="添加"
+        width={500}
+      >
+        <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
+            <Input placeholder="+86xxxxxxxxx" />
+          </Form.Item>
+          <Form.Item name="session_file" label="Session文件路径" rules={[{ required: true }]}>
+            <Input placeholder="sessions/+86xxxxxxxxx.session" />
+          </Form.Item>
+          <Form.Item name="app_id" label="App ID" rules={[{ required: true }]}>
+            <InputNumber style={{ width: '100%' }} placeholder="从 my.telegram.org 获取" />
+          </Form.Item>
+          <Form.Item name="app_hash" label="App Hash" rules={[{ required: true }]}>
+            <Input placeholder="从 my.telegram.org 获取" />
+          </Form.Item>
+          <Form.Item name="remark" label="备注">
+            <Input placeholder="可选" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}