Files
happy-export/frontend/src/components/Settings.jsx
T
Drsakura 13d67c804f feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
2026-09-17 14:53:02 +08:00

308 lines
14 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, ExchangeIcon, 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 的项只对有该权限点的人显示 ——
智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。 */
const CATEGORIES = [
{ key: 'appearance', label: '外观与主题', icon: TuneIcon },
{ key: 'organization', label: '组织与品牌', icon: BuildingIcon, permission: 'branding.manage' },
{ key: 'import-export', label: '数据交换', icon: ExchangeIcon },
{ 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 === 'import-export' && <ImportExportSettings />}
{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>
)
}
function ImportExportSettings() {
return (
<div className="settings-section">
<h3>导入 / 导出</h3>
<p className="settings-description">管理业务数据的导入、导出与本地备份。</p>
<div className="settings-group"><h4>导出数据</h4><div className="settings-actions"><button className="btn btn-secondary">导出供应商数据</button><button className="btn btn-secondary">导出产品数据</button><button className="btn btn-secondary">导出客户数据</button><button className="btn btn-secondary">导出订单数据</button></div></div>
<div className="settings-group"><h4>导入数据</h4><div className="settings-actions"><label className="btn btn-secondary file-label">导入供应商数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入产品数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入客户数据<input type="file" accept=".csv,.xlsx" /></label></div><p className="help-text">支持 CSV 和 Excel 格式文件</p></div>
<div className="settings-group"><h4>备份与恢复</h4><div className="settings-actions"><button className="btn btn-primary">创建完整备份</button><label className="btn btn-secondary file-label">恢复备份<input type="file" accept=".db,.backup" /></label></div><p className="help-text">建议定期备份数据库</p></div>
</div>
)
}
/* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */
/* ---------------------------------------------------------------------------
系统与更新
显示本机版本与运行环境,并从 GitHub 仓库检查是否有新版本。
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
--------------------------------------------------------------------------- */
function SystemUpdateSettings() {
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 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={checking}
/>
</div>
<p className="help-text">默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。</p>
</div>
<div className="settings-group">
<h4>检查更新</h4>
<div className="settings-actions">
<button className="btn btn-primary" onClick={check} disabled={checking || loadingInfo}>
{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>
</div>
)
}
export default Settings