Files
happy-export/frontend/src/pages/Dashboard.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

1225 lines
52 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, { 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,
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 = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const PRIORITY_OPTIONS = [
{ value: 'high', label: '高' },
{ value: 'medium', label: '中' },
{ value: 'low', label: '低' }
]
const PRIORITY_LABELS = { high: '高', medium: '中', low: '低' }
const COMPOSER_WIDTH = 306
const TIP_WIDTH = 268
const JUMP_WIDTH = 262
const JUMP_HEIGHT = 292
const WHEEL_WIDTH = 219
const WHEEL_HEIGHT = 168
const WHEEL_YEAR_SPAN = { back: 12, forward: 20 }
const MAX_CELL_EVENTS = 2
// 春节(农历正月初一)公历日期,逐年查表;2025–2035 与天文台公布数据一致。
const SPRING_FESTIVAL = {
2025: '2025-01-29', 2026: '2026-02-17', 2027: '2027-02-06', 2028: '2028-01-26',
2029: '2029-02-13', 2030: '2030-02-03', 2031: '2031-01-23', 2032: '2032-02-11',
2033: '2033-01-31', 2034: '2034-02-19', 2035: '2035-02-08'
}
function pad2(value) {
return `${value}`.padStart(2, '0')
}
function toDateKey(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
}
/** 把 YYYY-MM-DD 变成「2026年9月10日 周四」 */
function describeDate(key) {
const [year, month, day] = key.split('-').map(Number)
const weekday = WEEKDAY_SHORT[new Date(year, month - 1, day).getDay()]
return `${year}年${month}月${day}日 ${weekday}`
}
/** 把 YYYY-MM-DD 变成「9月10日 周四」 */
function describeDateShort(key) {
const [, month, day] = key.split('-').map(Number)
const [year] = key.split('-').map(Number)
const weekday = WEEKDAY_SHORT[new Date(year, month - 1, day).getDay()]
return `${month}月${day}日 ${weekday}`
}
/** 某年某月的第 n 个星期 weekday(0=周日) */
function nthWeekday(year, monthIndex, weekday, nth) {
const first = new Date(year, monthIndex, 1)
const offset = (weekday - first.getDay() + 7) % 7
return new Date(year, monthIndex, 1 + offset + (nth - 1) * 7)
}
/**
* 该年的节日清单:公历固定节日 + 春节 + 感恩节 / 黑色星期五。
* note 写「具体内容」,站在外贸视角说明它对出货、订舱和客户响应的影响。
*/
function holidaysOf(year) {
const list = [
{
date: `${year}-01-01`,
name: '元旦',
note: '公历新年。中国大陆放假 1 天,欧美客户同日休假,是全年邮件回复率最低的节点之一。'
},
{
date: `${year}-05-01`,
name: '劳动节',
note: '中国法定假日,通常连休 5 天(含调休)。工厂与货代节前集中出货,节中报关、拖车基本停摆。'
},
{
date: `${year}-10-01`,
name: '国庆节',
note: '中国法定假日,通常连休 7 天(含调休)。节前是出货高峰,节后第一周订舱紧张、运价常上浮。'
},
{
date: `${year}-12-25`,
name: '圣诞节',
note: '欧美客户最重要的节日,12 月 24 日起多数公司进入假期,下单与回款停滞至次年 1 月上旬。'
}
]
const springFestival = SPRING_FESTIVAL[year]
if (springFestival) {
list.push({
date: springFestival,
name: '春节',
note: '农历正月初一。中国法定假日,工厂通常停工 2–4 周,节前需提前 4–6 周排产与订舱。'
})
}
const thanksgiving = nthWeekday(year, 10, 4, 4)
list.push({
date: toDateKey(thanksgiving),
name: '感恩节',
note: '美国法定假日(11 月第 4 个周四)。美国客户休假,也是北美年末采购季的开端。'
})
const blackFriday = new Date(thanksgiving.getFullYear(), thanksgiving.getMonth(), thanksgiving.getDate() + 1)
list.push({
date: toDateKey(blackFriday),
name: '黑色星期五',
note: '感恩节次日,北美全年最大的促销节点。零售端的补货订单通常要提前到 9–10 月下达。'
})
return list
}
/** 以触发元素为锚点计算浮层坐标,下方放不下时翻到上方,并夹在视口内 */
function anchorFor(element, width, estimatedHeight = 240, gap = 8) {
const rect = element.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const flipUp = rect.bottom + gap + estimatedHeight > viewportHeight && rect.top - gap - estimatedHeight > 0
const maxLeft = Math.max(12, viewportWidth - width - 12)
return {
left: Math.min(Math.max(12, rect.left), maxLeft),
top: flipUp ? rect.top - gap : rect.bottom + gap,
placement: flipUp ? 'above' : 'below'
}
}
/**
* 今日 to do —— 轻量备忘录。
* 回车新增;勾选后划线变淡并沉到列表末尾(输入框始终在最底下一行)。
*/
function TodoMemo() {
const [items, setItems] = useState([])
const [draft, setDraft] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const listRef = useRef(null)
const revealIdRef = useRef(null)
const load = useCallback(async () => {
try {
const response = await axios.get('/api/todos', { params: { list: 'memo' } })
setItems(Array.isArray(response.data) ? response.data : [])
} catch (loadError) {
console.error('获取今日待办失败:', loadError)
}
}, [])
useEffect(() => { load() }, [load])
// 未完成在前(按创建顺序),已完成沉底(后完成的排更靠下,紧贴输入框)
const ordered = useMemo(() => {
const rank = (item) => (item.status === 'completed' ? 1 : 0)
return [...items].sort((a, b) => {
if (rank(a) !== rank(b)) return rank(a) - rank(b)
if (rank(a) === 1) return String(a.completed_at || '').localeCompare(String(b.completed_at || ''))
return Number(a.id) - Number(b.id)
})
}, [items])
// 列表可滚动时,勾选后把沉底的条目滚进视野,避免「点完就找不到了」
useLayoutEffect(() => {
const id = revealIdRef.current
if (!id) return
revealIdRef.current = null
const node = listRef.current?.querySelector(`[data-memo-id="${id}"]`)
if (node) node.scrollIntoView({ block: 'nearest' })
}, [ordered])
const doneCount = items.filter((item) => item.status === 'completed').length
const submit = async (event) => {
event.preventDefault()
const title = draft.trim()
if (!title || saving) return
setSaving(true)
setError('')
try {
const response = await axios.post('/api/todos', { title, list: 'memo' })
setDraft('')
if (response.data?.todo) {
setItems((current) => [...current, response.data.todo])
} else {
await load()
}
} catch (submitError) {
setError(submitError?.response?.data?.error || '添加失败,请稍后重试')
} finally {
setSaving(false)
}
}
const toggle = async (item) => {
const nextStatus = item.status === 'completed' ? 'pending' : 'completed'
const optimistic = {
...item,
status: nextStatus,
completed_at: nextStatus === 'completed' ? new Date().toISOString() : null
}
revealIdRef.current = item.id
setItems((current) => current.map((row) => (row.id === item.id ? optimistic : row)))
try {
const response = await axios.patch(`/api/todos/${item.id}`, { status: nextStatus })
if (response.data?.todo) {
setItems((current) => current.map((row) => (row.id === item.id ? response.data.todo : row)))
}
} catch (toggleError) {
console.error('更新待办状态失败:', toggleError)
setItems((current) => current.map((row) => (row.id === item.id ? item : row)))
setError('状态更新失败,请重试')
}
}
const remove = async (item) => {
setItems((current) => current.filter((row) => row.id !== item.id))
try {
await axios.delete(`/api/todos/${item.id}`)
} catch (removeError) {
console.error('删除待办失败:', removeError)
setError('删除失败,请重试')
await load()
}
}
return (
<section className="calendar-side-block is-memo">
<div className="calendar-side-head">
<div>
<h3><CheckIcon size={14} /> 今日 to do</h3>
<span className="calendar-side-sub">备忘录 · 回车确认,勾选后沉底</span>
</div>
{doneCount > 0 && <span className="calendar-side-count">{doneCount}/{items.length}</span>}
</div>
{ordered.length > 0 && (
<ul className="memo-list" ref={listRef}>
{ordered.map((item) => {
const isDone = item.status === 'completed'
return (
<li className={`memo-item ${isDone ? 'is-done' : ''}`} key={item.id} data-memo-id={item.id}>
<button
type="button"
className="memo-check"
role="checkbox"
aria-checked={isDone}
aria-label={isDone ? `取消完成 ${item.title}` : `完成 ${item.title}`}
onClick={() => toggle(item)}
>
<CheckIcon size={11} />
</button>
<span className="memo-text">{item.title}</span>
<button type="button" className="memo-remove" onClick={() => remove(item)} aria-label={`删除 ${item.title}`}>
<TrashIcon size={13} />
</button>
</li>
)
})}
</ul>
)}
<form className="memo-add" onSubmit={submit} data-saving={saving ? 'true' : 'false'}>
<span className="memo-add-icon"><PlusCircleIcon size={15} /></span>
<input
type="text"
value={draft}
maxLength={200}
placeholder="添加一条待办,回车确认"
aria-label="添加今日待办"
onChange={(event) => setDraft(event.target.value)}
/>
</form>
{error && <p className="calendar-composer-error">{error}</p>}
</section>
)
}
function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
const today = new Date()
const todayKey = toDateKey(today)
const [month, setMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1))
const [monthTodos, setMonthTodos] = useState(() => (seedTodos || []).filter(todo => todo.due_date))
const [selectedKey, setSelectedKey] = useState(null)
const [composerKey, setComposerKey] = useState(null)
const [composerAnchor, setComposerAnchor] = useState(null)
const [tip, setTip] = useState(null)
const [draft, setDraft] = useState({ title: '', priority: 'medium', description: '' })
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
// 快速跳转日期:工具条上的小日历按钮 + 浮出的迷你月历
const [jumpOpen, setJumpOpen] = useState(false)
const [jumpPos, setJumpPos] = useState(null)
const [jumpMonth, setJumpMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1))
// 跳转浮层标题:直接输入年月(前 4 位年 + 后 1~2 位月,回车跳转)
const [jumpDraft, setJumpDraft] = useState(null)
const [jumpInvalid, setJumpInvalid] = useState(false)
// 年月拨盘:仅工具条月份文字使用
const [wheelOpen, setWheelOpen] = useState(false)
const [wheelPos, setWheelPos] = useState(null)
const [wheelPeriod, setWheelPeriod] = useState(() => ({ year: today.getFullYear(), month: today.getMonth() + 1 }))
const composerRef = useRef(null)
const tipRef = useRef(null)
const jumpRef = useRef(null)
const jumpBtnRef = useRef(null)
const jumpInputRef = useRef(null)
const wheelRef = useRef(null)
const wheelAnchorRef = useRef(null)
const anchorElRef = useRef(null)
const pendingSelectRef = useRef(null)
const monthLabel = month.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long' })
const monthStartDay = (month.getDay() + 6) % 7
const calendarDays = useMemo(() => (
Array.from({ length: 42 }, (_, index) => {
const date = new Date(month.getFullYear(), month.getMonth(), index - monthStartDay + 1)
return { date, currentMonth: date.getMonth() === month.getMonth(), key: toDateKey(date) }
})
), [month, monthStartDay])
const gridFrom = calendarDays[0].key
const gridTo = calendarDays[calendarDays.length - 1].key
const holidayYears = useMemo(
() => [...new Set(calendarDays.map(day => day.date.getFullYear()))],
[calendarDays]
)
// 日历按可见网格范围取数(含已完成),与工作台的待办列表各取所需。
const loadTodos = useCallback(async () => {
try {
const response = await axios.get('/api/todos', { params: { from: gridFrom, to: gridTo, list: 'schedule' } })
setMonthTodos(Array.isArray(response.data) ? response.data.filter(todo => todo.due_date) : [])
} catch (error) {
console.error('获取日历日程失败:', error)
}
}, [gridFrom, gridTo])
useEffect(() => { loadTodos() }, [loadTodos])
const events = useMemo(() => {
const result = {}
const push = (key, item) => {
result[key] = [...(result[key] || []), { ...item, dateLabel: describeDate(key) }]
}
monthTodos.forEach(todo => {
const key = String(todo.due_date).slice(0, 10)
const done = todo.status === 'completed'
push(key, {
kind: 'todo',
key: `todo-${todo.id}`,
label: todo.title,
title: todo.title,
tone: done ? 'done' : todo.priority === 'high' ? 'danger' : 'blue',
priority: todo.priority,
status: todo.status,
customer: todo.customer_name || '',
description: todo.description || '',
note: ''
})
})
holidayYears.forEach(year => holidaysOf(year).forEach(holiday => push(holiday.date, {
kind: 'holiday',
key: `holiday-${holiday.date}`,
label: `${holiday.name} · 节日`,
title: holiday.name,
tone: 'holiday',
note: holiday.note
})))
return result
}, [holidayYears, monthTodos])
// 没显式选中时,右侧面板默认看今天
const activeKey = selectedKey || todayKey
const activeEvents = events[activeKey] || []
// 迷你月历:按真实天数出行数,避免六月/九月都留一行空
const jumpStartDay = (jumpMonth.getDay() + 6) % 7
const jumpRows = Math.ceil((jumpStartDay + new Date(jumpMonth.getFullYear(), jumpMonth.getMonth() + 1, 0).getDate()) / 7)
const jumpDays = useMemo(() => (
Array.from({ length: jumpRows * 7 }, (_, index) => {
const date = new Date(jumpMonth.getFullYear(), jumpMonth.getMonth(), index - jumpStartDay + 1)
return { date, key: toDateKey(date), currentMonth: date.getMonth() === jumpMonth.getMonth() }
})
), [jumpMonth, jumpStartDay, jumpRows])
const closeComposer = useCallback(() => {
setComposerKey(null)
setComposerAnchor(null)
setFormError('')
}, [])
const hideTip = useCallback(() => setTip(null), [])
// 渲染后按真实高度算一次校正量,避免估算偏差把浮层顶出视口
const settleShift = useCallback((element) => {
if (!element) return 0
const rect = element.getBoundingClientRect()
const margin = 10
const overflowBottom = rect.bottom - (window.innerHeight - margin)
const overflowTop = margin - rect.top
if (overflowBottom > 0) return -overflowBottom
if (overflowTop > 0) return overflowTop
return 0
}, [])
const openComposer = useCallback((key, element) => {
anchorElRef.current = element
setComposerAnchor({ ...anchorFor(element, COMPOSER_WIDTH, 330), settled: false })
setComposerKey(key)
setDraft({ title: '', priority: 'medium', description: '' })
setFormError('')
setSelectedKey(key)
}, [])
const handleDayClick = (event, key) => {
// 面板打开时点击日期由 document 的 mousedown 接管,这里只负责落到新选中日
if (pendingSelectRef.current) {
const pending = pendingSelectRef.current
pendingSelectRef.current = null
setSelectedKey(pending)
return
}
if (composerKey === key) { closeComposer(); return }
if (composerKey) { closeComposer(); setSelectedKey(key); return }
if (selectedKey === key) { openComposer(key, event.currentTarget); return }
setSelectedKey(key)
}
const handleDayKeyDown = (event, key) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
handleDayClick(event, key)
}
const showTip = (event, item) => setTip({ anchor: anchorFor(event.currentTarget, TIP_WIDTH, 200), item, settled: false })
// 每个浮层只校正一次(settled 标记),校正量不产生新的依赖变化,避免反复 setState
useLayoutEffect(() => {
if (!composerKey || !composerAnchor || composerAnchor.settled) return
const shift = settleShift(composerRef.current)
setComposerAnchor(current => (current ? { ...current, settled: true, top: shift ? current.top + shift : current.top } : current))
}, [composerAnchor, composerKey, settleShift])
useLayoutEffect(() => {
if (!tip || tip.settled) return
const shift = settleShift(tipRef.current)
setTip(current => (current ? { ...current, settled: true, anchor: shift ? { ...current.anchor, top: current.anchor.top + shift } : current.anchor } : current))
}, [tip, settleShift])
// Esc 收起面板与浮窗
useEffect(() => {
const onKeyDown = (event) => {
if (event.key !== 'Escape') return
closeComposer()
setTip(null)
setJumpOpen(false)
setWheelOpen(false)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [closeComposer])
// 面板打开时,点击面板外即收起;点到日期格则顺带选中那一天
useEffect(() => {
if (!composerKey) return undefined
const onPointerDown = (event) => {
if (composerRef.current?.contains(event.target)) return
const dayEl = event.target.closest?.('.calendar-day')
pendingSelectRef.current = dayEl?.dataset?.dateKey || null
closeComposer()
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [composerKey, closeComposer])
// 滚动 / 缩放时跟着锚点走,避免浮层飘在原地
useEffect(() => {
if (!composerKey) return undefined
const reposition = () => {
if (anchorElRef.current) setComposerAnchor({ ...anchorFor(anchorElRef.current, COMPOSER_WIDTH, 330), settled: false })
}
window.addEventListener('scroll', reposition, true)
window.addEventListener('resize', reposition)
return () => {
window.removeEventListener('scroll', reposition, true)
window.removeEventListener('resize', reposition)
}
}, [composerKey])
useEffect(() => {
if (!tip) return undefined
window.addEventListener('scroll', hideTip, true)
window.addEventListener('resize', hideTip)
return () => {
window.removeEventListener('scroll', hideTip, true)
window.removeEventListener('resize', hideTip)
}
}, [tip, hideTip])
const shiftMonth = (delta) => setMonth(current => new Date(current.getFullYear(), current.getMonth() + delta, 1))
const resetMonth = () => setMonth(new Date(today.getFullYear(), today.getMonth(), 1))
/* 跳转浮层挂在工具条按钮右缘(右对齐),下方放不下就翻到上方 */
const positionJump = useCallback((element) => {
const rect = element.getBoundingClientRect()
const left = Math.min(Math.max(12, rect.right - JUMP_WIDTH), Math.max(12, window.innerWidth - JUMP_WIDTH - 12))
const flipUp = rect.bottom + 9 + JUMP_HEIGHT > window.innerHeight && rect.top - 9 - JUMP_HEIGHT > 0
setJumpPos({ left, top: flipUp ? rect.top - 9 : rect.bottom + 9, placement: flipUp ? 'above' : 'below' })
}, [])
const toggleJump = (event) => {
if (jumpOpen) { setJumpOpen(false); return }
jumpBtnRef.current = event.currentTarget
positionJump(event.currentTarget)
setJumpMonth(new Date(month.getFullYear(), month.getMonth(), 1))
setJumpOpen(true)
}
const pickJumpDay = (key) => {
const [year, monthIndex] = key.split('-').map(Number)
setMonth(new Date(year, monthIndex - 1, 1))
setSelectedKey(key)
setJumpOpen(false)
}
/* 年月拨盘:以触发文字为锚点,水平居中于它,下方放不下就翻到上方 */
const positionWheel = useCallback((element) => {
const rect = element.getBoundingClientRect()
const centered = rect.left + rect.width / 2 - WHEEL_WIDTH / 2
const left = Math.min(Math.max(12, centered), Math.max(12, window.innerWidth - WHEEL_WIDTH - 12))
const flipUp = rect.bottom + 9 + WHEEL_HEIGHT > window.innerHeight && rect.top - 9 - WHEEL_HEIGHT > 0
setWheelPos({ left, top: flipUp ? rect.top - 9 : rect.bottom + 9, placement: flipUp ? 'above' : 'below' })
}, [])
const openWheel = (element) => {
if (!element) return
wheelAnchorRef.current = element
setWheelPeriod({ year: month.getFullYear(), month: month.getMonth() + 1 })
positionWheel(element)
setWheelOpen(true)
// 从跳转浮层里点进来时,把浮层收掉,只留拨盘
setJumpOpen(false)
}
const closeWheel = () => setWheelOpen(false)
/* 工具条上的月份文字:再点一次即收起 */
const toggleWheelFromBar = (element) => {
if (wheelOpen && wheelAnchorRef.current === element) { closeWheel(); return }
openWheel(element)
}
/* 拨盘选定年月:主日历与跳转浮层同步过去 */
const applyWheelPeriod = (nextYear, nextMonth) => {
setWheelPeriod({ year: nextYear, month: nextMonth })
const next = new Date(nextYear, nextMonth - 1, 1)
setMonth(next)
setJumpMonth(next)
}
const wheelGoToday = () => applyWheelPeriod(today.getFullYear(), today.getMonth() + 1)
/* 年月两种写法:「2026年9月」↔「202609」 */
const compactPeriod = (date) => `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`
/* 进入编辑态:换成紧凑数字串并全选,方便直接改;已在编辑中就不打断输入 */
const primeJumpInput = () => {
if (jumpDraft !== null) return
setJumpInvalid(false)
setJumpDraft(compactPeriod(jumpMonth))
requestAnimationFrame(() => jumpInputRef.current?.select())
}
const changeJumpInput = (event) => {
setJumpDraft(event.target.value.replace(/\D/g, '').slice(0, 6))
if (jumpInvalid) setJumpInvalid(false)
}
/* 前 4 位是年,剩下 1~2 位是月;只写 4 位则沿用当前月 */
const commitJumpInput = () => {
const digits = (jumpDraft ?? '').replace(/\D/g, '')
if (digits.length < 4) { setJumpInvalid(true); return false }
const year = Number(digits.slice(0, 4))
const tail = digits.slice(4)
const monthNum = tail ? Number(tail) : jumpMonth.getMonth() + 1
if (year < 1900 || year > 2999 || monthNum < 1 || monthNum > 12) { setJumpInvalid(true); return false }
setJumpMonth(new Date(year, monthNum - 1, 1))
setJumpInvalid(false)
setJumpDraft(null)
jumpInputRef.current?.blur()
return true
}
const cancelJumpInput = () => {
setJumpDraft(null)
setJumpInvalid(false)
}
// 点空白处收起跳转浮层(点按钮本身交给 toggleJump 处理)
useEffect(() => {
if (!jumpOpen) return undefined
const onPointerDown = (event) => {
if (jumpRef.current?.contains(event.target)) return
if (event.target.closest?.('.calendar-jump-btn')) return
setJumpOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [jumpOpen])
// 滚动 / 缩放时跟着按钮走
useEffect(() => {
if (!jumpOpen) return undefined
const reposition = () => { if (jumpBtnRef.current) positionJump(jumpBtnRef.current) }
window.addEventListener('scroll', reposition, true)
window.addEventListener('resize', reposition)
return () => {
window.removeEventListener('scroll', reposition, true)
window.removeEventListener('resize', reposition)
}
}, [jumpOpen, positionJump])
// 点空白处收起拨盘(点触发文字本身交给 openWheel 处理)
useEffect(() => {
if (!wheelOpen) return undefined
const onPointerDown = (event) => {
if (wheelRef.current?.contains(event.target)) return
if (event.target.closest?.('.calendar-month-btn')) return
closeWheel()
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [wheelOpen])
// 页面滚动 / 缩放时跟着锚点走;锚点已从 DOM 摘掉(例如跳转浮层关了)就直接收起
useEffect(() => {
if (!wheelOpen) return undefined
const follow = () => {
const anchor = wheelAnchorRef.current
if (!anchor || !anchor.isConnected || !anchor.getBoundingClientRect().width) { closeWheel(); return }
positionWheel(anchor)
}
window.addEventListener('scroll', follow, true)
window.addEventListener('resize', follow)
return () => {
window.removeEventListener('scroll', follow, true)
window.removeEventListener('resize', follow)
}
}, [wheelOpen, positionWheel])
const submitDraft = async (event) => {
event.preventDefault()
const title = draft.title.trim()
if (!title) { setFormError('请填写日程标题'); return }
setSaving(true)
setFormError('')
try {
await axios.post('/api/todos', {
title,
due_date: composerKey,
priority: draft.priority,
description: draft.description.trim(),
list: 'schedule'
})
setSelectedKey(composerKey)
closeComposer()
setDraft({ title: '', priority: 'medium', description: '' })
await loadTodos()
if (onTodoCreated) onTodoCreated()
} catch (error) {
setFormError(error?.response?.data?.error || '添加失败,请稍后重试')
} finally {
setSaving(false)
}
}
return (
<section className="calendar-board dashboard-panel">
<div className="dashboard-panel-header calendar-board-header">
<div>
<h2><CalendarIcon size={18} /> 工作日历</h2>
<p className="calendar-hint">单击日期选中,再次单击添加日程 · 悬浮日程或节日查看详情</p>
</div>
<div className="calendar-toolbar"><button className="calendar-today-btn" onClick={resetMonth}>今天</button><button onClick={() => shiftMonth(-1)} aria-label="上个月">‹</button><button type="button" className={`calendar-month-btn${wheelOpen ? ' is-open' : ''}`} onClick={(event) => toggleWheelFromBar(event.currentTarget)} aria-haspopup="dialog" aria-expanded={wheelOpen} title="滚轮选择年月">{monthLabel}</button><button onClick={() => shiftMonth(1)} aria-label="下个月">›</button><button type="button" ref={jumpBtnRef} className={`calendar-jump-btn${jumpOpen ? ' is-open' : ''}`} onClick={toggleJump} aria-label="快速跳转日期" aria-expanded={jumpOpen} title="快速跳转日期"><CalendarIcon size={16} /></button></div>
</div>
<div className="calendar-layout">
<div className="calendar-main">
<div className="calendar-week-row">{WEEKDAYS.map(day => <div key={day}>{day}</div>)}</div>
<div className="calendar-grid">
{calendarDays.map(({ date, currentMonth, key }) => {
const dayEvents = events[key] || []
const isToday = key === todayKey
const isSelected = key === selectedKey
const overflow = dayEvents.slice(MAX_CELL_EVENTS)
const label = `${describeDate(key)}${dayEvents.length ? `,${dayEvents.length} 项日程` : ''}${isSelected ? ',已选中' : ''}`
return (
<div
className={`calendar-day ${currentMonth ? '' : 'is-outside'} ${isToday ? 'is-today' : ''} ${isSelected ? 'is-selected' : ''} ${key === composerKey ? 'is-composing' : ''}`}
key={key}
data-date-key={key}
role="button"
tabIndex={0}
aria-label={label}
onClick={(event) => handleDayClick(event, key)}
onKeyDown={(event) => handleDayKeyDown(event, key)}
>
<span className="calendar-day-number">{date.getDate()}</span>
<div className="calendar-events">
{dayEvents.slice(0, MAX_CELL_EVENTS).map(event => (
<span
className={`calendar-event ${event.tone}`}
key={event.key}
onMouseEnter={(mouseEvent) => showTip(mouseEvent, event)}
onMouseLeave={hideTip}
>
{event.label}
</span>
))}
{overflow.length > 0 && (
<span
className="calendar-more"
onMouseEnter={(mouseEvent) => showTip(mouseEvent, { kind: 'more', title: `还有 ${overflow.length} 项`, items: overflow })}
onMouseLeave={hideTip}
>
+{overflow.length}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
<aside className="calendar-side">
<TodoMemo />
<section className="calendar-side-block is-day">
<div className="calendar-side-head">
<div>
<h3><PinIcon size={14} /> 待办事项</h3>
<span className="calendar-side-sub">{describeDate(activeKey)}</span>
</div>
<button
type="button"
className="calendar-side-add"
onClick={(event) => openComposer(activeKey, event.currentTarget)}
>
<PlusCircleIcon size={14} /> 新建
</button>
</div>
{activeEvents.length === 0 ? (
<p className="calendar-side-empty">这一天还没有安排。</p>
) : (
<ul className="day-list">
{activeEvents.map(item => (
<li
className={`day-item ${item.kind === 'holiday' ? 'is-holiday' : ''} ${item.status === 'completed' ? 'is-done' : ''}`}
key={item.key}
>
<span className={`day-dot ${item.tone}`} />
<div className="day-body">
<span className="day-title">{item.title}</span>
{item.kind === 'holiday' ? (
<span className="day-note">{item.note}</span>
) : (
<span className="day-meta">
{PRIORITY_LABELS[item.priority] || '中'}优先级
{item.customer ? ` · ${item.customer}` : ''}
{item.status === 'completed' ? ' · 已完成' : ''}
</span>
)}
</div>
</li>
))}
</ul>
)}
</section>
</aside>
</div>
{composerKey && composerAnchor && (
<div
className="calendar-popover calendar-composer"
ref={composerRef}
role="dialog"
aria-label={`添加日程 ${describeDate(composerKey)}`}
style={{
left: composerAnchor.left,
top: composerAnchor.top,
transform: composerAnchor.placement === 'above' ? 'translateY(-100%)' : 'none'
}}
>
<form onSubmit={submitDraft}>
<div className="calendar-composer-head">
<div>
<strong>添加日程</strong>
<small>{describeDateShort(composerKey)}</small>
</div>
<button type="button" className="calendar-popover-close" onClick={closeComposer} aria-label="关闭"><CloseIcon size={14} /></button>
</div>
<label className="calendar-field">
<span>标题</span>
<input
type="text"
autoFocus
maxLength={200}
value={draft.title}
placeholder="例如:给德国客户回报价单"
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
</label>
<div className="calendar-field">
<span>优先级</span>
<div className="calendar-priority-row">
{PRIORITY_OPTIONS.map(option => (
<button
type="button"
key={option.value}
className={draft.priority === option.value ? 'is-active' : ''}
onClick={() => setDraft({ ...draft, priority: option.value })}
>
{option.label}
</button>
))}
</div>
</div>
<label className="calendar-field">
<span>备注</span>
<textarea
maxLength={1000}
value={draft.description}
placeholder="可选,补充跟进要点"
onChange={(event) => setDraft({ ...draft, description: event.target.value })}
/>
</label>
{formError && <p className="calendar-composer-error">{formError}</p>}
<div className="calendar-composer-actions">
<button type="button" className="is-ghost" onClick={closeComposer}>取消</button>
<button type="submit" className="is-primary" disabled={saving}>{saving ? '保存中…' : '保存'}</button>
</div>
</form>
</div>
)}
{tip && tip.anchor && (
<div
className="calendar-popover calendar-tip"
ref={tipRef}
role="tooltip"
style={{
left: tip.anchor.left,
top: tip.anchor.top,
transform: tip.anchor.placement === 'above' ? 'translateY(-100%)' : 'none'
}}
>
{tip.item.kind === 'more' ? (
<React.Fragment>
<div className="calendar-tip-head"><span className="calendar-tip-kind is-todo">日程</span><span className="calendar-tip-title">{tip.item.title}</span></div>
<div className="calendar-tip-more">{tip.item.items.map(item => <span key={item.key}>· {item.label}</span>)}</div>
</React.Fragment>
) : (
<React.Fragment>
<div className="calendar-tip-head">
<span className={`calendar-tip-kind ${tip.item.kind === 'holiday' ? 'is-holiday' : 'is-todo'}`}>{tip.item.kind === 'holiday' ? '节日' : '日程'}</span>
<span className="calendar-tip-date">{tip.item.dateLabel}</span>
</div>
<div className="calendar-tip-title">{tip.item.title}</div>
{tip.item.kind !== 'holiday' && (
<dl className="calendar-tip-body">
<div className="calendar-tip-row"><dt>优先级</dt><dd>{PRIORITY_LABELS[tip.item.priority] || '中'}</dd></div>
<div className="calendar-tip-row"><dt>状态</dt><dd>{tip.item.status === 'completed' ? '已完成' : '待处理'}</dd></div>
{tip.item.customer && <div className="calendar-tip-row"><dt>客户</dt><dd>{tip.item.customer}</dd></div>}
</dl>
)}
{tip.item.description && <p className="calendar-tip-note">备注:{tip.item.description}</p>}
{tip.item.note && <p className="calendar-tip-note">{tip.item.note}</p>}
</React.Fragment>
)}
</div>
)}
{jumpOpen && jumpPos && (
<div
className="calendar-popover calendar-jump-pop"
ref={jumpRef}
role="dialog"
aria-label="快速跳转日期"
style={{
left: jumpPos.left,
top: jumpPos.top,
transform: jumpPos.placement === 'above' ? 'translateY(-100%)' : 'none'
}}
>
<div className="jump-head">
<button type="button" onClick={() => setJumpMonth(current => new Date(current.getFullYear(), current.getMonth() - 1, 1))} aria-label="上个月">‹</button>
<input
ref={jumpInputRef}
type="text"
className={`jump-month-input${jumpInvalid ? ' is-invalid' : ''}`}
value={jumpDraft ?? `${jumpMonth.getFullYear()}年${jumpMonth.getMonth() + 1}月`}
onMouseDown={primeJumpInput}
onFocus={primeJumpInput}
onChange={changeJumpInput}
onKeyDown={(event) => {
if (event.key === 'Enter') { event.preventDefault(); commitJumpInput(); return }
if (event.key === 'Escape') { event.stopPropagation(); event.preventDefault(); cancelJumpInput(); event.currentTarget.blur() }
}}
onBlur={cancelJumpInput}
inputMode="numeric"
autoComplete="off"
spellCheck={false}
aria-label="输入年月快速跳转"
aria-invalid={jumpInvalid || undefined}
title="前 4 位是年、后 1~2 位是月,如 202609,回车跳转"
/>
<button type="button" onClick={() => setJumpMonth(current => new Date(current.getFullYear(), current.getMonth() + 1, 1))} aria-label="下个月">›</button>
</div>
<div className="jump-week">{WEEKDAYS.map(day => <span key={day}>{day.slice(1)}</span>)}</div>
<div className="jump-grid">
{jumpDays.map(({ date, key, currentMonth }) => (
<button
type="button"
key={key}
className={`jump-day${currentMonth ? '' : ' is-outside'}${key === todayKey ? ' is-today' : ''}${key === selectedKey ? ' is-selected' : ''}`}
onClick={() => pickJumpDay(key)}
aria-label={describeDate(key)}
title={describeDate(key)}
>
{date.getDate()}
</button>
))}
</div>
<button type="button" className="jump-today" onClick={() => pickJumpDay(todayKey)}>回到今天</button>
</div>
)}
{wheelOpen && wheelPos && (
<div
className="calendar-popover calendar-wheel-pop"
ref={wheelRef}
role="dialog"
aria-label="选择年月"
style={{
left: wheelPos.left,
top: wheelPos.top,
transform: wheelPos.placement === 'above' ? 'translateY(-100%)' : 'none'
}}
>
<MonthYearWheel
year={wheelPeriod.year}
month={wheelPeriod.month}
onChange={applyWheelPeriod}
onToday={wheelGoToday}
minYear={Math.min(today.getFullYear() - WHEEL_YEAR_SPAN.back, month.getFullYear())}
maxYear={Math.max(today.getFullYear() + WHEEL_YEAR_SPAN.forward, month.getFullYear())}
/>
</div>
)}
</section>
)
}
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')
setData(response.data)
} catch (error) {
console.error('获取工作台数据失败:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadDashboard() }, [loadDashboard])
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,
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><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><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><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><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>
)
}
function StatLine({ icon, label, value }) { return <div className="stat-line"><span className="stat-line-icon">{icon}</span><span>{label}</span><strong>{value}</strong></div> }
export default Dashboard