diff --git a/backend/server.js b/backend/server.js index ae2880b..e5649c6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -352,6 +352,73 @@ app.get('/api/search', (req, res) => { } }); +// --------------------------------------------------------------------------- +// 日程 / 待办(工作日历用) +// 日历按可见网格的日期范围取数,包含已完成项,便于区分状态。 +// --------------------------------------------------------------------------- +const PRIORITIES = ['high', 'medium', 'low']; + +app.get('/api/todos', (req, res) => { + try { + const from = String(req.query.from || '').slice(0, 10); + const to = String(req.query.to || '').slice(0, 10); + const conditions = []; + const params = []; + if (/^\d{4}-\d{2}-\d{2}$/.test(from)) { conditions.push('substr(t.due_date, 1, 10) >= ?'); params.push(from); } + if (/^\d{4}-\d{2}-\d{2}$/.test(to)) { conditions.push('substr(t.due_date, 1, 10) <= ?'); params.push(to); } + + const todos = db.prepare(` + SELECT t.id, t.title, t.description, t.due_date, t.priority, t.status, t.customer_id, + t.created_at, t.completed_at, c.company AS customer_name + FROM todos t + LEFT JOIN customers c ON t.customer_id = c.id + ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : 'WHERE t.due_date IS NOT NULL'} + ORDER BY t.due_date ASC, + CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END + LIMIT 500 + `).all(...params); + + res.json(todos); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/todos', (req, res) => { + try { + const title = String(req.body?.title || '').trim().slice(0, 200); + if (!title) return res.status(400).json({ error: '请填写日程标题' }); + + const rawDueDate = String(req.body?.due_date || '').slice(0, 10); + const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(rawDueDate) ? rawDueDate : null; + if (!dueDate) return res.status(400).json({ error: '日期格式应为 YYYY-MM-DD' }); + + const description = String(req.body?.description || '').trim().slice(0, 1000) || null; + const priority = PRIORITIES.includes(req.body?.priority) ? req.body.priority : 'medium'; + const now = new Date().toISOString(); + + const result = db.prepare(` + INSERT INTO todos (title, description, due_date, priority, status, created_at) + VALUES (?, ?, ?, ?, 'pending', ?) + `).run(title, description, dueDate, priority, now); + + // 活动流;activities 表缺失或字段约束变化时不影响主流程 + try { + db.prepare(` + INSERT INTO activities (activity_type, subject, content, created_at) + VALUES ('todo', ?, ?, ?) + `).run('新建日程', `${dueDate} ${title}`, now); + } catch (activityError) { + console.warn('写入活动流失败(已忽略):', activityError.message); + } + + const todo = db.prepare('SELECT * FROM todos WHERE id = ?').get(result.lastInsertRowid); + res.json({ id: result.lastInsertRowid, todo, message: '日程已添加' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // SPA fallback - 所有未匹配的路由返回 index.html app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 416c672..3883c0b 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import axios from 'axios' import { ActivityIcon, @@ -6,6 +6,7 @@ import { CalendarIcon, CheckIcon, ClipboardIcon, + CloseIcon, DollarIcon, FactoryIcon, FlagIcon, @@ -16,68 +17,493 @@ import { } from '../components/Icons' const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] +const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] -function toDateKey(date) { - const year = date.getFullYear() - const month = `${date.getMonth() + 1}`.padStart(2, '0') - const day = `${date.getDate()}`.padStart(2, '0') - return `${year}-${month}-${day}` +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 + +// 春节(农历正月初一)公历日期,逐年查表;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 CalendarBoard({ todos }) { +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}` +} + +/** 某年某月的第 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' + } +} + +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 composerRef = useRef(null) + const tipRef = 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 daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate() - const previousDays = new Date(month.getFullYear(), month.getMonth(), 0).getDate() - const calendarDays = useMemo(() => { - return Array.from({ length: 42 }, (_, index) => { - const offset = index - monthStartDay + 1 - const date = new Date(month.getFullYear(), month.getMonth(), offset) + 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]) + ), [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 } }) + 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 = {} - todos.forEach(todo => { - if (!todo.due_date) return - const key = todo.due_date.slice(0, 10) - result[key] = [...(result[key] || []), { label: todo.title, tone: todo.priority === 'high' ? 'danger' : 'blue' }] + 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: '' + }) }) - const year = month.getFullYear() - const holidays = [ - { date: `${year}-01-01`, label: '元旦 · 节日', tone: 'holiday' }, - { date: `${year}-05-01`, label: '劳动节 · 节日', tone: 'holiday' }, - { date: `${year}-10-01`, label: '国庆节 · 节日', tone: 'holiday' }, - { date: `${year}-12-25`, label: '圣诞节 · 节日', tone: 'holiday' }, - ] - holidays.forEach(event => { result[event.date] = [...(result[event.date] || []), event] }) + + 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 - }, [month, todos]) + }, [holidayYears, monthTodos]) + + 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 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) { + anchorElRef.current = event.currentTarget + setComposerAnchor({ ...anchorFor(event.currentTarget, COMPOSER_WIDTH, 330), settled: false }) + setComposerKey(key) + setDraft({ title: '', priority: 'medium', description: '' }) + setFormError('') + setSelectedKey(key) + 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) + } + 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 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() + }) + 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]

工作日历

+
+ [SCHEDULE] +

工作日历

+

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

+
{monthLabel}
{WEEKDAYS.map(day =>
{day}
)}
{calendarDays.map(({ date, currentMonth, key }) => { const dayEvents = events[key] || [] - const isToday = key === toDateKey(today) - return
- {date.getDate()} -
{dayEvents.slice(0, 3).map((event, index) => {event.label})}
-
+ const isToday = key === todayKey + const isSelected = key === selectedKey + const overflow = dayEvents.slice(3) + const label = `${describeDate(key)}${dayEvents.length ? `,${dayEvents.length} 项日程` : ''}${isSelected ? ',已选中' : ''}` + return ( +
handleDayClick(event, key)} + onKeyDown={(event) => handleDayKeyDown(event, key)} + > + {date.getDate()} +
+ {dayEvents.slice(0, 3).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 && ( +
+
+
+
+ 添加日程 + {describeDate(composerKey)} +
+ +
+ +
+ 优先级 +
+ {PRIORITY_OPTIONS.map(option => ( + + ))} +
+
+