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 (
单击日期选中,再次单击添加日程 · 悬浮日程或节日查看详情 备注:{tip.item.description} {tip.item.note}
+
+
+ )}
+ {tip.item.description &&
把今天的节奏、客户和订单放在同一个视野里。