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'));