From b9d62965913c9c385d6e388ef13b7b85c3a72c5b Mon Sep 17 00:00:00 2001 From: Drsakura Date: Thu, 10 Sep 2026 23:35:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20=E6=97=A5=E5=8E=86=E7=98=A6?= =?UTF-8?q?=E8=BA=AB=20+=20=E5=8F=B3=E4=BE=A7=E3=80=8C=E4=BB=8A=E6=97=A5?= =?UTF-8?q?=20to=20do=20/=20=E9=80=89=E4=B8=AD=E6=97=A5=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E3=80=8D=E5=8F=8C=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日历 - 行高 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 --- backend/db/db.js | 14 ++ backend/db/schema.sql | 1 + backend/server.js | 109 ++++++++-- frontend/src/pages/Dashboard.jsx | 331 +++++++++++++++++++++++++------ frontend/src/styles/luxury.css | 96 +++++++-- 5 files changed, 462 insertions(+), 89 deletions(-) diff --git a/backend/db/db.js b/backend/db/db.js index 1d12bfe..444d55d 100644 --- a/backend/db/db.js +++ b/backend/db/db.js @@ -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'); diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 09f0e97..74b0b0d 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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), diff --git a/backend/server.js b/backend/server.js index e5649c6..494d61e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 }); } diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 3883c0b..3a849f6 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -2,17 +2,17 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta import axios from 'axios' import { ActivityIcon, - BellIcon, CalendarIcon, CheckIcon, ClipboardIcon, CloseIcon, DollarIcon, FactoryIcon, - FlagIcon, PackageIcon, PinIcon, + PlusCircleIcon, SparkIcon, + TrashIcon, UsersIcon } from '../components/Icons' @@ -28,6 +28,7 @@ const PRIORITY_LABELS = { high: '高', medium: '中', low: '低' } const COMPOSER_WIDTH = 306 const TIP_WIDTH = 268 +const MAX_CELL_EVENTS = 2 // 春节(农历正月初一)公历日期,逐年查表;2025–2035 与天文台公布数据一致。 const SPRING_FESTIVAL = { @@ -51,6 +52,14 @@ function describeDate(key) { return `${year}年${month}月${day}日 ${weekday}` } +/** 把 YYYY-MM-DD 变成「9月10日 周四」 */ +function describeDateShort(key) { + const [, month, day] = key.split('-').map(Number) + const [year] = key.split('-').map(Number) + const weekday = WEEKDAY_SHORT[new Date(year, month - 1, day).getDay()] + return `${month}月${day}日 ${weekday}` +} + /** 某年某月的第 n 个星期 weekday(0=周日) */ function nthWeekday(year, monthIndex, weekday, nth) { const first = new Date(year, monthIndex, 1) @@ -125,6 +134,156 @@ function anchorFor(element, width, estimatedHeight = 240, gap = 8) { } } +/** + * 今日 to do —— 轻量备忘录。 + * 回车新增;勾选后划线变淡并沉到列表末尾(输入框始终在最底下一行)。 + */ +function TodoMemo() { + const [items, setItems] = useState([]) + const [draft, setDraft] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const listRef = useRef(null) + const revealIdRef = useRef(null) + + const load = useCallback(async () => { + try { + const response = await axios.get('/api/todos', { params: { list: 'memo' } }) + setItems(Array.isArray(response.data) ? response.data : []) + } catch (loadError) { + console.error('获取今日待办失败:', loadError) + } + }, []) + + useEffect(() => { load() }, [load]) + + // 未完成在前(按创建顺序),已完成沉底(后完成的排更靠下,紧贴输入框) + const ordered = useMemo(() => { + const rank = (item) => (item.status === 'completed' ? 1 : 0) + return [...items].sort((a, b) => { + if (rank(a) !== rank(b)) return rank(a) - rank(b) + if (rank(a) === 1) return String(a.completed_at || '').localeCompare(String(b.completed_at || '')) + return Number(a.id) - Number(b.id) + }) + }, [items]) + + // 列表可滚动时,勾选后把沉底的条目滚进视野,避免「点完就找不到了」 + useLayoutEffect(() => { + const id = revealIdRef.current + if (!id) return + revealIdRef.current = null + const node = listRef.current?.querySelector(`[data-memo-id="${id}"]`) + if (node) node.scrollIntoView({ block: 'nearest' }) + }, [ordered]) + + const doneCount = items.filter((item) => item.status === 'completed').length + + const submit = async (event) => { + event.preventDefault() + const title = draft.trim() + if (!title || saving) return + setSaving(true) + setError('') + try { + const response = await axios.post('/api/todos', { title, list: 'memo' }) + setDraft('') + if (response.data?.todo) { + setItems((current) => [...current, response.data.todo]) + } else { + await load() + } + } catch (submitError) { + setError(submitError?.response?.data?.error || '添加失败,请稍后重试') + } finally { + setSaving(false) + } + } + + const toggle = async (item) => { + const nextStatus = item.status === 'completed' ? 'pending' : 'completed' + const optimistic = { + ...item, + status: nextStatus, + completed_at: nextStatus === 'completed' ? new Date().toISOString() : null + } + revealIdRef.current = item.id + setItems((current) => current.map((row) => (row.id === item.id ? optimistic : row))) + try { + const response = await axios.patch(`/api/todos/${item.id}`, { status: nextStatus }) + if (response.data?.todo) { + setItems((current) => current.map((row) => (row.id === item.id ? response.data.todo : row))) + } + } catch (toggleError) { + console.error('更新待办状态失败:', toggleError) + setItems((current) => current.map((row) => (row.id === item.id ? item : row))) + setError('状态更新失败,请重试') + } + } + + const remove = async (item) => { + setItems((current) => current.filter((row) => row.id !== item.id)) + try { + await axios.delete(`/api/todos/${item.id}`) + } catch (removeError) { + console.error('删除待办失败:', removeError) + setError('删除失败,请重试') + await load() + } + } + + return ( +
+
+
+

今日 to do

+ 备忘录 · 回车确认,勾选后沉底 +
+ {doneCount > 0 && {doneCount}/{items.length}} +
+ + {ordered.length > 0 && ( + + )} + +
+ + setDraft(event.target.value)} + /> +
+ {error &&

{error}

} +
+ ) +} + function CalendarBoard({ todos: seedTodos, onTodoCreated }) { const today = new Date() const todayKey = toDateKey(today) @@ -165,7 +324,7 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { // 日历按可见网格范围取数(含已完成),与工作台的待办列表各取所需。 const loadTodos = useCallback(async () => { try { - const response = await axios.get('/api/todos', { params: { from: gridFrom, to: gridTo } }) + const response = await axios.get('/api/todos', { params: { from: gridFrom, to: gridTo, list: 'schedule' } }) setMonthTodos(Array.isArray(response.data) ? response.data.filter(todo => todo.due_date) : []) } catch (error) { console.error('获取日历日程失败:', error) @@ -209,6 +368,10 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { return result }, [holidayYears, monthTodos]) + // 没显式选中时,右侧面板默认看今天 + const activeKey = selectedKey || todayKey + const activeEvents = events[activeKey] || [] + const closeComposer = useCallback(() => { setComposerKey(null) setComposerAnchor(null) @@ -229,6 +392,15 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { return 0 }, []) + const openComposer = useCallback((key, element) => { + anchorElRef.current = element + setComposerAnchor({ ...anchorFor(element, COMPOSER_WIDTH, 330), settled: false }) + setComposerKey(key) + setDraft({ title: '', priority: 'medium', description: '' }) + setFormError('') + setSelectedKey(key) + }, []) + const handleDayClick = (event, key) => { // 面板打开时点击日期由 document 的 mousedown 接管,这里只负责落到新选中日 if (pendingSelectRef.current) { @@ -239,15 +411,7 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { } 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 - } + if (selectedKey === key) { openComposer(key, event.currentTarget); return } setSelectedKey(key) } @@ -334,7 +498,8 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { title, due_date: composerKey, priority: draft.priority, - description: draft.description.trim() + description: draft.description.trim(), + list: 'schedule' }) setSelectedKey(composerKey) closeComposer() @@ -358,50 +523,102 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
{monthLabel}
-
{WEEKDAYS.map(day =>
{day}
)}
-
- {calendarDays.map(({ date, currentMonth, key }) => { - const dayEvents = events[key] || [] - const isToday = key === todayKey - const isSelected = key === selectedKey - const overflow = dayEvents.slice(3) - const label = `${describeDate(key)}${dayEvents.length ? `,${dayEvents.length} 项日程` : ''}${isSelected ? ',已选中' : ''}` - return ( -
handleDayClick(event, key)} - onKeyDown={(event) => handleDayKeyDown(event, key)} - > - {date.getDate()} -
- {dayEvents.slice(0, 3).map(event => ( - showTip(mouseEvent, event)} - onMouseLeave={hideTip} - > - {event.label} - - ))} - {overflow.length > 0 && ( - showTip(mouseEvent, { kind: 'more', title: `还有 ${overflow.length} 项`, items: overflow })} - onMouseLeave={hideTip} - > - +{overflow.length} - - )} + +
+
+
{WEEKDAYS.map(day =>
{day}
)}
+
+ {calendarDays.map(({ date, currentMonth, key }) => { + const dayEvents = events[key] || [] + const isToday = key === todayKey + const isSelected = key === selectedKey + const overflow = dayEvents.slice(MAX_CELL_EVENTS) + const label = `${describeDate(key)}${dayEvents.length ? `,${dayEvents.length} 项日程` : ''}${isSelected ? ',已选中' : ''}` + return ( +
handleDayClick(event, key)} + onKeyDown={(event) => handleDayKeyDown(event, key)} + > + {date.getDate()} +
+ {dayEvents.slice(0, MAX_CELL_EVENTS).map(event => ( + showTip(mouseEvent, event)} + onMouseLeave={hideTip} + > + {event.label} + + ))} + {overflow.length > 0 && ( + showTip(mouseEvent, { kind: 'more', title: `还有 ${overflow.length} 项`, items: overflow })} + onMouseLeave={hideTip} + > + +{overflow.length} + + )} +
+
+ ) + })} +
+
+ +
{composerKey && composerAnchor && ( @@ -420,7 +637,7 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
添加日程 - {describeDate(composerKey)} + {describeDateShort(composerKey)}
@@ -540,8 +757,6 @@ function Dashboard() {
[AI BRIEFING]

今日工作摘要

实时
系统已准备好协助你处理今天的业务。

当前共有 {stats.pendingTodos || 0} 项待办、{stats.activeDeals || 0} 个进行中商机和 {stats.orders || 0} 笔进行中订单。打开右上角 AI 助手,可以直接用自然语言查阅和操作系统。

供应链客户跟进订单节奏
-
[TASK QUEUE]

待办事项

{todos.length === 0 ?
暂无待办事项,今天可以保持从容。
:
    {todos.map(todo =>
  • {todo.title}
    {todo.customer_name ? `客户:${todo.customer_name}` : '内部任务'}{todo.due_date ? ` · 截止 ${new Date(todo.due_date).toLocaleDateString('zh-CN')}` : ''}
    {todo.priority === 'high' ? '高' : todo.priority === 'medium' ? '中' : '低'}
  • )}
}
-
[ACTIVITY LOG]

最近活动

{recentActivities.length === 0 ?
暂无最近活动
:
    {recentActivities.map(activity =>
  • {new Date(activity.created_at).toLocaleString('zh-CN')}
    {activity.subject}{activity.content}
  • )}
}
diff --git a/frontend/src/styles/luxury.css b/frontend/src/styles/luxury.css index dbd56fa..eb2bb6a 100644 --- a/frontend/src/styles/luxury.css +++ b/frontend/src/styles/luxury.css @@ -283,7 +283,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px .dashboard-panel-header h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-primary); font-size: 15px; } .dashboard-panel-header .section-label { margin-bottom: 7px; } .calendar-board { overflow: hidden; padding: 0; } -.calendar-board-header { padding: 20px 22px 16px; margin: 0; border-bottom: 1px solid var(--border-light); } +.calendar-board-header { padding: 15px 18px 12px; margin: 0; border-bottom: 1px solid var(--border-light); } .calendar-toolbar { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); } .calendar-toolbar button { display: inline-grid; place-items: center; min-width: 30px; height: 30px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; } .calendar-toolbar button:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); } @@ -291,15 +291,15 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px .calendar-toolbar .calendar-today-btn, .calendar-toolbar .calendar-view-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 11px; } .calendar-week-row, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } .calendar-week-row { background: var(--surface-deep); border-bottom: 1px solid var(--border-light); } -.calendar-week-row > div { padding: 10px 12px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; } -.calendar-day { min-height: 92px; padding: 8px 9px; border-right: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light); background: var(--surface); } +.calendar-week-row > div { padding: 7px 8px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; } +.calendar-day { min-height: 72px; padding: 6px 7px; border-right: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light); background: var(--surface); } .calendar-day:nth-child(7n) { border-right: 0; } .calendar-day.is-outside { background: var(--surface-deep); opacity: .58; } .calendar-day.is-today { background: linear-gradient(180deg, var(--primary-bg), var(--surface)); } -.calendar-day-number { display: inline-grid; place-items: center; min-width: 22px; height: 22px; color: var(--text-secondary); font: 11px var(--font-mono); } -.calendar-day.is-today .calendar-day-number { border-radius: 50%; background: var(--primary); color: var(--surface-deep); font-weight: 800; } -.calendar-events { display: grid; gap: 4px; margin-top: 5px; } -.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-day-number { display: inline-grid; place-items: center; min-width: 19px; height: 19px; color: var(--text-secondary); font: 11px var(--font-mono); transition: background var(--transition-base), color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-base); } +.calendar-day.is-today .calendar-day-number { border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--primary); color: var(--primary-light); font-weight: 800; } +.calendar-events { display: grid; gap: 3px; margin-top: 4px; } +.calendar-event { display: block; overflow: hidden; padding: 2px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 10px; line-height: 1.28; 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); } @@ -309,17 +309,69 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px .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-selected { background: var(--primary-bg); } +.calendar-day.is-selected::after { content: ''; position: absolute; inset: 2px; border-radius: 8px; box-shadow: inset 0 0 0 1.5px var(--primary); pointer-events: none; animation: calendar-select-in 200ms cubic-bezier(.2, .8, .2, 1); } +@keyframes calendar-select-in { from { opacity: 0; transform: scale(.93); } to { opacity: 1; transform: scale(1); } } .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-day.is-selected .calendar-day-number { border-radius: 50%; background: var(--primary); box-shadow: none; color: var(--surface-deep); font-weight: 800; transform: scale(1.05); } .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 { justify-self: start; padding: 1px 5px; border-radius: 3px; background: var(--surface-deep); color: var(--text-tertiary); font: 9px var(--font-mono); cursor: pointer; } .calendar-more:hover { color: var(--text-secondary); } +/* ---------- 日历右侧:今日 to do(备忘录)+ 选中日待办 ---------- */ +.calendar-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(248px, 292px); align-items: stretch; } +.calendar-main { min-width: 0; } +.calendar-side { display: flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--border-light); background: var(--surface-deep); } +.calendar-side-block { padding: 13px 15px 14px; border-bottom: 1px solid var(--border-light); } +.calendar-side-block:last-child { border-bottom: 0; } +.calendar-side-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 9px; } +.calendar-side-head h3 { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--text-primary); font-size: 13px; font-weight: 700; } +.calendar-side-head h3 svg { color: var(--primary-light); } +.calendar-side-sub { display: block; margin-top: 3px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .03em; } +.calendar-side-count { flex: 0 0 auto; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--primary-bg); color: var(--primary-light); font: 10px var(--font-mono); } +.calendar-side-add { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; height: 25px; padding: 0 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font-size: 11px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); } +.calendar-side-add:hover { border-color: var(--primary); color: var(--primary-light); } +.calendar-side-empty { margin: 0; padding: 14px 0 8px; color: var(--text-tertiary); font-size: 11.5px; text-align: center; } + +/* 今日 to do */ +.calendar-side-block.is-memo { display: flex; flex-direction: column; flex: 1 1 auto; } +.memo-list { display: grid; gap: 1px; margin: 0 0 8px; padding: 0; max-height: 206px; overflow-y: auto; list-style: none; } +.memo-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 4px 6px 5px; border-radius: var(--radius-sm); animation: memo-item-in 180ms cubic-bezier(.2, .8, .2, 1); } +@keyframes memo-item-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } +.memo-item:hover { background: var(--surface-hover); } +.memo-check { flex: 0 0 15px; display: grid; place-items: center; width: 15px; height: 15px; margin-top: 2px; padding: 0; border: 1.5px solid var(--border-strong); border-radius: 4px; background: transparent; color: transparent; cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast), transform var(--transition-fast); } +.memo-check:hover { border-color: var(--primary); } +.memo-check:active { transform: scale(.86); } +.memo-item.is-done .memo-check { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); } +.memo-text { flex: 1; min-width: 0; color: var(--text-primary); font-size: 12.5px; line-height: 1.45; word-break: break-word; transition: color var(--transition-base), opacity var(--transition-base); } +.memo-item.is-done .memo-text { color: var(--text-tertiary); opacity: .6; text-decoration: line-through; } +.memo-remove { flex: 0 0 auto; display: grid; place-items: center; width: 19px; height: 19px; padding: 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-disabled); cursor: pointer; opacity: 0; transition: opacity var(--transition-fast), color var(--transition-fast); } +.memo-item:hover .memo-remove, .memo-remove:focus-visible { opacity: 1; } +.memo-remove:hover { color: var(--danger); } +.memo-add { display: flex; align-items: center; gap: 7px; padding: 6px 8px; border: 1px dashed var(--border); border-radius: var(--radius-sm); background: var(--surface); transition: border-color var(--transition-fast), background var(--transition-fast); } +.memo-add:focus-within { border-style: solid; border-color: var(--primary); background: var(--surface-raised); } +.memo-add[data-saving="true"] { opacity: .6; } +.memo-add-icon { display: grid; place-items: center; flex: 0 0 auto; color: var(--text-tertiary); transition: color var(--transition-fast); } +.memo-add:focus-within .memo-add-icon { color: var(--primary-light); } +.memo-add input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text-primary); font: 12.5px var(--font-body); outline: none; } +.memo-add input::placeholder { color: var(--text-disabled); } + +/* 选中日待办 */ +.day-list { display: grid; gap: 7px; margin: 0; padding: 0; max-height: 224px; overflow-y: auto; list-style: none; } +.day-item { display: flex; gap: 8px; padding: 8px 9px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface); } +.day-item.is-holiday { border-color: var(--success-border); background: var(--success-bg); } +.day-item.is-done { opacity: .58; } +.day-dot { flex: 0 0 6px; width: 6px; height: 6px; margin-top: 6px; border-radius: 50%; background: var(--primary); } +.day-dot.danger { background: var(--danger); } +.day-dot.holiday { background: var(--success); } +.day-dot.done { background: var(--text-tertiary); } +.day-body { display: grid; gap: 3px; min-width: 0; } +.day-title { color: var(--text-primary); font-size: 12.5px; font-weight: 600; line-height: 1.4; word-break: break-word; } +.day-meta { color: var(--text-tertiary); font-size: 10.5px; line-height: 1.4; } +.day-note { color: var(--text-secondary); font-size: 11px; line-height: 1.5; } + .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; } @@ -452,8 +504,26 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px .global-header-actions { gap: 7px; } } @media (max-width: 1280px) { .page-tabs { display: none; } } +/* 侧栏 + 右侧面板同时存在时,日历再窄就放不下 7 列了,改成上下排 */ +@media (max-width: 1200px) { + .calendar-layout { grid-template-columns: 1fr; } + .calendar-side { flex-direction: row; border-left: 0; border-top: 1px solid var(--border-light); } + .calendar-side-block { flex: 1 1 50%; min-width: 0; border-bottom: 0; border-right: 1px solid var(--border-light); } + .calendar-side-block:last-child { border-right: 0; } + .memo-list, .day-list { max-height: 186px; } +} +@media (max-width: 780px) { + .calendar-side { flex-direction: column; } + .calendar-side-block { border-right: 0; border-bottom: 1px solid var(--border-light); } + .calendar-side-block:last-child { border-bottom: 0; } +} +/* 窄屏下标题与工具条抢宽度,改成上下两行,避免「工作日历」被折成两列 */ +@media (max-width: 680px) { + .calendar-board-header { flex-direction: column; align-items: stretch; gap: 10px; } + .calendar-toolbar { justify-content: space-between; } +} @media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.clock-zone, .clock-caret { display: none; }.dashboard-content-grid { grid-template-columns: 1fr; }.dashboard-secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 0; } } -@media (max-width: 680px) { :root { --header-height: 62px; }.global-header { gap: 8px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.fx-panel { position: fixed; top: calc(var(--header-height) + 8px); left: 12px; right: 12px; width: auto; max-height: calc(100vh - var(--header-height) - 24px); overflow-y: auto; }.route-content { min-height: calc(100vh - var(--header-height)); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.calendar-day { min-height: 68px; padding: 5px 4px; }.calendar-week-row > div { padding: 8px 4px; }.calendar-event { font-size: 8px; }.calendar-day-number { font-size: 10px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-assistant-panel { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); } } +@media (max-width: 680px) { :root { --header-height: 62px; }.global-header { gap: 8px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.fx-panel { position: fixed; top: calc(var(--header-height) + 8px); left: 12px; right: 12px; width: auto; max-height: calc(100vh - var(--header-height) - 24px); overflow-y: auto; }.route-content { min-height: calc(100vh - var(--header-height)); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.calendar-day { min-height: 58px; padding: 5px 4px; }.calendar-week-row > div { padding: 6px 4px; }.calendar-event { font-size: 9px; }.calendar-day-number { font-size: 10px; }.calendar-side-block { padding: 12px; }.memo-list, .day-list { max-height: 168px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-assistant-panel { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); } } /* Requested compact navigation controls. */ .collapse-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--sb-border); border-radius: 8px; background: transparent; color: var(--sb-text); box-shadow: none; }