feat(workspace): 日历瘦身 + 右侧「今日 to do / 选中日待办」双面板

日历
- 行高 92→72px,行程条只留 2 条(超出折成 +N 并压缩成小 chip),标题区与周标同步收窄
- 选中态重做:去掉了原来生硬的 2px 内描边,改成单元格内嵌圆角环 + 淡入缩放动画;
  「今天」改为日号描边圆环,与「选中」的实心日号区分开
- 侧栏宽度不足时(≤1200px)自动改为上下排布

右侧新增两块
- 「今日 to do」:备忘录性质。底部常驻加号输入框,回车即建;建完前面出现勾选框,
  勾选后划线变淡并沉到已完成组末尾(输入框始终在最后一行);可取消勾选回到未完成组;
  悬浮出现删除按钮;列表过长可滚动,勾选后自动滚进视野
- 「待办事项」:显示日历上选中那天的日程与节日(未选中时默认今天),
  带优先级点与说明,右上角「新建」直接为该日添加日程
- 原左栏重复的 [TASK QUEUE] 面板移到这里后删除,避免同名面板出现两次

后端
- todos 增加 list 列(schedule 日历日程 / memo 备忘录待办),老库启动时自动补列
- GET /api/todos 支持 list=memo;POST 支持 list,memo 不写日期
- 新增 PATCH /api/todos/:id(勾选完成/取消、改标题等)与 DELETE /api/todos/:id
This commit is contained in:
Drsakura
2026-09-10 23:35:20 +08:00
parent 33dc71f8c3
commit b9d6296591
5 changed files with 462 additions and 89 deletions
+14
View File
@@ -36,6 +36,20 @@ if (!tableExists) {
initDatabase();
}
// ---------------------------------------------------------------------------
// 轻量迁移:schema.sql 只对新库生效,老库缺列在这里补。
// todos.list —— 'schedule' 日历日程 / 'memo' 今日待办备忘录(备忘录不带日期)
// ---------------------------------------------------------------------------
function ensureColumns() {
const todoColumns = db.pragma('table_info(todos)').map(column => column.name);
if (!todoColumns.includes('list')) {
db.exec("ALTER TABLE todos ADD COLUMN list TEXT NOT NULL DEFAULT 'schedule'");
console.log('✓ 迁移:todos.list 列已补齐');
}
}
ensureColumns();
// 创建默认管理员账号(如果不存在)
function createDefaultAdmin() {
const crypto = require('crypto');
+1
View File
@@ -225,6 +225,7 @@ CREATE TABLE IF NOT EXISTS todos (
due_date TEXT,
priority TEXT DEFAULT 'medium',
status TEXT DEFAULT 'pending',
list TEXT NOT NULL DEFAULT 'schedule',
customer_id INTEGER REFERENCES customers(id),
deal_id INTEGER REFERENCES deals(id),
order_id INTEGER REFERENCES orders(id),
+91 -18
View File
@@ -358,23 +358,32 @@ app.get('/api/search', (req, res) => {
// ---------------------------------------------------------------------------
const PRIORITIES = ['high', 'medium', 'low'];
// list=schedule(默认)按 due_date 取日历日程;list=memo 取备忘录待办(无日期)
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 list = req.query.list === 'memo' ? 'memo' : 'schedule';
const conditions = ['t.list = ?'];
const params = [list];
if (list === 'schedule') {
const from = String(req.query.from || '').slice(0, 10);
const to = String(req.query.to || '').slice(0, 10);
conditions.push('t.due_date IS NOT NULL');
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 order = list === 'memo'
? `CASE t.status WHEN 'completed' THEN 1 ELSE 0 END ASC, t.created_at ASC`
: `t.due_date ASC, CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END`;
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
WHERE ${conditions.join(' AND ')}
ORDER BY ${order}
LIMIT 500
`).all(...params);
@@ -386,34 +395,98 @@ app.get('/api/todos', (req, res) => {
app.post('/api/todos', (req, res) => {
try {
const list = req.body?.list === 'memo' ? 'memo' : 'schedule';
const title = String(req.body?.title || '').trim().slice(0, 200);
if (!title) return res.status(400).json({ error: '请填写日程标题' });
if (!title) return res.status(400).json({ error: list === 'memo' ? '请填写待办内容' : '请填写日程标题' });
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' });
// 备忘录不带日期;日程必须落在某一天
let dueDate = null;
if (list === 'schedule') {
const rawDueDate = String(req.body?.due_date || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(rawDueDate)) return res.status(400).json({ error: '日期格式应为 YYYY-MM-DD' });
dueDate = rawDueDate;
}
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);
INSERT INTO todos (title, description, due_date, priority, status, list, created_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?)
`).run(title, description, dueDate, priority, list, now);
// 活动流;activities 表缺失或字段约束变化时不影响主流程
try {
db.prepare(`
INSERT INTO activities (activity_type, subject, content, created_at)
VALUES ('todo', ?, ?, ?)
`).run('新建日程', `${dueDate} ${title}`, now);
`).run(list === 'memo' ? '新建待办' : '新建日程', list === 'memo' ? title : `${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: '日程已添加' });
res.json({ id: result.lastInsertRowid, todo, message: list === 'memo' ? '待办已添加' : '日程已添加' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 勾选完成 / 取消完成 / 改标题等,局部更新
app.patch('/api/todos/:id', (req, res) => {
try {
const id = Number(req.params.id);
const existing = db.prepare('SELECT * FROM todos WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: '待办不存在' });
const fields = [];
const params = [];
const body = req.body || {};
if (body.status !== undefined) {
const status = body.status === 'completed' ? 'completed' : 'pending';
fields.push('status = ?', 'completed_at = ?');
params.push(status, status === 'completed' ? new Date().toISOString() : null);
} else if (body.completed === true || body.completed === false) {
fields.push('status = ?', 'completed_at = ?');
params.push(body.completed ? 'completed' : 'pending', body.completed ? new Date().toISOString() : null);
}
if (body.title !== undefined) {
const title = String(body.title).trim().slice(0, 200);
if (!title) return res.status(400).json({ error: '标题不能为空' });
fields.push('title = ?');
params.push(title);
}
if (body.priority !== undefined) {
fields.push('priority = ?');
params.push(PRIORITIES.includes(body.priority) ? body.priority : 'medium');
}
if (body.description !== undefined) {
fields.push('description = ?');
params.push(String(body.description).trim().slice(0, 1000) || null);
}
if (!fields.length) return res.status(400).json({ error: '没有需要更新的字段' });
params.push(id);
db.prepare(`UPDATE todos SET ${fields.join(', ')} WHERE id = ?`).run(...params);
const todo = db.prepare('SELECT * FROM todos WHERE id = ?').get(id);
res.json({ todo, message: todo.status === 'completed' ? '已标记完成' : '已更新' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/todos/:id', (req, res) => {
try {
const result = db.prepare('DELETE FROM todos WHERE id = ?').run(Number(req.params.id));
if (!result.changes) return res.status(404).json({ error: '待办不存在' });
res.json({ message: '已删除' });
} catch (error) {
res.status(500).json({ error: error.message });
}