Files
happy-export/frontend/src/components/Settings.jsx
T
Drsakura 1a759ef37d feat(data): 业务数据与系统数据分离的导入导出
数据迁移不再整体替换 trade.db —— 那条路会把账号、权限组、组织、设置(含大模型密钥)、
登录会话与审计日志一起搬到目标部署。改为按类别搬运业务数据,系统数据永远是目标部署自己那份。

- backend/lib/dataTransfer:表目录(30 张业务表,依赖顺序)、gzip JSONL 数据包(每表 sha256)、导入内核
- 归属统一给导入人;冲突跳过不覆盖;父记录按 __refs 自然键快照补壳
- 流水表(price_history)按整行内容判重:用 (sku, changed_at) 当键实测 643 行丢 186 行
- 预览在库副本上真跑一遍导入,报告与真导同源;导入前 VACUUM INTO 备份,单事务写入
- 新权限点 data.transfer(仅超管组),导出/导入均写审计
- 前端「数据迁移」页:按类别勾选导出、上传后预览逐表明细、确认导入;移除设置里的静态占位「数据交换」
- 测试:单测 7 项(含目录外键顺序、BLOB、重复导入、包被改坏)、API 端到端 1 项(两个真实实例互传)
- 用真实库副本彩排:3854 行 0 失败,29 张业务表行数全部一致,包 1.22 MB
2026-10-23 13:46:26 +08:00

363 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, Suspense } from 'react'
import axios from 'axios'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons'
import { useAuth, hasPermission } from '../auth'
import { useBranding } from '../branding'
/* 这两块只有系统管理账号会看,按需加载:设置本身在外壳里是静态引入的,
跟着首屏一起下发等于给所有人多下载十几 KB。 */
const AIConfigSettings = React.lazy(() => import('./AIConfigSettings'))
const OrgBrandingSettings = React.lazy(() => import('./OrgBrandingSettings'))
/* 设置分类。带 permission 的项只对有该权限点的人显示 ——
智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。
数据导入导出已从设置里移出,做成独立页面「数据迁移」(侧栏 · 系统管理),
因为那里要放类别勾选和逐表明细,塞进这个 1000px 的模态框里看不清。 */
const CATEGORIES = [
{ key: 'appearance', label: '外观与主题', icon: TuneIcon },
{ key: 'organization', label: '组织与品牌', icon: BuildingIcon, permission: 'branding.manage' },
{ key: 'ai-config', label: '智能配置', icon: SparkIcon, permission: 'ai.manage' },
{ key: 'system-update', label: '系统与更新', icon: RefreshIcon }
]
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
const { user } = useAuth()
const [activeCategory, setActiveCategory] = useState('appearance')
const visibleCategories = CATEGORIES.filter(item => !item.permission || hasPermission(user, item.permission))
/* 打开着设置时权限被收回(换了角色),当前分类可能已经不可见,退回第一项 */
const current = visibleCategories.some(item => item.key === activeCategory)
? activeCategory
: visibleCategories[0]?.key
const modalRef = useRef(null)
useEffect(() => {
if (!isOpen) return
const previousFocus = document.activeElement
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const modal = modalRef.current
const focusable = () => [...modal.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')].filter(el => !el.disabled && el.getClientRects().length)
modal.querySelector('.close-btn')?.focus()
const onKeyDown = event => {
if (event.key === 'Escape') { event.preventDefault(); onClose() }
if (event.key === 'Tab') {
const items = focusable()
const first = items[0], last = items[items.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}
}
modal.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = previousOverflow
modal.removeEventListener('keydown', onKeyDown)
previousFocus?.focus()
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="settings-overlay" onClick={onClose}>
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="settings-title" className="settings-modal luxury-settings" onClick={(e) => e.stopPropagation()}>
<aside className="settings-sidebar">
<div className="settings-header">
<div>
<h2 id="settings-title">系统设置</h2>
</div>
<button className="close-btn" onClick={onClose} aria-label="关闭设置"><CloseIcon size={18} /></button>
</div>
<nav className="settings-nav" aria-label="设置分类">
{visibleCategories.map(item => {
const Icon = item.icon
return (
<button
key={item.key}
className={`settings-nav-item ${current === item.key ? 'active' : ''}`}
onClick={() => setActiveCategory(item.key)}
>
<span className="settings-nav-icon"><Icon size={17} /></span>
<span>{item.label}</span>
</button>
)
})}
</nav>
<div className="settings-sidebar-status"><TuneIcon size={15} /><span>专注业务,自在掌控<small>TRADE MANAGEMENT</small></span></div>
</aside>
<section className="settings-content">
<Suspense fallback={<div className="settings-section"><p className="settings-description">正在载入…</p></div>}>
{current === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{current === 'organization' && <OrgBrandingSettings />}
{current === 'ai-config' && <AIConfigSettings />}
{current === 'system-update' && <SystemUpdateSettings />}
</Suspense>
</section>
</div>
</div>
)
}
function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
return (
<div className="settings-section">
<h3>外观与主题</h3>
<p className="settings-description">选择适合工作环境的显示模式,设置会自动保存在本机。</p>
<div className="theme-selector" role="group" aria-label="界面主题">
<button className={`theme-option ${theme === 'dark' ? 'active' : ''}`} aria-pressed={theme === 'dark'} onClick={() => onThemeChange('dark')}>
<span className="theme-preview theme-preview-dark" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><MoonStarsIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
<span><strong>夜间控制台</strong><small>深色 · 低光环境</small></span>
<span className="theme-check" aria-hidden="true">{theme === 'dark' && <CheckIcon size={14} />}</span>
</button>
<button className={`theme-option ${theme === 'light' ? 'active' : ''}`} aria-pressed={theme === 'light'} onClick={() => onThemeChange('light')}>
<span className="theme-preview theme-preview-light" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><SunIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
<span><strong>日间工作台</strong><small>淡色 · 明亮环境</small></span>
<span className="theme-check" aria-hidden="true">{theme === 'light' && <CheckIcon size={14} />}</span>
</button>
</div>
<div className="settings-group">
<h4>显示偏好</h4>
<div className="setting-info"><span><strong>清晰优先</strong><small>两种主题均保留高对比度文字与明确的状态标识。</small></span><span className="preference-badge">默认启用</span></div>
<label className="setting-toggle"><span><strong>减少动效</strong><small>适用于需要稳定画面的工作环境</small></span><input type="checkbox" aria-label="减少动效" checked={reducedMotion} onChange={e => onReducedMotionChange(e.target.checked)} /><i aria-hidden="true" /></label>
</div>
<p className="appearance-note"><CheckIcon size={14} /> 即时生效 · 自动记住您的主题与动效偏好</p>
</div>
)
}
/* 数据导入导出已移出设置:见 pages/DataTransfer.jsx(侧栏 · 系统管理 · 数据迁移) */
/* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */
/* ---------------------------------------------------------------------------
系统与更新
显示本机版本与运行环境,并从 GitHub 仓库检查是否有新版本。
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
--------------------------------------------------------------------------- */
function SystemUpdateSettings() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const [job, setJob] = useState(null)
const [updatePassword, setUpdatePassword] = useState('')
const [starting, setStarting] = useState(false)
const [connectionLost, setConnectionLost] = useState(false)
const brand = useBranding()
const [info, setInfo] = useState(null)
const [repo, setRepo] = useState('')
const [loadingInfo, setLoadingInfo] = useState(true)
const [checking, setChecking] = useState(false)
const [result, setResult] = useState(null)
const [error, setError] = useState('')
const [showNotes, setShowNotes] = useState(false)
useEffect(() => {
let alive = true
axios
.get('/api/system/info')
.then(({ data }) => {
if (!alive) return
setInfo(data)
setRepo(data.repo || '')
})
.catch((e) => {
if (alive) setError(e?.response?.data?.error || e.message)
})
.finally(() => {
if (alive) setLoadingInfo(false)
})
return () => {
alive = false
}
}, [])
const check = async () => {
setChecking(true)
setError('')
setResult(null)
setShowNotes(false)
try {
const { data } = await axios.get('/api/system/check-update', { params: { repo, force: 1 } })
setResult(data)
if (!data.ok) setError(data.error || '检查更新失败')
} catch (e) {
setError(e?.response?.data?.error || e.message)
} finally {
setChecking(false)
}
}
const updating = starting || (job && !['idle', 'completed', 'failed'].includes(job.state))
useEffect(() => {
if (!isAdmin) return
let alive = true
let timer
const poll = async () => {
try {
const { data } = await axios.get('/api/system/update/status', { timeout: 8000 })
if (alive) { setJob(data); setConnectionLost(false) }
} catch {
if (alive) setConnectionLost(true)
} finally {
if (alive) timer = setTimeout(poll, 2500)
}
}
poll()
return () => { alive = false; clearTimeout(timer) }
}, [isAdmin])
const install = async event => {
event.preventDefault()
setStarting(true)
setError('')
try {
const { data } = await axios.post('/api/system/update', { current_password: updatePassword }, { timeout: 60000 })
setJob(current => ({ ...current, ...data }))
setUpdatePassword('')
} catch (e) {
setError(e?.response?.data?.error || '未能确认更新任务状态,请查看下方进度后再试')
} finally { setStarting(false) }
}
const fmtTime = (value) => {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
return (
<div className="settings-section">
<h3>系统与更新</h3>
<p className="settings-description">查看当前版本;系统管理员可在此安装更新。</p>
<div className="settings-group">
<h4>当前版本</h4>
<div className="setting-info">
<span>
<strong>{info ? `${brand.name} v${info.version}` : '正在读取版本…'}</strong>
<small>{info ? `运行环境 Node ${info.node} · ${info.platform} ${info.arch}` : '读取本机信息中'}</small>
</span>
<span className="preference-badge">正式版</span>
</div>
{info?.startedAt && <p className="help-text">服务启动于 {fmtTime(info.startedAt)}</p>}
</div>
<div className="settings-group">
<h4>更新源</h4>
<div className="form-group">
<label>GitHub 仓库</label>
<input
type="text"
className="form-input"
value={repo}
placeholder="owner/repo"
onChange={(e) => setRepo(e.target.value)}
disabled
/>
</div>
<p className="help-text">更新源由服务器配置,网页安装使用此仓库的最新正式发布版本或版本标签。</p>
</div>
<div className="settings-group">
<h4>检查更新</h4>
<div className="settings-actions">
<button className="btn btn-primary" onClick={check} disabled={checking || loadingInfo || updating}>
{checking ? '检查中…' : '检查更新'}
</button>
</div>
{!checking && result && (
<p className="help-text">
上次检查:{fmtTime(result.checkedAt)}
{result.cached ? '(结果来自缓存)' : ''}
</p>
)}
{checking && (
<div className="update-status is-checking">
<span className="spinner" />
<span>正在连接 GitHub,请稍候…</span>
</div>
)}
{!checking && error && (
<div className="update-status is-error">
<CloseIcon size={15} />
<span>{error}</span>
</div>
)}
{!checking && !error && result?.ok && result.hasUpdate && (
<>
<div className="update-status is-new">
<SparkIcon size={15} />
<span>
发现新版本 <b>v{result.latest}</b>,当前 v{result.current}
</span>
</div>
{result.publishedAt && <p className="help-text">发布于 {fmtTime(result.publishedAt)}</p>}
{(result.url || result.notes) && (
<div className="update-links">
{result.url && (
<a className="btn-link" href={result.url} target="_blank" rel="noreferrer">
在 GitHub 上查看
</a>
)}
{result.notes && (
<button className="btn-link" onClick={() => setShowNotes((v) => !v)}>
{showNotes ? '收起更新说明' : '查看更新说明'}
</button>
)}
</div>
)}
{showNotes && result.notes && <pre className="update-notes">{result.notes}</pre>}
</>
)}
{!checking && !error && result?.ok && !result.hasUpdate && (
<>
<div className="update-status is-ok">
<CheckIcon size={15} />
<span>已是最新版本(v{result.current})</span>
</div>
{result.note && <p className="help-text">{result.note}</p>}
</>
)}
</div>
{isAdmin && (
<div className="settings-group">
<h4>安装更新</h4>
<p className="help-text">系统先构建新版本并备份数据库,再切换应用和重启。重启时页面可能短暂断开,进度会自动恢复。</p>
{job?.capability && !job.capability.supported && <p className="help-text">{job.capability.reason}</p>}
{result?.hasUpdate && job?.capability?.supported && !updating && !(job?.state === 'completed' && job?.version === result.latest) && (
<form onSubmit={install}>
<div className="form-group">
<label htmlFor="update-current-password">管理员当前密码</label>
<input id="update-current-password" className="form-input" type="password" autoComplete="current-password"
required value={updatePassword} onChange={event => setUpdatePassword(event.target.value)} />
</div>
<button type="submit" className="btn btn-primary" disabled={starting || !updatePassword}>
{starting ? '正在创建任务…' : `安装 v${result.latest}`}
</button>
</form>
)}
{job && job.state !== 'idle' && (
<div role="status" className={`update-status ${job.state === 'failed' ? 'is-error' : job.state === 'completed' ? 'is-ok' : 'is-checking'}`}>
{updating && <span className="spinner" />}<span>{job.message}</span>
</div>
)}
{connectionLost && <p role="status" className="help-text">暂时无法连接服务,正在自动重试…</p>}
{job?.state === 'completed' && <button className="btn btn-primary" onClick={() => window.location.reload()}>刷新使用新版本</button>}
{job?.log && <details><summary>更新日志</summary><pre className="update-notes">{job.log}</pre></details>}
</div>
)}
</div>
)
}
export default Settings