diff --git a/frontend/src/components/MonthYearWheel.jsx b/frontend/src/components/MonthYearWheel.jsx new file mode 100644 index 0000000..f7149fb --- /dev/null +++ b/frontend/src/components/MonthYearWheel.jsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +/** + * 年月拨盘(iOS 闹钟式滚轮) + * - 两列各自滚动吸附,停稳后把最终值抛给父级(onChange) + * - 支持鼠标滚轮 / 触摸板 / 按住拖动;点某一行直接跳到该行 + * - 外部值变化时反向同步滚动位置(且不会打回环) + */ + +const ITEM_HEIGHT = 32 // 与 luxury.css 的 --wheel-item 保持一致 +const SETTLE_DELAY = 140 + +function WheelColumn({ values, value, onSettle, format, ariaLabel }) { + const listRef = useRef(null) + const timerRef = useRef(null) + const dragRef = useRef(null) + const frameRef = useRef(0) + const muteRef = useRef(false) + const [dragging, setDragging] = useState(false) + const index = Math.max(0, values.indexOf(value)) + const [centered, setCentered] = useState(index) + + // 滚动过程中实时算出居中那一行,用来做 iOS 拨盘那样的放大/淡出 + const syncCentered = useCallback(() => { + window.cancelAnimationFrame(frameRef.current) + frameRef.current = window.requestAnimationFrame(() => { + const node = listRef.current + if (!node) return + setCentered(Math.min(values.length - 1, Math.max(0, Math.round(node.scrollTop / ITEM_HEIGHT)))) + }) + }, [values.length]) + + const snapTo = useCallback((target, behavior) => { + const node = listRef.current + if (!node) return + if (Math.abs(node.scrollTop - target) < 1) return + muteRef.current = true + node.scrollTo({ top: target, behavior }) + window.setTimeout(() => { muteRef.current = false }, behavior === 'smooth' ? 340 : 60) + }, []) + + // 外部值变了(含父级纠正)就把滚轮挪到对应行 + useLayoutEffect(() => { + snapTo(index * ITEM_HEIGHT, 'auto') + }, [index, snapTo]) + + // 值变化(含外部纠正)后,让居中高亮跟过来 + useLayoutEffect(() => { + setCentered(index) + }, [index]) + + const settle = useCallback(() => { + const node = listRef.current + if (!node) return + const next = Math.min(values.length - 1, Math.max(0, Math.round(node.scrollTop / ITEM_HEIGHT))) + if (values[next] !== value) onSettle(values[next]) + snapTo(next * ITEM_HEIGHT, 'smooth') + }, [onSettle, snapTo, value, values]) + + const handleScroll = () => { + if (muteRef.current || dragRef.current) return + syncCentered() + window.clearTimeout(timerRef.current) + timerRef.current = window.setTimeout(settle, SETTLE_DELAY) + } + + useEffect(() => () => { + window.clearTimeout(timerRef.current) + window.cancelAnimationFrame(frameRef.current) + }, []) + + const onPointerDown = (event) => { + const node = listRef.current + if (!node || event.button > 0) return + window.clearTimeout(timerRef.current) + dragRef.current = { startY: event.clientY, startTop: node.scrollTop, moved: false } + setDragging(true) + try { node.setPointerCapture(event.pointerId) } catch { /* 忽略不支持捕获的浏览器 */ } + } + + const onPointerMove = (event) => { + if (!dragRef.current) return + const node = listRef.current + if (!node) return + const delta = event.clientY - dragRef.current.startY + if (Math.abs(delta) > 3) dragRef.current.moved = true + node.scrollTop = dragRef.current.startTop - delta + syncCentered() + } + + const endDrag = () => { + if (!dragRef.current) return + const moved = dragRef.current.moved + dragRef.current = null + setDragging(false) + if (moved) settle() + } + + return ( +
+
+ + ) +} + +export default function MonthYearWheel({ year, month, onChange, onToday, minYear, maxYear }) { + const years = useMemo(() => { + const list = [] + for (let y = minYear; y <= maxYear; y += 1) list.push(y) + return list + }, [minYear, maxYear]) + const months = useMemo(() => Array.from({ length: 12 }, (_, index) => index + 1), []) + + // 两列可能在同一帧内先后停稳,各自只认自己那一列时不能读渲染期闭包, + // 否则后一次会用旧值覆盖前一次(滚到 2028年12月 会退回 2026年12月)。 + const latest = useRef({ year, month }) + latest.current = { year, month } + + const pickYear = useCallback((nextYear) => { + latest.current = { ...latest.current, year: nextYear } + onChange(latest.current.year, latest.current.month) + }, [onChange]) + + const pickMonth = useCallback((nextMonth) => { + latest.current = { ...latest.current, month: nextMonth } + onChange(latest.current.year, latest.current.month) + }, [onChange]) + + return ( +
+
+
+ +
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 060b4b6..e5b2923 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -15,6 +15,7 @@ import { TrashIcon, UsersIcon } from '../components/Icons' +import MonthYearWheel from '../components/MonthYearWheel' const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] @@ -30,6 +31,9 @@ const COMPOSER_WIDTH = 306 const TIP_WIDTH = 268 const JUMP_WIDTH = 262 const JUMP_HEIGHT = 292 +const WHEEL_WIDTH = 219 +const WHEEL_HEIGHT = 168 +const WHEEL_YEAR_SPAN = { back: 12, forward: 20 } const MAX_CELL_EVENTS = 2 // 春节(农历正月初一)公历日期,逐年查表;2025–2035 与天文台公布数据一致。 @@ -303,11 +307,17 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { const [jumpOpen, setJumpOpen] = useState(false) const [jumpPos, setJumpPos] = useState(null) const [jumpMonth, setJumpMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1)) + // 年月拨盘:工具条月份文字与跳转浮层标题共用 + const [wheelOpen, setWheelOpen] = useState(false) + const [wheelPos, setWheelPos] = useState(null) + const [wheelPeriod, setWheelPeriod] = useState(() => ({ year: today.getFullYear(), month: today.getMonth() + 1 })) const composerRef = useRef(null) const tipRef = useRef(null) const jumpRef = useRef(null) const jumpBtnRef = useRef(null) + const wheelRef = useRef(null) + const wheelAnchorRef = useRef(null) const anchorElRef = useRef(null) const pendingSelectRef = useRef(null) @@ -460,6 +470,8 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { if (event.key !== 'Escape') return closeComposer() setTip(null) + setJumpOpen(false) + setWheelOpen(false) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) @@ -528,6 +540,43 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { setJumpOpen(false) } + /* 年月拨盘:以触发文字为锚点,水平居中于它,下方放不下就翻到上方 */ + const positionWheel = useCallback((element) => { + const rect = element.getBoundingClientRect() + const centered = rect.left + rect.width / 2 - WHEEL_WIDTH / 2 + const left = Math.min(Math.max(12, centered), Math.max(12, window.innerWidth - WHEEL_WIDTH - 12)) + const flipUp = rect.bottom + 9 + WHEEL_HEIGHT > window.innerHeight && rect.top - 9 - WHEEL_HEIGHT > 0 + setWheelPos({ left, top: flipUp ? rect.top - 9 : rect.bottom + 9, placement: flipUp ? 'above' : 'below' }) + }, []) + + const openWheel = (element) => { + if (!element) return + wheelAnchorRef.current = element + setWheelPeriod({ year: month.getFullYear(), month: month.getMonth() + 1 }) + positionWheel(element) + setWheelOpen(true) + // 从跳转浮层里点进来时,把浮层收掉,只留拨盘 + setJumpOpen(false) + } + + const closeWheel = () => setWheelOpen(false) + + /* 工具条上的月份文字:再点一次即收起 */ + const toggleWheelFromBar = (element) => { + if (wheelOpen && wheelAnchorRef.current === element) { closeWheel(); return } + openWheel(element) + } + + /* 拨盘选定年月:主日历与跳转浮层同步过去 */ + const applyWheelPeriod = (nextYear, nextMonth) => { + setWheelPeriod({ year: nextYear, month: nextMonth }) + const next = new Date(nextYear, nextMonth - 1, 1) + setMonth(next) + setJumpMonth(next) + } + + const wheelGoToday = () => applyWheelPeriod(today.getFullYear(), today.getMonth() + 1) + // 点空白处收起跳转浮层(点按钮本身交给 toggleJump 处理) useEffect(() => { if (!jumpOpen) return undefined @@ -552,6 +601,34 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { } }, [jumpOpen, positionJump]) + // 点空白处收起拨盘(点触发文字本身交给 openWheel 处理) + useEffect(() => { + if (!wheelOpen) return undefined + const onPointerDown = (event) => { + if (wheelRef.current?.contains(event.target)) return + if (event.target.closest?.('.calendar-month-btn')) return + closeWheel() + } + document.addEventListener('mousedown', onPointerDown) + return () => document.removeEventListener('mousedown', onPointerDown) + }, [wheelOpen]) + + // 页面滚动 / 缩放时跟着锚点走;锚点已从 DOM 摘掉(例如跳转浮层关了)就直接收起 + useEffect(() => { + if (!wheelOpen) return undefined + const follow = () => { + const anchor = wheelAnchorRef.current + if (!anchor || !anchor.isConnected || !anchor.getBoundingClientRect().width) { closeWheel(); return } + positionWheel(anchor) + } + window.addEventListener('scroll', follow, true) + window.addEventListener('resize', follow) + return () => { + window.removeEventListener('scroll', follow, true) + window.removeEventListener('resize', follow) + } + }, [wheelOpen, positionWheel]) + const submitDraft = async (event) => { event.preventDefault() const title = draft.title.trim() @@ -586,7 +663,7 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {

工作日历

单击日期选中,再次单击添加日程 · 悬浮日程或节日查看详情

-
{monthLabel}
+
@@ -801,7 +878,7 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) { >
- {jumpMonth.getFullYear()}年{jumpMonth.getMonth() + 1}月 +
{WEEKDAYS.map(day => {day.slice(1)})}
@@ -822,6 +899,29 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
)} + + {wheelOpen && wheelPos && ( +
+ +
+ )} ) } diff --git a/frontend/src/styles/luxury.css b/frontend/src/styles/luxury.css index 1f81b8a..f9a950b 100644 --- a/frontend/src/styles/luxury.css +++ b/frontend/src/styles/luxury.css @@ -288,7 +288,9 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px .calendar-toolbar { display: flex; align-items: center; gap: 7px; color: var(--text-secondary); } .calendar-toolbar button { display: inline-grid; place-items: center; min-width: 28px; height: 28px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); } .calendar-toolbar button:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); } -.calendar-toolbar strong { min-width: 88px; color: var(--text-primary); text-align: center; font-size: 12.5px; } +.calendar-toolbar .calendar-month-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 88px; height: 28px; padding: 0 9px; color: var(--text-primary); font-size: 12.5px; font-variant-numeric: tabular-nums; } +.calendar-toolbar .calendar-month-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); } +.calendar-toolbar .calendar-month-btn.is-open { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-light); } .calendar-toolbar .calendar-today-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 11px; } /* 快速跳转日期:平时是低调的日历图标,悬浮/展开变蓝 */ .calendar-toolbar .calendar-jump-btn { border-color: var(--border-light); background: var(--surface-deep); } @@ -436,6 +438,25 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px .jump-day.is-selected { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); font-weight: 800; } .jump-today { width: 100%; height: 27px; margin-top: 8px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 11px; cursor: pointer; } .jump-today:hover { border-color: var(--primary); color: var(--primary-light); } +/* 跳转浮层标题 = 可点的年月(弹出拨盘) */ +.jump-head .jump-month-btn { display: inline-flex; align-items: center; justify-content: center; flex: 1; width: auto; min-width: 0; height: 24px; padding: 0 8px; border-radius: 7px; color: var(--text-primary); font-size: 12px; font-variant-numeric: tabular-nums; } +.jump-head .jump-month-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); } +/* 年月拨盘(iOS 闹钟式滚轮) */ +.calendar-wheel-pop { --wheel-item: 32px; padding: 12px 14px 10px; } +.ymw-body { display: grid; gap: 9px; } +.ymw-drum { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; } +.ymw-highlight { position: absolute; left: 2px; right: 2px; top: 50%; height: var(--wheel-item); transform: translateY(-50%); border: 1px solid var(--primary-border); border-radius: 9px; background: var(--primary-bg); pointer-events: none; } +.ymw-col { position: relative; width: 72px; height: calc(var(--wheel-item) * 3); } +.ymw-list { height: 100%; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; touch-action: none; cursor: grab; } +.ymw-list::-webkit-scrollbar { display: none; } +.ymw-list.is-dragging { scroll-snap-type: none; cursor: grabbing; } +.ymw-cell { display: block; width: 100%; height: var(--wheel-item); border: 0; background: transparent; color: var(--text-tertiary); font: 15px var(--font-mono); font-variant-numeric: tabular-nums; text-align: center; scroll-snap-align: center; cursor: pointer; transform-origin: center; transition: color var(--transition-fast); } +.ymw-cell:hover { color: var(--text-secondary); } +.ymw-cell.is-active { color: var(--primary-light); font-weight: 800; } +.ymw-pad { height: var(--wheel-item); } +.ymw-unit { color: var(--text-tertiary); font-size: 12px; } +.ymw-today { width: 100%; height: 27px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 11px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); } +.ymw-today:hover { border-color: var(--primary); color: var(--primary-light); } .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)); }