feat(calendar): 工作日历选中态 + 再次单击添加日程 + 日程/节日悬浮详情
- 单击日期选中(主题色底 + 内描边环),再次单击同一日期弹出「添加日程」浮层 - 浮层支持标题 / 优先级 / 备注,保存后写库并即时刷新日历与工作台待办 - 悬浮日程或节日显示详情小窗:日程含日期/优先级/状态/客户/备注, 节日含外贸视角的具体说明(对出货、订舱、客户响应的影响) - 节日补充春节(2025–2035 逐年查表)、感恩节与黑色星期五 - 超过 3 项时折叠为 +N,悬浮查看剩余条目 - 后端新增 GET /api/todos(按日期范围)与 POST /api/todos - 浮层用 fixed 定位 + 视口翻转/夹取,渲染后按真实高度校正一次; 滚动跟随锚点;Esc 与点击外部关闭;日期格支持键盘 Enter/Space
This commit is contained in:
@@ -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
|
// SPA fallback - 所有未匹配的路由返回 index.html
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||||||
|
|||||||
@@ -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 axios from 'axios'
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ClipboardIcon,
|
ClipboardIcon,
|
||||||
|
CloseIcon,
|
||||||
DollarIcon,
|
DollarIcon,
|
||||||
FactoryIcon,
|
FactoryIcon,
|
||||||
FlagIcon,
|
FlagIcon,
|
||||||
@@ -16,68 +17,493 @@ import {
|
|||||||
} from '../components/Icons'
|
} from '../components/Icons'
|
||||||
|
|
||||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
|
const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
|
||||||
function toDateKey(date) {
|
const PRIORITY_OPTIONS = [
|
||||||
const year = date.getFullYear()
|
{ value: 'high', label: '高' },
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
{ value: 'medium', label: '中' },
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
{ value: 'low', label: '低' }
|
||||||
return `${year}-${month}-${day}`
|
]
|
||||||
|
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 today = new Date()
|
||||||
|
const todayKey = toDateKey(today)
|
||||||
|
|
||||||
const [month, setMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1))
|
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 monthLabel = month.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long' })
|
||||||
const monthStartDay = (month.getDay() + 6) % 7
|
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(() => {
|
const calendarDays = useMemo(() => (
|
||||||
return Array.from({ length: 42 }, (_, index) => {
|
Array.from({ length: 42 }, (_, index) => {
|
||||||
const offset = index - monthStartDay + 1
|
const date = new Date(month.getFullYear(), month.getMonth(), index - monthStartDay + 1)
|
||||||
const date = new Date(month.getFullYear(), month.getMonth(), offset)
|
|
||||||
return { date, currentMonth: date.getMonth() === month.getMonth(), key: toDateKey(date) }
|
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 events = useMemo(() => {
|
||||||
const result = {}
|
const result = {}
|
||||||
todos.forEach(todo => {
|
const push = (key, item) => {
|
||||||
if (!todo.due_date) return
|
result[key] = [...(result[key] || []), { ...item, dateLabel: describeDate(key) }]
|
||||||
const key = todo.due_date.slice(0, 10)
|
}
|
||||||
result[key] = [...(result[key] || []), { label: todo.title, tone: todo.priority === 'high' ? 'danger' : 'blue' }]
|
|
||||||
|
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 = [
|
holidayYears.forEach(year => holidaysOf(year).forEach(holiday => push(holiday.date, {
|
||||||
{ date: `${year}-01-01`, label: '元旦 · 节日', tone: 'holiday' },
|
kind: 'holiday',
|
||||||
{ date: `${year}-05-01`, label: '劳动节 · 节日', tone: 'holiday' },
|
key: `holiday-${holiday.date}`,
|
||||||
{ date: `${year}-10-01`, label: '国庆节 · 节日', tone: 'holiday' },
|
label: `${holiday.name} · 节日`,
|
||||||
{ date: `${year}-12-25`, label: '圣诞节 · 节日', tone: 'holiday' },
|
title: holiday.name,
|
||||||
]
|
tone: 'holiday',
|
||||||
holidays.forEach(event => { result[event.date] = [...(result[event.date] || []), event] })
|
note: holiday.note
|
||||||
|
})))
|
||||||
|
|
||||||
return result
|
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 shiftMonth = (delta) => setMonth(current => new Date(current.getFullYear(), current.getMonth() + delta, 1))
|
||||||
const resetMonth = () => setMonth(new Date(today.getFullYear(), today.getMonth(), 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 (
|
return (
|
||||||
<section className="calendar-board dashboard-panel">
|
<section className="calendar-board dashboard-panel">
|
||||||
<div className="dashboard-panel-header calendar-board-header">
|
<div className="dashboard-panel-header calendar-board-header">
|
||||||
<div><span className="section-label">[SCHEDULE]</span><h2><CalendarIcon size={18} /> 工作日历</h2></div>
|
<div>
|
||||||
|
<span className="section-label">[SCHEDULE]</span>
|
||||||
|
<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><strong>{monthLabel}</strong><button onClick={() => shiftMonth(1)} aria-label="下个月">›</button><button className="calendar-view-btn">月</button></div>
|
<div className="calendar-toolbar"><button className="calendar-today-btn" onClick={resetMonth}>今天</button><button onClick={() => shiftMonth(-1)} aria-label="上个月">‹</button><strong>{monthLabel}</strong><button onClick={() => shiftMonth(1)} aria-label="下个月">›</button><button className="calendar-view-btn">月</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="calendar-week-row">{WEEKDAYS.map(day => <div key={day}>{day}</div>)}</div>
|
<div className="calendar-week-row">{WEEKDAYS.map(day => <div key={day}>{day}</div>)}</div>
|
||||||
<div className="calendar-grid">
|
<div className="calendar-grid">
|
||||||
{calendarDays.map(({ date, currentMonth, key }) => {
|
{calendarDays.map(({ date, currentMonth, key }) => {
|
||||||
const dayEvents = events[key] || []
|
const dayEvents = events[key] || []
|
||||||
const isToday = key === toDateKey(today)
|
const isToday = key === todayKey
|
||||||
return <div className={`calendar-day ${currentMonth ? '' : 'is-outside'} ${isToday ? 'is-today' : ''}`} key={`${key}-${date.getTime()}`}>
|
const isSelected = key === selectedKey
|
||||||
<span className="calendar-day-number">{date.getDate()}</span>
|
const overflow = dayEvents.slice(3)
|
||||||
<div className="calendar-events">{dayEvents.slice(0, 3).map((event, index) => <span className={`calendar-event ${event.tone}`} key={`${event.label}-${index}`} title={event.label}>{event.label}</span>)}</div>
|
const label = `${describeDate(key)}${dayEvents.length ? `,${dayEvents.length} 项日程` : ''}${isSelected ? ',已选中' : ''}`
|
||||||
</div>
|
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, 3).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>
|
||||||
|
|
||||||
|
{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>{describeDate(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>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -86,10 +512,19 @@ function Dashboard() {
|
|||||||
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
|
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadDashboard = useCallback(async () => {
|
||||||
axios.get('/api/dashboard').then(response => setData(response.data)).catch(error => console.error('获取工作台数据失败:', error)).finally(() => setLoading(false))
|
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="loading">加载工作台数据…</div>
|
if (loading) return <div className="loading">加载工作台数据…</div>
|
||||||
|
|
||||||
const { stats, recentActivities, todos } = data
|
const { stats, recentActivities, todos } = data
|
||||||
@@ -99,7 +534,7 @@ function Dashboard() {
|
|||||||
<div className="dashboard-container">
|
<div className="dashboard-container">
|
||||||
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||||
|
|
||||||
<CalendarBoard todos={todos} />
|
<CalendarBoard todos={todos} onTodoCreated={loadDashboard} />
|
||||||
|
|
||||||
<div className="dashboard-content-grid">
|
<div className="dashboard-content-grid">
|
||||||
<div className="dashboard-primary-column">
|
<div className="dashboard-primary-column">
|
||||||
|
|||||||
@@ -302,6 +302,64 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
|
|||||||
.calendar-event { display: block; overflow: hidden; padding: 3px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
.calendar-event { display: block; overflow: hidden; padding: 3px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.calendar-event.danger { border-color: var(--danger); background: var(--danger-bg); color: var(--danger); }
|
.calendar-event.danger { border-color: var(--danger); background: var(--danger-bg); color: var(--danger); }
|
||||||
.calendar-event.holiday { border-color: var(--success); background: var(--success-bg); color: var(--success); }
|
.calendar-event.holiday { border-color: var(--success); background: var(--success-bg); color: var(--success); }
|
||||||
|
|
||||||
|
/* ---------- 工作日历交互:单击选中 → 再次单击添加日程;悬浮日程/节日看详情 ---------- */
|
||||||
|
.calendar-hint { margin: 7px 0 0; color: var(--text-tertiary); font-size: 11px; line-height: 1.4; }
|
||||||
|
.calendar-day { position: relative; cursor: pointer; transition: background var(--transition-fast), box-shadow var(--transition-fast); }
|
||||||
|
.calendar-day:hover { background: var(--surface-hover); }
|
||||||
|
.calendar-day.is-outside:hover { background: var(--surface-alt); opacity: .82; }
|
||||||
|
.calendar-day:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; z-index: 2; }
|
||||||
|
.calendar-day.is-selected { background: var(--primary-bg); box-shadow: inset 0 0 0 2px var(--primary); }
|
||||||
|
.calendar-day.is-outside.is-selected { opacity: 1; }
|
||||||
|
.calendar-day.is-selected .calendar-day-number { color: var(--primary-light); font-weight: 800; }
|
||||||
|
.calendar-day.is-today.is-selected .calendar-day-number { color: var(--surface-deep); }
|
||||||
|
.calendar-day.is-composing { box-shadow: inset 0 0 0 2px var(--primary); }
|
||||||
|
.calendar-event { cursor: pointer; transition: filter var(--transition-fast), transform var(--transition-fast); }
|
||||||
|
.calendar-event:hover { filter: brightness(1.15); transform: translateX(1px); }
|
||||||
|
.calendar-event.done { border-color: var(--text-tertiary); background: var(--surface-deep); color: var(--text-tertiary); text-decoration: line-through; }
|
||||||
|
.calendar-more { justify-self: start; padding: 2px 6px; border-radius: 3px; background: var(--surface-deep); color: var(--text-tertiary); font: 10px var(--font-mono); cursor: pointer; }
|
||||||
|
.calendar-more:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.calendar-popover { position: fixed; z-index: 70; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: 0 20px 52px rgba(0, 0, 0, .26), inset 0 1px 0 var(--panel-highlight); }
|
||||||
|
|
||||||
|
.calendar-composer { width: min(306px, calc(100vw - 24px)); padding: 14px; }
|
||||||
|
.calendar-composer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 11px; }
|
||||||
|
.calendar-composer-head strong { display: block; color: var(--text-primary); font-size: 13px; }
|
||||||
|
.calendar-composer-head small { display: block; margin-top: 3px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .06em; }
|
||||||
|
.calendar-popover-close { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 24px; border: 1px solid var(--border-light); border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; }
|
||||||
|
.calendar-popover-close:hover { border-color: var(--border); color: var(--text-primary); }
|
||||||
|
.calendar-field { display: block; margin-bottom: 10px; }
|
||||||
|
.calendar-field > span { display: block; margin-bottom: 5px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
|
||||||
|
.calendar-field input, .calendar-field textarea { width: 100%; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--text-primary); font: 12px var(--font-body); }
|
||||||
|
.calendar-field textarea { min-height: 56px; resize: vertical; }
|
||||||
|
.calendar-field input::placeholder, .calendar-field textarea::placeholder { color: var(--text-disabled); }
|
||||||
|
.calendar-field input:focus, .calendar-field textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-bg); }
|
||||||
|
.calendar-priority-row { display: flex; gap: 6px; }
|
||||||
|
.calendar-priority-row button { flex: 1; height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--text-secondary); font-size: 11px; cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast); }
|
||||||
|
.calendar-priority-row button:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.calendar-priority-row button.is-active { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-light); font-weight: 700; }
|
||||||
|
.calendar-composer-error { margin: 0 0 8px; color: var(--danger); font-size: 11px; }
|
||||||
|
.calendar-composer-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.calendar-composer-actions button { flex: 1; height: 32px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; }
|
||||||
|
.calendar-composer-actions .is-ghost { border: 1px solid var(--border); background: transparent; color: var(--text-secondary); }
|
||||||
|
.calendar-composer-actions .is-ghost:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.calendar-composer-actions .is-primary { border: 1px solid var(--primary); background: var(--primary); color: var(--surface-deep); font-weight: 700; }
|
||||||
|
.calendar-composer-actions .is-primary:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.calendar-tip { width: min(268px, calc(100vw - 24px)); padding: 12px; pointer-events: none; }
|
||||||
|
.calendar-tip-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.calendar-tip-kind { padding: 2px 8px; border-radius: 999px; font: 9px var(--font-mono); letter-spacing: .1em; }
|
||||||
|
.calendar-tip-kind.is-todo { background: var(--primary-bg); color: var(--primary-light); }
|
||||||
|
.calendar-tip-kind.is-holiday { background: var(--success-bg); color: var(--success); }
|
||||||
|
.calendar-tip-date { color: var(--text-tertiary); font: 10px var(--font-mono); }
|
||||||
|
.calendar-tip-title { color: var(--text-primary); font-size: 13px; font-weight: 700; line-height: 1.38; }
|
||||||
|
.calendar-tip-body { display: grid; gap: 5px; margin: 9px 0 0; }
|
||||||
|
.calendar-tip-row { display: flex; gap: 10px; font-size: 11px; }
|
||||||
|
.calendar-tip-row dt { flex: 0 0 44px; color: var(--text-tertiary); }
|
||||||
|
.calendar-tip-row dd { margin: 0; color: var(--text-secondary); }
|
||||||
|
.calendar-tip-note { margin: 9px 0 0; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 11px; line-height: 1.55; }
|
||||||
|
.calendar-tip-more { display: grid; gap: 4px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-light); }
|
||||||
|
.calendar-tip-more span { color: var(--text-secondary); font-size: 11px; line-height: 1.4; }
|
||||||
.dashboard-content-grid { display: grid; grid-template-columns: minmax(0, 1.52fr) minmax(280px, .72fr); gap: 18px; align-items: start; }
|
.dashboard-content-grid { display: grid; grid-template-columns: minmax(0, 1.52fr) minmax(280px, .72fr); gap: 18px; align-items: start; }
|
||||||
.dashboard-primary-column, .dashboard-secondary-column { min-width: 0; }
|
.dashboard-primary-column, .dashboard-secondary-column { min-width: 0; }
|
||||||
.ai-digest-panel { background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 82%, var(--surface)), var(--surface)); }
|
.ai-digest-panel { background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 82%, var(--surface)), var(--surface)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user