数据迁移不再整体替换 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
363 lines
16 KiB
React
363 lines
16 KiB
React
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
|