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>
)
}
|