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
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { ActivityIcon, DownloadIcon, RefreshIcon, SearchIcon, ClockIcon, ShieldIcon } from '../components/Icons'
|
||||
import { setPageTitle } from '../branding'
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
系统日志(审计轨迹)—— 侧栏「系统管理」下的独立页面。
|
||||
参考 GoodJob 的系统日志页:四格统计 + 等级/分类筛选 + 搜索 + 导出 + 列表 + 展开详情。
|
||||
数据源 GET /api/logs(后端按 action 前缀归分类、按 ok 归等级)。
|
||||
入口与路由都认 audit.view —— 按 Wayne 的口径,仅系统管理员可见。
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
const LEVELS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'info', label: '信息' },
|
||||
{ key: 'success', label: '成功' },
|
||||
{ key: 'warning', label: '警告' },
|
||||
{ key: 'error', label: '错误' }
|
||||
]
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function fmtTime(value) {
|
||||
if (!value) return ''
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function SystemLogs() {
|
||||
const [level, setLevel] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [data, setData] = useState(null)
|
||||
const [openId, setOpenId] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const alive = useRef(true)
|
||||
|
||||
useEffect(() => { setPageTitle('系统日志') }, [])
|
||||
|
||||
/* 搜索框防抖,避免每敲一个字打一次接口 */
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { setSearch(keyword.trim()); setOffset(0) }, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [keyword])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data: payload } = await axios.get('/api/logs', {
|
||||
params: { level: level || undefined, category: category || undefined, q: search || undefined, limit: PAGE_SIZE, offset }
|
||||
})
|
||||
if (!alive.current) return
|
||||
setData(payload)
|
||||
} catch (e) {
|
||||
if (alive.current) setError(e?.response?.data?.error || e.message)
|
||||
} finally {
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
}, [level, category, search, offset])
|
||||
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const items = data?.items || []
|
||||
const stats = data?.stats || { total: 0, today: 0, warning: 0, error: 0 }
|
||||
const total = data?.total || 0
|
||||
|
||||
const rangeText = useMemo(() => {
|
||||
if (!total) return '暂无记录'
|
||||
const from = offset + 1
|
||||
const to = Math.min(offset + PAGE_SIZE, total)
|
||||
return `第 ${from}–${to} 条 / 共 ${total} 条`
|
||||
}, [offset, total])
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await axios.get('/api/logs/export', {
|
||||
params: { level: level || undefined, category: category || undefined, q: search || undefined },
|
||||
responseType: 'blob'
|
||||
})
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `system-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.error || '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container logs-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">系统日志</h1>
|
||||
<p className="page-subtitle">
|
||||
账号登录与核心业务操作的审计轨迹,用于追溯与排障。仅系统管理员可见。
|
||||
{total > 0 && ` 共 ${total} 条记录。`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
|
||||
<RefreshIcon size={14} /> {loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={exportCsv} disabled={exporting || !total}>
|
||||
<DownloadIcon size={14} /> {exporting ? '导出中…' : '导出 CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-stats">
|
||||
<div className="log-stat">
|
||||
<b>{stats.total}</b>
|
||||
<span>记录</span>
|
||||
</div>
|
||||
<div className="log-stat">
|
||||
<b>{stats.today}</b>
|
||||
<span>今日</span>
|
||||
</div>
|
||||
<div className="log-stat is-warn">
|
||||
<b>{stats.warning}</b>
|
||||
<span>警告</span>
|
||||
</div>
|
||||
<div className="log-stat is-err">
|
||||
<b>{stats.error}</b>
|
||||
<span>错误</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="log-toolbar">
|
||||
<div className="log-chips" role="tablist" aria-label="按等级筛选">
|
||||
{LEVELS.map(item => (
|
||||
<button
|
||||
key={item.key || 'all'}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={level === item.key}
|
||||
className={`log-chip ${level === item.key ? 'is-active' : ''} ${item.key ? 'is-' + item.key : ''}`}
|
||||
onClick={() => { setLevel(item.key); setOffset(0) }}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-toolbar">
|
||||
<div className="log-chips" role="tablist" aria-label="按分类筛选">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === ''}
|
||||
className={`log-chip ${category === '' ? 'is-active' : ''}`}
|
||||
onClick={() => { setCategory(''); setOffset(0) }}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{(data?.categories || []).map(item => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === item.key}
|
||||
className={`log-chip ${category === item.key ? 'is-active' : ''}`}
|
||||
onClick={() => { setCategory(item.key); setOffset(0) }}
|
||||
>
|
||||
{item.label}<small>{item.count}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-actions">
|
||||
<label className="log-search">
|
||||
<SearchIcon size={15} />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
placeholder="搜索操作人 / 动作 / 说明 / IP"
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="log-error"><ShieldIcon size={14} /> {error}</p>}
|
||||
|
||||
{!loading && !items.length && (
|
||||
<div className="log-empty">
|
||||
<ActivityIcon size={22} />
|
||||
<strong>暂无日志记录</strong>
|
||||
<small>登录系统或改动用户、权限、客户、商机与模型接入时,操作会自动记录到这里。</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!items.length && (
|
||||
<ul className="log-list">
|
||||
{items.map(row => (
|
||||
<li key={row.id} className={`log-row is-${row.level}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="log-row-main"
|
||||
aria-expanded={openId === row.id}
|
||||
onClick={() => setOpenId(openId === row.id ? null : row.id)}
|
||||
>
|
||||
<span className={`log-dot is-${row.level}`} aria-hidden="true" />
|
||||
<span className="log-time"><ClockIcon size={12} />{fmtTime(row.at)}</span>
|
||||
<span className={`log-badge is-${row.level}`}>{row.ok ? '' : '失败 · '}{row.action_label}</span>
|
||||
<span className="log-detail">{row.detail || '—'}</span>
|
||||
<span className="log-actor">{row.username || '—'}</span>
|
||||
<span className="log-cat">{row.category}</span>
|
||||
</button>
|
||||
{openId === row.id && (
|
||||
<dl className="log-more">
|
||||
<div><dt>动作标识</dt><dd>{row.action}</dd></div>
|
||||
<div><dt>操作人</dt><dd>{row.username || '—'}</dd></div>
|
||||
<div><dt>来源 IP</dt><dd>{row.ip || '—'}</dd></div>
|
||||
<div><dt>结果</dt><dd>{row.ok ? '成功' : '失败'}</dd></div>
|
||||
<div className="full"><dt>说明</dt><dd>{row.detail || '—'}</dd></div>
|
||||
<div className="full"><dt>User-Agent</dt><dd>{row.user_agent || '—'}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{(total > PAGE_SIZE || offset > 0) && (
|
||||
<div className="log-pager">
|
||||
<span>{rangeText}</span>
|
||||
<div>
|
||||
<button type="button" className="btn btn-secondary" disabled={offset === 0 || loading} onClick={() => setOffset(Math.max(offset - PAGE_SIZE, 0))}>上一页</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={offset + PAGE_SIZE >= total || loading} onClick={() => setOffset(offset + PAGE_SIZE)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemLogs
|
||||
Reference in New Issue
Block a user