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:
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
+86
-13
@@ -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 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);
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
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' ? '请填写待办内容' : '请填写日程标题' });
|
||||
|
||||
// 备忘录不带日期;日程必须落在某一天
|
||||
let dueDate = null;
|
||||
if (list === 'schedule') {
|
||||
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' });
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section className="calendar-side-block is-memo">
|
||||
<div className="calendar-side-head">
|
||||
<div>
|
||||
<h3><CheckIcon size={14} /> 今日 to do</h3>
|
||||
<span className="calendar-side-sub">备忘录 · 回车确认,勾选后沉底</span>
|
||||
</div>
|
||||
{doneCount > 0 && <span className="calendar-side-count">{doneCount}/{items.length}</span>}
|
||||
</div>
|
||||
|
||||
{ordered.length > 0 && (
|
||||
<ul className="memo-list" ref={listRef}>
|
||||
{ordered.map((item) => {
|
||||
const isDone = item.status === 'completed'
|
||||
return (
|
||||
<li className={`memo-item ${isDone ? 'is-done' : ''}`} key={item.id} data-memo-id={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="memo-check"
|
||||
role="checkbox"
|
||||
aria-checked={isDone}
|
||||
aria-label={isDone ? `取消完成 ${item.title}` : `完成 ${item.title}`}
|
||||
onClick={() => toggle(item)}
|
||||
>
|
||||
<CheckIcon size={11} />
|
||||
</button>
|
||||
<span className="memo-text">{item.title}</span>
|
||||
<button type="button" className="memo-remove" onClick={() => remove(item)} aria-label={`删除 ${item.title}`}>
|
||||
<TrashIcon size={13} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form className="memo-add" onSubmit={submit} data-saving={saving ? 'true' : 'false'}>
|
||||
<span className="memo-add-icon"><PlusCircleIcon size={15} /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
maxLength={200}
|
||||
placeholder="添加一条待办,回车确认"
|
||||
aria-label="添加今日待办"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
{error && <p className="calendar-composer-error">{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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,13 +523,16 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
</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-layout">
|
||||
<div className="calendar-main">
|
||||
<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 === todayKey
|
||||
const isSelected = key === selectedKey
|
||||
const overflow = dayEvents.slice(3)
|
||||
const overflow = dayEvents.slice(MAX_CELL_EVENTS)
|
||||
const label = `${describeDate(key)}${dayEvents.length ? `,${dayEvents.length} 项日程` : ''}${isSelected ? ',已选中' : ''}`
|
||||
return (
|
||||
<div
|
||||
@@ -379,7 +547,7 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
>
|
||||
<span className="calendar-day-number">{date.getDate()}</span>
|
||||
<div className="calendar-events">
|
||||
{dayEvents.slice(0, 3).map(event => (
|
||||
{dayEvents.slice(0, MAX_CELL_EVENTS).map(event => (
|
||||
<span
|
||||
className={`calendar-event ${event.tone}`}
|
||||
key={event.key}
|
||||
@@ -403,6 +571,55 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="calendar-side">
|
||||
<TodoMemo />
|
||||
|
||||
<section className="calendar-side-block is-day">
|
||||
<div className="calendar-side-head">
|
||||
<div>
|
||||
<h3><PinIcon size={14} /> 待办事项</h3>
|
||||
<span className="calendar-side-sub">{describeDate(activeKey)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="calendar-side-add"
|
||||
onClick={(event) => openComposer(activeKey, event.currentTarget)}
|
||||
>
|
||||
<PlusCircleIcon size={14} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeEvents.length === 0 ? (
|
||||
<p className="calendar-side-empty">这一天还没有安排。</p>
|
||||
) : (
|
||||
<ul className="day-list">
|
||||
{activeEvents.map(item => (
|
||||
<li
|
||||
className={`day-item ${item.kind === 'holiday' ? 'is-holiday' : ''} ${item.status === 'completed' ? 'is-done' : ''}`}
|
||||
key={item.key}
|
||||
>
|
||||
<span className={`day-dot ${item.tone}`} />
|
||||
<div className="day-body">
|
||||
<span className="day-title">{item.title}</span>
|
||||
{item.kind === 'holiday' ? (
|
||||
<span className="day-note">{item.note}</span>
|
||||
) : (
|
||||
<span className="day-meta">
|
||||
{PRIORITY_LABELS[item.priority] || '中'}优先级
|
||||
{item.customer ? ` · ${item.customer}` : ''}
|
||||
{item.status === 'completed' ? ' · 已完成' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{composerKey && composerAnchor && (
|
||||
<div
|
||||
@@ -420,7 +637,7 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
|
||||
<div className="calendar-composer-head">
|
||||
<div>
|
||||
<strong>添加日程</strong>
|
||||
<small>{describeDate(composerKey)}</small>
|
||||
<small>{describeDateShort(composerKey)}</small>
|
||||
</div>
|
||||
<button type="button" className="calendar-popover-close" onClick={closeComposer} aria-label="关闭"><CloseIcon size={14} /></button>
|
||||
</div>
|
||||
@@ -540,8 +757,6 @@ function Dashboard() {
|
||||
<div className="dashboard-primary-column">
|
||||
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><span className="section-label">[AI BRIEFING]</span><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{stats.activeDeals || 0}</b> 个进行中商机和 <b>{stats.orders || 0}</b> 笔进行中订单。打开右上角 AI 助手,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK QUEUE]</span><h2><PinIcon size={17} /> 待办事项</h2></div><button className="panel-link">查看全部</button></div>{todos.length === 0 ? <div className="dashboard-empty">暂无待办事项,今天可以保持从容。</div> : <ul className="todo-list">{todos.map(todo => <li key={todo.id} className="todo-item"><input type="checkbox" aria-label={`完成 ${todo.title}`} /><div className="todo-content"><div className="todo-title">{todo.title}</div><div className="todo-meta">{todo.customer_name ? `客户:${todo.customer_name}` : '内部任务'}{todo.due_date ? ` · 截止 ${new Date(todo.due_date).toLocaleDateString('zh-CN')}` : ''}</div></div><span className={`priority-badge priority-${todo.priority}`}>{todo.priority === 'high' ? '高' : todo.priority === 'medium' ? '中' : '低'}</span></li>)}</ul>}</section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[ACTIVITY LOG]</span><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user