refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
9.76 KiB
RainbowBridge
import { useEffect, useState } from 'react'
import { Button, Badge, Select } from '@/components/atoms'
import { Modal } from '@/components/common/Modal'
import { DataTable, type DataColumn } from '@/components/common/DataTable'
import { showToast } from '@/stores/toastStore'
import {
  getDnsRules, addDnsRule, updateDnsRule, deleteDnsRule,
  getDnsBlacklist, getDnsBlockStats, addDnsBlacklist, deleteDnsBlacklist,
  type DnsRule, type DnsBlacklistItem, type DnsBlockCategory,
} from '@/lib/api'

const MATCH_LABELS = ['精确', '通配', '正则']
const MATCH_OPTIONS = [
  { value: '0', label: '精确匹配' },
  { value: '1', label: '通配匹配' },
  { value: '2', label: '正则匹配' },
]

export function DnsPage() {
  const [rules, setRules] = useState<DnsRule[]>([])
  const [blacklist, setBlacklist] = useState<DnsBlacklistItem[]>([])
  const [blockStats, setBlockStats] = useState<DnsBlockCategory[]>([])
  const [loading, setLoading] = useState(true)
  const [tab, setTab] = useState<'rules' | 'blacklist' | 'stats'>('rules')
  const [showAddRule, setShowAddRule] = useState(false)
  const [showAddBlack, setShowAddBlack] = useState(false)
  const [ruleForm, setRuleForm] = useState({ domain: '', ip: '', kind: 0 })
  const [blackForm, setBlackForm] = useState({ domain: '', category: '' })

  const fetchData = async () => {
    try {
      const [r, b, s] = await Promise.all([getDnsRules(), getDnsBlacklist(), getDnsBlockStats()])
      setRules(r); setBlacklist(b); setBlockStats(s)
    } catch { /* 静默 */ }
    setLoading(false)
  }

  useEffect(() => { fetchData() }, [])

  const ruleColumns: DataColumn<DnsRule>[] = [
    { key: 'domain', label: '域名', sortable: true, className: 'font-mono text-xs' },
    { key: 'ip', label: '目标 IP', sortable: true },
    {
      key: 'kind', label: '匹配', sortable: true,
      render: (r) => <span className="text-xs text-[var(--color-text-tertiary)]">{MATCH_LABELS[r.kind] ?? r.kind}</span>,
    },
    {
      key: 'enable', label: '状态', sortable: true,
      render: (r) => <Badge variant={r.enable ? 'success' : 'default'}>{r.enable ? '启用' : '禁用'}</Badge>,
    },
    {
      key: 'actions', label: '操作',
      render: (r) => (
        <div className="flex gap-1">
          <Button variant="ghost" size="sm" onClick={async () => {
            try { await updateDnsRule(r.id, { kind: r.kind, domain: r.domain, ip: r.ip }); await fetchData() }
            catch { showToast('error', '操作失败') }
          }}>{r.enable ? '禁用' : '启用'}</Button>
          <Button variant="ghost" size="sm" onClick={async () => {
            try { await deleteDnsRule(r.id); showToast('success', '已删除'); await fetchData() }
            catch { showToast('error', '删除失败') }
          }}>删除</Button>
        </div>
      ),
    },
  ]

  const blackColumns: DataColumn<DnsBlacklistItem>[] = [
    { key: 'domain', label: '域名', sortable: true, className: 'font-mono text-xs' },
    { key: 'category', label: '分类', render: (b) => <Badge variant="warning">{b.category}</Badge> },
    { key: 'source', label: '来源', render: (b) => <span className="text-xs text-[var(--color-text-tertiary)]">{b.source}</span> },
    {
      key: 'actions', label: '操作',
      render: (b) => (
        <Button variant="ghost" size="sm" onClick={async () => {
          try { await deleteDnsBlacklist(b.id); showToast('success', '已删除'); await fetchData() }
          catch { showToast('error', '删除失败') }
        }}>删除</Button>
      ),
    },
  ]

  return (
    <div className="p-6">
      <h1 className="text-lg font-bold text-[var(--color-text-primary)] mb-4">DNS 管控</h1>

      {/* Tab 切换 */}
      <div className="flex gap-1 mb-4 bg-[var(--color-surface-2)] rounded-lg p-1 w-fit">
        {(['rules', 'blacklist', 'stats'] as const).map((t) => (
          <button
            key={t}
            onClick={() => setTab(t)}
            className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
              tab === t
                ? 'bg-[var(--color-surface-0)] text-[var(--color-text-primary)] shadow-sm'
                : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
            }`}
          >
            {t === 'rules' ? '域名规则' : t === 'blacklist' ? '黑名单' : '拦截统计'}
          </button>
        ))}
      </div>

      {tab === 'rules' && (
        <div>
          <div className="flex items-center justify-between mb-3">
            <h2 className="text-sm font-semibold text-[var(--color-text-primary)]">域名规则</h2>
            <Button size="sm" variant="soft" onClick={() => setShowAddRule(true)}>添加规则</Button>
          </div>
          <DataTable data={rules} columns={ruleColumns} rowKey={(r) => r.id} searchFields={['domain']} searchPlaceholder="搜索域名…" loading={loading} emptyText="暂无规则" />
        </div>
      )}

      {tab === 'blacklist' && (
        <div>
          <div className="flex items-center justify-between mb-3">
            <h2 className="text-sm font-semibold text-[var(--color-text-primary)]">DNS 黑名单</h2>
            <Button size="sm" variant="soft" onClick={() => setShowAddBlack(true)}>添加域名</Button>
          </div>
          <DataTable data={blacklist} columns={blackColumns} rowKey={(b) => b.id} searchFields={['domain', 'category']} searchPlaceholder="搜索域名 / 分类…" loading={loading} emptyText="暂无黑名单" />
        </div>
      )}

      {tab === 'stats' && (
        <div className="glass-panel p-5">
          <h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-4">拦截统计</h2>
          {blockStats.length === 0 ? (
            <p className="text-sm text-[var(--color-text-tertiary)] text-center py-8">暂无拦截数据</p>
          ) : (
            <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
              {blockStats.map((s) => (
                <div key={s.category} className="p-3 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)] text-center">
                  <p className="text-2xl font-bold text-[var(--color-brand-500)]">{s.count}</p>
                  <p className="text-xs text-[var(--color-text-tertiary)] mt-1">{s.category}</p>
                </div>
              ))}
            </div>
          )}
        </div>
      )}

      {/* 添加规则弹窗 */}
      <Modal open={showAddRule} onClose={() => setShowAddRule(false)} maxWidth="max-w-sm">
        <div className="p-6">
          <h3 className="text-sm font-semibold mb-4 text-[var(--color-text-primary)]">添加 DNS 规则</h3>
          <div className="space-y-3">
            <InputField label="域名 *" value={ruleForm.domain} onChange={(v) => setRuleForm((f) => ({ ...f, domain: v }))} placeholder="example.com" />
            <InputField label="目标 IP *" value={ruleForm.ip} onChange={(v) => setRuleForm((f) => ({ ...f, ip: v }))} placeholder="192.168.1.1" />
            <div>
              <label className="text-xs text-[var(--color-text-secondary)]">匹配模式</label>
              <Select options={MATCH_OPTIONS} value={String(ruleForm.kind)} onChange={(v) => setRuleForm((f) => ({ ...f, kind: Number(v) }))} className="mt-1" />
            </div>
          </div>
          <div className="flex justify-end gap-2 mt-4">
            <Button variant="secondary" size="sm" onClick={() => setShowAddRule(false)}>取消</Button>
            <Button variant="primary" size="sm" onClick={async () => {
              if (!ruleForm.domain || !ruleForm.ip) { showToast('warning', '域名和 IP 为必填'); return }
              try { await addDnsRule(ruleForm); showToast('success', '规则已添加'); setShowAddRule(false); setRuleForm({ domain: '', ip: '', kind: 0 }); await fetchData() }
              catch { showToast('error', '添加失败') }
            }}>确定</Button>
          </div>
        </div>
      </Modal>

      {/* 添加黑名单弹窗 */}
      <Modal open={showAddBlack} onClose={() => setShowAddBlack(false)} maxWidth="max-w-sm">
        <div className="p-6">
          <h3 className="text-sm font-semibold mb-4 text-[var(--color-text-primary)]">添加黑名单域名</h3>
          <div className="space-y-3">
            <InputField label="域名 *" value={blackForm.domain} onChange={(v) => setBlackForm((f) => ({ ...f, domain: v }))} placeholder="example.com" />
            <InputField label="分类" value={blackForm.category} onChange={(v) => setBlackForm((f) => ({ ...f, category: v }))} placeholder="如 广告" />
          </div>
          <div className="flex justify-end gap-2 mt-4">
            <Button variant="secondary" size="sm" onClick={() => setShowAddBlack(false)}>取消</Button>
            <Button variant="primary" size="sm" onClick={async () => {
              if (!blackForm.domain) { showToast('warning', '域名为必填'); return }
              try { await addDnsBlacklist(blackForm); showToast('success', '已添加'); setShowAddBlack(false); setBlackForm({ domain: '', category: '' }); await fetchData() }
              catch { showToast('error', '添加失败') }
            }}>确定</Button>
          </div>
        </div>
      </Modal>
    </div>
  )
}

function InputField({ label, value, onChange, placeholder }: {
  label: string; value: string; onChange: (v: string) => void; placeholder?: string
}) {
  return (
    <label className="block">
      <span className="text-xs text-[var(--color-text-secondary)]">{label}</span>
      <input
        value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
        className="mt-1 w-full px-3 py-2 text-sm rounded-lg border border-[var(--color-border-default)] bg-[var(--color-surface-0)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-brand-500)]/40"
      />
    </label>
  )
}