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 (
+
+
+
+ {values.map((item, position) => {
+ const distance = Math.abs(position - centered)
+ 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 (
+
+
+
+ `${item}`} ariaLabel="年份" />
+ 年
+ `${item}`} ariaLabel="月份" />
+ 月
+
+
+
+ )
+}
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)); }