refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
21.39 KiB
RainbowBridge
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/atoms'
import { Icon } from '@/components/common/Icon'
import { ScrollArea } from '@/components/common/ScrollArea'
import { useSettingsStore, type ThemeMode } from '@/stores/settingsStore'
import { getSystemInfo, logout, type SystemInfo, getUpgradeStatus, checkUpgrade, installUpgrade, type UpgradeStatus } from '@/lib/api'
import { useAuth } from '@/App'
import { showToast } from '@/stores/toastStore'

type SettingsTab = 'general' | 'appearance' | 'diagnostics' | 'upgrade' | 'about'

interface TabItem {
  id: SettingsTab
  icon: string
  label: string
}

const tabs: TabItem[] = [
  { id: 'general', icon: 'tune', label: '常规' },
  { id: 'appearance', icon: 'palette', label: '外观' },
  { id: 'diagnostics', icon: 'network_check', label: '诊断' },
  { id: 'upgrade', icon: 'system_update', label: '升级' },
  { id: 'about', icon: 'info', label: '关于' },
]

export function SettingsPage() {
  const { user, isAdmin } = useAuth()
  const { theme, update } = useSettingsStore()
  const [activeTab, setActiveTab] = useState<SettingsTab>('general')
  const [info, setInfo] = useState<SystemInfo | null>(null)

  useEffect(() => {
    getSystemInfo().then(setInfo).catch(() => {})
  }, [])

  return (
    <div className="flex h-full">
      {/* 左侧 Tab 导航 */}
      <div className="w-56 bg-[var(--color-surface-1)] border-r border-[var(--color-border-subtle)] flex flex-col pt-6 pb-4 max-md:hidden">
        <div className="px-6 mb-6">
          <h2 className="text-lg font-bold text-gradient-brand">系统设置</h2>
        </div>
        <nav className="flex-1 px-3 overflow-y-auto custom-scrollbar">
          <div className="space-y-0.5">
            {tabs.map((tab) => (
              <button
                key={tab.id}
                onClick={() => setActiveTab(tab.id)}
                className={cn(
                  'relative flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg w-full text-left transition-colors',
                  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-brand-500)]/55',
                  activeTab === tab.id
                    ? 'bg-[image:var(--gradient-brand-soft)] text-[color:var(--color-brand-700)] dark:text-[color:var(--color-brand-200)] before:absolute before:left-0 before:top-2 before:bottom-2 before:w-[3px] before:rounded-full before:bg-[image:var(--gradient-brand)]'
                    : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)]',
                )}
              >
                <Icon name={tab.icon} size="lg" />
                <span>{tab.label}</span>
              </button>
            ))}
          </div>
        </nav>
      </div>

      {/* 右侧内容区 */}
      <ScrollArea className="flex-1 p-8 max-md:p-6">
        {activeTab === 'general' && (
          <GeneralSection user={user} isAdmin={isAdmin} />
        )}
        {activeTab === 'appearance' && (
          <AppearanceSection theme={theme} onThemeChange={(m) => update({ theme: m })} />
        )}
        {activeTab === 'diagnostics' && (
          <DiagnosticsSection />
        )}
        {activeTab === 'upgrade' && (
          <UpgradeSection />
        )}
        {activeTab === 'about' && (
          <AboutSection info={info} />
        )}
      </ScrollArea>
    </div>
  )
}

// ── 常规 ──

function GeneralSection({ user, isAdmin }: { user: { account?: string; nickname?: string } | null; isAdmin: boolean }) {
  return (
    <div>
      <SectionHeader icon="account_circle" label="当前用户" color="blue" />
      <div className="space-y-5">
        {/* 用户信息卡片 */}
        <div className="flex items-center gap-4 p-4 rounded-xl bg-[var(--color-surface-1)] border border-[var(--color-border-subtle)]">
          <div className="w-14 h-14 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center flex-shrink-0">
            <Icon name="person" variant="filled" size="xl" className="text-blue-500 dark:text-blue-400" />
          </div>
          <div className="flex flex-col gap-0.5 min-w-0">
            <span className="text-base font-semibold text-[var(--color-text-primary)] truncate">
              {user?.nickname || user?.account || '—'}
            </span>
            {user?.nickname && user?.account && (
              <span className="text-sm text-[var(--color-text-tertiary)] truncate">@{user.account}</span>
            )}
            <span className="text-xs text-[var(--color-text-muted)]">
              {isAdmin ? '管理员' : '普通用户'}
            </span>
          </div>
        </div>
      </div>

      <div className="mt-8">
        <SectionHeader icon="settings" label="操作" color="brand" />
        <div className="space-y-3">
          {/* 配置备份 */}
          <SettingsRow
            label="配置备份"
            desc="备份当前系统配置到文件"
            action={
              <Button variant="secondary" size="sm" onClick={async () => {
                try {
                  const res = await fetch('/api/backup/create', { method: 'POST' });
                  if (res.ok) showToast('success', '配置已备份');
                  else showToast('error', '备份失败');
                } catch { showToast('error', '备份失败'); }
              }}>
                立即备份
              </Button>
            }
          />
          {/* 后台管理 */}
          {isAdmin && (
            <SettingsRow
              label="后台管理"
              desc="进入 Cube 后台管理系统"
              action={
                <Button variant="secondary" size="sm" onClick={() => window.location.href = '/admin'}>
                  进入
                </Button>
              }
            />
          )}
          {/* 退出登录 */}
          <SettingsRow
            label="退出登录"
            desc="退出当前账号"
            action={
              <Button variant="danger" size="sm" onClick={logout}>
                退出
              </Button>
            }
          />
        </div>
      </div>
    </div>
  )
}

// ── 外观 ──

function AppearanceSection({ theme, onThemeChange }: { theme: ThemeMode; onThemeChange: (m: ThemeMode) => void }) {
  const { language, update } = useSettingsStore()

  return (
    <div>
      <SectionHeader icon="palette" label="外观主题" color="violet" />

      <div className="mb-6">
        <div className="text-sm font-medium text-[var(--color-text-primary)] mb-3">主题模式</div>
        <div className="grid grid-cols-3 gap-4">
          <ThemeCard
            active={theme === 'light'}
            onClick={() => onThemeChange('light')}
            label="浅色"
          >
            <div className="absolute inset-x-2 top-2 bottom-0 bg-white rounded-t-lg shadow-sm">
              <div className="p-2 space-y-1">
                <div className="w-8 h-2 bg-gray-100 rounded" />
                <div className="w-12 h-2 bg-gray-100 rounded" />
              </div>
            </div>
          </ThemeCard>
          <ThemeCard
            active={theme === 'dark'}
            onClick={() => onThemeChange('dark')}
            label="深色"
          >
            <div className="absolute inset-x-2 top-2 bottom-0 bg-[#2b2b2e] rounded-t-lg shadow-sm">
              <div className="p-2 space-y-1">
                <div className="w-8 h-2 bg-gray-600 rounded" />
                <div className="w-12 h-2 bg-gray-600 rounded" />
              </div>
            </div>
          </ThemeCard>
          <ThemeCard
            active={theme === 'system'}
            onClick={() => onThemeChange('system')}
            label="跟随系统"
          >
            <div className="absolute inset-0 bg-gradient-to-br from-white via-gray-100 to-gray-800 opacity-50" />
            <div className="absolute inset-0 flex items-center justify-center">
              <Icon name="brightness_auto" variant="filled" className="text-gray-400 text-3xl" />
            </div>
          </ThemeCard>
        </div>
      </div>

      {/* 语言切换 */}
      <div className="mb-6">
        <div className="text-sm font-medium text-[var(--color-text-primary)] mb-3">语言</div>
        <div className="grid grid-cols-2 gap-4">
          <LanguageCard
            active={language === 'zh'}
            onClick={() => update({ language: 'zh' })}
            label="中文"
            sublabel="简体中文"
          />
          <LanguageCard
            active={language === 'en'}
            onClick={() => update({ language: 'en' })}
            label="English"
            sublabel="英文"
          />
        </div>
      </div>
    </div>
  )
}

// ── 关于 ──

function AboutSection({ info }: { info: SystemInfo | null }) {
  return (
    <div>
      <SectionHeader icon="info" label="系统信息" color="brand" />
      {info ? (
        <div className="space-y-4">
          <div className="rounded-xl border border-[var(--color-border-subtle)] divide-y divide-[var(--color-border-subtle)] overflow-hidden">
            <InfoItem icon="dns" label="主机名" value={info.hostName || '-'} />
            <InfoItem icon="computer" label="操作系统" value={info.osVersion || '-'} />
            <InfoItem icon="code" label=".NET 版本" value={info.dotNetVersion || '-'} />
            <InfoItem icon="language" label="平台" value={info.isLinux ? 'Linux' : 'Windows / macOS'} />
          </div>
        </div>
      ) : (
        <p className="text-sm text-[var(--color-text-tertiary)]">加载中…</p>
      )}
    </div>
  )
}

// ── 子组件 ──

function SectionHeader({ icon, label, color }: { icon: string; label: string; color: 'brand' | 'blue' | 'violet' }) {
  const colorMap = {
    brand: 'bg-[var(--color-brand-50)] text-[var(--color-brand-500)] dark:bg-[var(--color-brand-900)]/40 dark:text-[var(--color-brand-300)]',
    blue: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-400',
    violet: 'bg-violet-100 text-violet-600 dark:bg-violet-900/40 dark:text-violet-400',
  }

  return (
    <h3 className="text-lg font-bold text-[var(--color-text-primary)] mb-6 flex items-center">
      <span className={cn('p-1 rounded mr-3', colorMap[color])}>
        <Icon name={icon} variant="filled" size="lg" />
      </span>
      {label}
    </h3>
  )
}

function SettingsRow({ label, desc, action }: { label: string; desc?: string; action: React.ReactNode }) {
  return (
    <div className="flex items-center justify-between py-3">
      <div>
        <div className="text-sm font-medium text-[var(--color-text-primary)]">{label}</div>
        {desc && <div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{desc}</div>}
      </div>
      {action}
    </div>
  )
}

function ThemeCard({ active, onClick, children, label }: {
  active: boolean
  onClick: () => void
  children: React.ReactNode
  label: string
}) {
  return (
    <button onClick={onClick} className="cursor-pointer group relative text-left">
      <div
        className={cn(
          'h-24 rounded-xl border-2 overflow-hidden relative hover:shadow-md transition-all',
          active ? 'border-primary' : 'border-transparent bg-[var(--color-surface-2)]',
        )}
      >
        {children}
        {active && (
          <div className="absolute right-2 bottom-2 text-primary">
            <Icon name="check_circle" variant="filled" size="xl" />
          </div>
        )}
      </div>
      <span className={cn(
        'block text-center text-xs mt-2 font-medium',
        active ? 'text-primary' : 'text-[var(--color-text-secondary)]',
      )}>
        {label}
      </span>
    </button>
  )
}

function LanguageCard({ active, onClick, label, sublabel }: {
  active: boolean
  onClick: () => void
  label: string
  sublabel: string
}) {
  return (
    <button
      onClick={onClick}
      className={cn(
        'flex items-center gap-3 p-4 rounded-xl border-2 transition-all hover:shadow-md text-left',
        active
          ? 'border-[var(--color-brand-400)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20'
          : 'border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]',
      )}
    >
      <div className={cn(
        'w-10 h-10 rounded-lg flex items-center justify-center shrink-0',
        active ? 'bg-[var(--color-brand-100)] dark:bg-[var(--color-brand-800)]/40' : 'bg-[var(--color-surface-2)]',
      )}>
        <Icon name="translate" variant="filled" size="lg" className={active ? 'text-[var(--color-brand-500)]' : 'text-[var(--color-text-tertiary)]'} />
      </div>
      <div>
        <div className={cn('text-sm font-medium', active ? 'text-[var(--color-brand-600)] dark:text-[var(--color-brand-300)]' : 'text-[var(--color-text-primary)]')}>
          {label}
        </div>
        <div className="text-xs text-[var(--color-text-tertiary)]">{sublabel}</div>
      </div>
      {active && (
        <div className="ml-auto">
          <Icon name="check_circle" variant="filled" size="lg" className="text-[var(--color-brand-500)]" />
        </div>
      )}
    </button>
  )
}

function InfoItem({ icon, label, value }: { icon: string; label: string; value: string }) {
  return (
    <div className="flex items-start gap-3 px-4 py-3">
      <Icon name={icon} size="base" className="text-[var(--color-text-tertiary)] mt-0.5 shrink-0" />
      <span className="text-sm text-[var(--color-text-secondary)] w-24 shrink-0">{label}</span>
      <span className="text-sm text-[var(--color-text-primary)] break-all">
        {value || <span className="text-[var(--color-text-muted)]">—</span>}
      </span>
    </div>
  )
}

// ── 在线升级 ──

function UpgradeSection() {
  const [status, setStatus] = useState<UpgradeStatus | null>(null)
  const [checking, setChecking] = useState(false)
  const [installing, setInstalling] = useState(false)
  const [checkResult, setCheckResult] = useState<{ hasUpdate: boolean; latestVersion?: string | null; fileName?: string | null } | null>(null)

  useEffect(() => {
    getUpgradeStatus().then(setStatus).catch(() => {})
  }, [])

  const handleCheck = async () => {
    setChecking(true)
    setCheckResult(null)
    try {
      const result = await checkUpgrade()
      setCheckResult({ hasUpdate: result.hasUpdate, latestVersion: result.latestVersion, fileName: result.fileName })
      if (result.hasUpdate) {
        showToast('success', `发现新版本 ${result.latestVersion}`)
      } else {
        showToast('info', '已是最新版本')
      }
      // 刷新状态
      getUpgradeStatus().then(setStatus).catch(() => {})
    } catch {
      showToast('error', '检查更新失败')
    }
    setChecking(false)
  }

  const handleInstall = async () => {
    setInstalling(true)
    try {
      const result = await installUpgrade()
      if (result.success) {
        showToast('success', result.message)
        if (result.needRestart) {
          setTimeout(() => {
            showToast('info', '服务正在重启,页面即将刷新...')
            // 轮询等待服务恢复
            let retries = 0
            const poll = setInterval(async () => {
              try {
                await getUpgradeStatus()
                clearInterval(poll)
                window.location.reload()
              } catch {
                retries++
                if (retries > 30) {
                  clearInterval(poll)
                  showToast('error', '服务重启超时,请手动刷新页面')
                }
              }
            }, 2000)
          }, 3000)
        }
      } else {
        showToast('error', result.message || '安装失败')
      }
    } catch {
      showToast('error', '安装更新失败')
    }
    setInstalling(false)
  }

  return (
    <div>
      <SectionHeader icon="system_update" label="在线升级" color="brand" />

      {/* 当前版本信息 */}
      <div className="rounded-xl border border-[var(--color-border-subtle)] divide-y divide-[var(--color-border-subtle)] overflow-hidden mb-6">
        <InfoItem icon="info" label="当前版本" value={status?.currentVersion || '—'} />
        <InfoItem icon="new_releases" label="最新版本" value={status?.latestVersion || '—'} />
        <InfoItem icon="cloud" label="更新服务器" value={status?.server || '—'} />
        <InfoItem icon="update" label="最后检查" value={status?.lastCheck ? new Date(status.lastCheck).toLocaleString() : '从未检查'} />
      </div>

      {/* 操作按钮 */}
      <div className="space-y-3">
        <SettingsRow
          label="检查更新"
          desc="从更新服务器检测是否有新版本可用"
          action={
            <Button variant="secondary" size="sm" onClick={handleCheck} disabled={checking || installing}>
              {checking ? '检查中...' : '检查更新'}
            </Button>
          }
        />

        {checkResult?.hasUpdate && (
          <div className="p-4 rounded-xl bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20 border border-[var(--color-brand-200)] dark:border-[var(--color-brand-800)]">
            <div className="flex items-center gap-2 mb-2">
              <Icon name="new_releases" size="base" className="text-[var(--color-brand-500)]" />
              <span className="text-sm font-semibold text-[var(--color-brand-600)] dark:text-[var(--color-brand-300)]">
                发现新版本 {checkResult.latestVersion}
              </span>
            </div>
            {checkResult.fileName && (
              <p className="text-xs text-[var(--color-text-tertiary)] mb-3">安装包: {checkResult.fileName}</p>
            )}
            <Button variant="primary" size="sm" onClick={handleInstall} disabled={installing}>
              {installing ? '安装中...' : '立即安装更新'}
            </Button>
            <p className="text-xs text-[var(--color-text-muted)] mt-2">
              安装完成后服务将自动重启,StarAgent 守护进程会自动拉起重启
            </p>
          </div>
        )}
      </div>
    </div>
  )
}

// ── 网络诊断 ──

function DiagnosticsSection() {
  const [target, setTarget] = useState('')
  const [result, setResult] = useState('')
  const [running, setRunning] = useState(false)

  const runPing = async () => {
    if (!target.trim()) { showToast('warning', '请输入目标地址'); return }
    setRunning(true)
    setResult('正在 ping...')
    try {
      const res = await fetch(`/api/system/ping?host=${encodeURIComponent(target.trim())}`)
      const data = await res.json()
      setResult(data.output || data.message || JSON.stringify(data))
    } catch {
      setResult('诊断失败,请检查后端服务是否运行')
    }
    setRunning(false)
  }

  return (
    <div>
      <SectionHeader icon="network_check" label="网络诊断" color="blue" />
      <div className="space-y-4">
        {/* Ping 工具 */}
        <div className="p-4 rounded-xl bg-[var(--color-surface-1)] border border-[var(--color-border-subtle)]">
          <h4 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">Ping 连通性测试</h4>
          <div className="flex gap-2 mb-3">
            <input
              type="text"
              value={target}
              onChange={(e) => setTarget(e.target.value)}
              onKeyDown={(e) => { if (e.key === 'Enter') runPing() }}
              placeholder="输入域名或 IP,如 baidu.com 或 192.168.1.1"
              className="flex-1 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"
            />
            <Button variant="primary" size="sm" onClick={runPing} disabled={running}>
              Ping
            </Button>
          </div>
          {result && (
            <pre className="text-xs font-mono text-[var(--color-text-secondary)] bg-[var(--color-surface-0)] rounded-lg p-3 max-h-64 overflow-y-auto whitespace-pre-wrap">
              {result}
            </pre>
          )}
        </div>

        {/* 快捷诊断目标 */}
        <div className="p-4 rounded-xl bg-[var(--color-surface-1)] border border-[var(--color-border-subtle)]">
          <h4 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">常用诊断</h4>
          <div className="flex flex-wrap gap-2">
            {[
              { label: '百度', host: 'baidu.com' },
              { label: 'Google DNS', host: '8.8.8.8' },
              { label: 'Cloudflare DNS', host: '1.1.1.1' },
              { label: '网关', host: '192.168.1.1' },
            ].map((item) => (
              <Button key={item.host} variant="ghost" size="sm"
                onClick={() => { setTarget(item.host); setTimeout(() => document.querySelector<HTMLInputElement>('input[placeholder*="输入域名"]')?.dispatchEvent(new Event('change', { bubbles: true })), 0) }}>
                {item.label}
              </Button>
            ))}
          </div>
        </div>

        {/* 网络接口信息 */}
        <div className="p-4 rounded-xl bg-[var(--color-surface-1)] border border-[var(--color-border-subtle)]">
          <h4 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">说明</h4>
          <ul className="text-xs text-[var(--color-text-tertiary)] space-y-1">
            <li>• Ping 功能需要后端支持 Shell 命令执行</li>
            <li>• 仅在 Linux 环境下支持完整诊断功能</li>
            <li>• Windows/macOS 下可通过系统自带终端进行网络诊断</li>
          </ul>
        </div>
      </div>
    </div>
  )
}