import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import axios from 'axios' import { ActivityIcon, CalendarIcon, CheckIcon, ClipboardIcon, CloseIcon, DollarIcon, FactoryIcon, PackageIcon, PinIcon, PlusCircleIcon, SparkIcon, TrashIcon, UsersIcon } from '../components/Icons' import MonthYearWheel from '../components/MonthYearWheel' 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 (

今日 to do

备忘录 · 回车确认,勾选后沉底
{doneCount > 0 && {doneCount}/{items.length}}
{ordered.length > 0 && ( )}
setDraft(event.target.value)} />
{error &&

{error}

}
) } 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)) // 年月拨盘:工具条月份文字与跳转浮层标题共用 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 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) // 点空白处收起跳转浮层(点按钮本身交给 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 (
[SCHEDULE]

工作日历

单击日期选中,再次单击添加日程 · 悬浮日程或节日查看详情

{WEEKDAYS.map(day =>
{day}
)}
{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 (
handleDayClick(event, key)} onKeyDown={(event) => handleDayKeyDown(event, key)} > {date.getDate()}
{dayEvents.slice(0, MAX_CELL_EVENTS).map(event => ( showTip(mouseEvent, event)} onMouseLeave={hideTip} > {event.label} ))} {overflow.length > 0 && ( showTip(mouseEvent, { kind: 'more', title: `还有 ${overflow.length} 项`, items: overflow })} onMouseLeave={hideTip} > +{overflow.length} )}
) })}
{composerKey && composerAnchor && (
添加日程 {describeDateShort(composerKey)}
优先级
{PRIORITY_OPTIONS.map(option => ( ))}