feat(calendar): 工作日历选中态 + 再次单击添加日程 + 日程/节日悬浮详情

- 单击日期选中(主题色底 + 内描边环),再次单击同一日期弹出「添加日程」浮层
- 浮层支持标题 / 优先级 / 备注,保存后写库并即时刷新日历与工作台待办
- 悬浮日程或节日显示详情小窗:日程含日期/优先级/状态/客户/备注,
  节日含外贸视角的具体说明(对出货、订舱、客户响应的影响)
- 节日补充春节(2025–2035 逐年查表)、感恩节与黑色星期五
- 超过 3 项时折叠为 +N,悬浮查看剩余条目
- 后端新增 GET /api/todos(按日期范围)与 POST /api/todos
- 浮层用 fixed 定位 + 视口翻转/夹取,渲染后按真实高度校正一次;
  滚动跟随锚点;Esc 与点击外部关闭;日期格支持键盘 Enter/Space
This commit is contained in:
Drsakura
2026-09-10 23:16:29 +08:00
parent 02e985c011
commit 33dc71f8c3
3 changed files with 596 additions and 36 deletions
+67
View File
@@ -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'));
+469 -34
View File
@@ -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 (
<section className="calendar-board dashboard-panel">
<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>
<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 === toDateKey(today)
return <div className={`calendar-day ${currentMonth ? '' : 'is-outside'} ${isToday ? 'is-today' : ''}`} key={`${key}-${date.getTime()}`}>
const isToday = key === todayKey
const isSelected = key === selectedKey
const overflow = dayEvents.slice(3)
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, 3).map((event, index) => <span className={`calendar-event ${event.tone}`} key={`${event.label}-${index}`} title={event.label}>{event.label}</span>)}</div>
<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>
{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>
)
}
@@ -86,10 +512,19 @@ function Dashboard() {
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
const [loading, setLoading] = useState(true)
useEffect(() => {
axios.get('/api/dashboard').then(response => setData(response.data)).catch(error => console.error('获取工作台数据失败:', error)).finally(() => setLoading(false))
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="loading">加载工作台数据…</div>
const { stats, recentActivities, todos } = data
@@ -99,7 +534,7 @@ function Dashboard() {
<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>
<CalendarBoard todos={todos} />
<CalendarBoard todos={todos} onTodoCreated={loadDashboard} />
<div className="dashboard-content-grid">
<div className="dashboard-primary-column">
+58
View File
@@ -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.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-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-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)); }