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:
@@ -1,21 +1,28 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
ActivityIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
CleanupIcon,
|
||||
ClipboardIcon,
|
||||
ClockIcon,
|
||||
CloseIcon,
|
||||
DollarIcon,
|
||||
FactoryIcon,
|
||||
FlagIcon,
|
||||
InquiryIcon,
|
||||
PackageIcon,
|
||||
PinIcon,
|
||||
PlusCircleIcon,
|
||||
QuoteIcon,
|
||||
SparkIcon,
|
||||
TrashIcon,
|
||||
UsersIcon
|
||||
} from '../components/Icons'
|
||||
import MonthYearWheel from '../components/MonthYearWheel'
|
||||
import { SkeletonPanel } from '../components/Skeleton'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
@@ -699,7 +706,6 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
<section className="calendar-board dashboard-panel">
|
||||
<div className="dashboard-panel-header calendar-board-header">
|
||||
<div>
|
||||
<span className="section-label">[SCHEDULE]</span>
|
||||
<h2><CalendarIcon size={18} /> 工作日历</h2>
|
||||
<p className="calendar-hint">单击日期选中,再次单击添加日程 · 悬浮日程或节日查看详情</p>
|
||||
</div>
|
||||
@@ -985,10 +991,48 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
)
|
||||
}
|
||||
|
||||
const INQUIRY_STATUS_LABELS = {
|
||||
draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭'
|
||||
}
|
||||
|
||||
const CONTRACT_STATUS_LABELS = {
|
||||
queued: '排队中', parsing: '解析中', pending_review: '待复核', reviewed: '已复核', parse_failed: '解析失败'
|
||||
}
|
||||
|
||||
/** 今日/逾期跟进条目:客户名 + 跟进动作 + 日期,点进去就是客户详情 */
|
||||
function FollowUpRow({ record, overdue }) {
|
||||
return (
|
||||
<li className="followup-row">
|
||||
<span className={`followup-dot${overdue ? ' is-overdue' : ''}`} />
|
||||
<div className="followup-body">
|
||||
<Link className="followup-customer" to={`/customers/${record.customer_id}`}>
|
||||
{record.customer_name || `客户 #${record.customer_id}`}
|
||||
</Link>
|
||||
<span className="followup-action">
|
||||
{record.next_action || `${record.activity_type || '跟进'}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`followup-date${overdue ? ' is-overdue' : ''}`}>
|
||||
{String(record.next_action_date || '').slice(0, 10)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
/* 工作台是跨模块的汇总页,里面的入口必须跟侧栏用同一套口径 ——
|
||||
没有那个模块的权限,入口就不出现在这里(否则侧栏藏了、工作台还漏一个)。 */
|
||||
const canInquiry = hasPermission(user, 'inquiry.view')
|
||||
const canQuote = hasPermission(user, 'quote.view')
|
||||
const canCleanup = hasPermission(user, 'cleanup.view')
|
||||
const canCustomer = hasPermission(user, 'customer.view')
|
||||
const canEditInquiry = hasPermission(user, 'inquiry.edit')
|
||||
const canEditCustomer = hasPermission(user, 'customer.edit')
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/dashboard')
|
||||
@@ -1002,28 +1046,173 @@ function Dashboard() {
|
||||
|
||||
useEffect(() => { loadDashboard() }, [loadDashboard])
|
||||
|
||||
if (loading) return <div className="loading">加载工作台数据…</div>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-welcome"><div><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||
<div className="dashboard-content-grid">
|
||||
<div className="dashboard-primary-column">
|
||||
<section className="dashboard-panel"><SkeletonPanel lines={4} /></section>
|
||||
<section className="dashboard-panel"><SkeletonPanel lines={3} /></section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { stats, recentActivities, todos } = data
|
||||
const {
|
||||
stats, recentActivities, todos,
|
||||
todayFollowUps = [], overdueFollowUps = [], recentInquiries = [],
|
||||
recentQuotes = [], pendingContractJobs = []
|
||||
} = data
|
||||
const completion = todos.length ? Math.max(12, Math.round((todos.filter(todo => todo.status === 'completed').length / todos.length) * 100)) : 0
|
||||
const followUpCount = todayFollowUps.length + overdueFollowUps.length
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||
<div className="dashboard-welcome"><div><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||
|
||||
<CalendarBoard todos={todos} onTodoCreated={loadDashboard} />
|
||||
|
||||
{/* 跟进卡放日历下面一栏(Wayne 2026-09-15 要求):先看日程,再看今天该催谁 */}
|
||||
{followUpCount > 0 && canCustomer && (
|
||||
<section className="followup-strip">
|
||||
<div className="followup-card is-today">
|
||||
<div className="followup-card-head">
|
||||
<h3><ClockIcon size={15} /> 今日待跟进</h3>
|
||||
<span className="followup-count">{todayFollowUps.length}</span>
|
||||
</div>
|
||||
{todayFollowUps.length === 0
|
||||
? <p className="followup-empty">今天没有安排跟进。</p>
|
||||
: <ul className="followup-list">{todayFollowUps.map(record => <FollowUpRow key={record.id} record={record} />)}</ul>}
|
||||
</div>
|
||||
<div className="followup-card is-overdue">
|
||||
<div className="followup-card-head">
|
||||
<h3><FlagIcon size={15} /> 逾期未跟进</h3>
|
||||
<span className="followup-count">{overdueFollowUps.length}</span>
|
||||
</div>
|
||||
{overdueFollowUps.length === 0
|
||||
? <p className="followup-empty">没有逾期项,节奏很好。</p>
|
||||
: <ul className="followup-list">{overdueFollowUps.map(record => <FollowUpRow key={record.id} record={record} overdue />)}</ul>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="dashboard-content-grid">
|
||||
<div className="dashboard-primary-column">
|
||||
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><span className="section-label">[AI BRIEFING]</span><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{stats.activeDeals || 0}</b> 个进行中商机和 <b>{stats.orders || 0}</b> 笔进行中订单。打开右上角的小皮,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
|
||||
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{followUpCount}</b> 个待跟进客户、<b>{recentInquiries.length}</b> 条最近询盘、<b>{stats.pendingContractJobs || 0}</b> 个待复核合同任务。打开右上角的小皮,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>询盘报价</span><span>订单节奏</span></div></div></section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[ACTIVITY LOG]</span><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><InquiryIcon size={17} /> 最近询盘</h2></div>
|
||||
{canInquiry && <Link className="panel-link" to="/inquiries">全部询盘</Link>}
|
||||
</div>
|
||||
{recentInquiries.length === 0
|
||||
? <div className="dashboard-empty">还没有询盘记录</div>
|
||||
: (
|
||||
<ul className="compact-list">
|
||||
{recentInquiries.map(inquiry => (
|
||||
<li key={inquiry.id}>
|
||||
{canInquiry ? (
|
||||
<Link className="compact-main" to={`/inquiries/${inquiry.id}`}>
|
||||
<span className="compact-title">{inquiry.title || '未命名询盘'}</span>
|
||||
<span className="compact-sub">
|
||||
<code>{inquiry.inquiry_no}</code>
|
||||
{' · '}
|
||||
{inquiry.customer_name || '未知客户'}
|
||||
{' · '}
|
||||
{inquiry.inquiry_date}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="compact-main">
|
||||
<span className="compact-title">{inquiry.title || '未命名询盘'}</span>
|
||||
<span className="compact-sub">
|
||||
<code>{inquiry.inquiry_no}</code>
|
||||
{' · '}
|
||||
{inquiry.customer_name || '未知客户'}
|
||||
{' · '}
|
||||
{inquiry.inquiry_date}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="compact-tag">{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><QuoteIcon size={17} /> 最近报价单</h2></div>
|
||||
{canQuote && <Link className="panel-link" to="/quotes">报价单管理</Link>}
|
||||
</div>
|
||||
{recentQuotes.length === 0
|
||||
? <div className="dashboard-empty">还没有客户报价单</div>
|
||||
: (
|
||||
<ul className="compact-list">
|
||||
{recentQuotes.map(quote => (
|
||||
<li key={quote.id}>
|
||||
<span className="compact-main">
|
||||
<span className="compact-title"><code>{quote.quote_no}</code></span>
|
||||
<span className="compact-sub">
|
||||
{quote.customer_name || '未知客户'}
|
||||
{' · '}
|
||||
{quote.quote_date}
|
||||
{quote.valid_until ? ` · 有效期至 ${quote.valid_until}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="compact-tag">{quote.currency || ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
|
||||
</div>
|
||||
|
||||
<aside className="dashboard-secondary-column">
|
||||
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><span className="section-label">[SYSTEM PULSE]</span><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<DollarIcon size={17} />} label="进行中商机" value={stats.activeDeals || 0} /></section>
|
||||
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK HEALTH]</span><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
|
||||
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><span className="section-label">[QUICK ACCESS]</span><h2>快捷入口</h2></div></div><button><UsersIcon size={16} /> 新增客户</button><button><PackageIcon size={16} /> 添加产品</button><button><ClipboardIcon size={16} /> 新建订单</button></section>
|
||||
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<InquiryIcon size={17} />} label="待跟进" value={followUpCount} /><StatLine icon={<CleanupIcon size={17} />} label="待复核合同" value={stats.pendingContractJobs || 0} /></section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div><h2><CleanupIcon size={17} /> 待审核合同</h2></div>
|
||||
{canCleanup && <Link className="panel-link" to="/contract-cleanup">去复核</Link>}
|
||||
</div>
|
||||
{pendingContractJobs.length === 0
|
||||
? <div className="dashboard-empty">没有待复核的导入任务</div>
|
||||
: (
|
||||
<ul className="compact-list">
|
||||
{pendingContractJobs.map(job => (
|
||||
<li key={job.id}>
|
||||
{canCleanup ? (
|
||||
<Link className="compact-main" to="/contract-cleanup">
|
||||
<span className="compact-title"><code>{job.job_no}</code></span>
|
||||
<span className="compact-sub">
|
||||
{job.file_count} 个文件 · {new Date(job.created_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="compact-main">
|
||||
<span className="compact-title"><code>{job.job_no}</code></span>
|
||||
<span className="compact-sub">
|
||||
{job.file_count} 个文件 · {new Date(job.created_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="compact-tag">{CONTRACT_STATUS_LABELS[job.status] || job.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
|
||||
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><h2>快捷入口</h2></div></div>{canEditInquiry && <Link to="/inquiries"><InquiryIcon size={16} /> 新建询盘</Link>}{canEditCustomer && <Link to="/customers"><UsersIcon size={16} /> 新增客户</Link>}{canCleanup && <Link to="/contract-cleanup"><CleanupIcon size={16} /> 导入合同</Link>}</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user