系列导航
- 上一篇:架构与边缘吸附系统《一》
主题系统:CSS 变量 + Tailwind v4 @theme
为什么不只用 Tailwind 内置颜色?
Tailwind v4 的 @theme 指令可以把自定义颜色注册为工具类(bg-app-panel, text-app-title),但如果主题值在运行时更改(light → dark),这些类名对应的颜色值不会自动变——Tailwind 在构建时生成类,不支持运行态 CSS 变量交换。
因此设计是两层结构:
CSS 自定义属性(运行时可覆盖) ← 数据源
│
▼
@theme { --color-app-*: var(--app-*) } ← 编译时绑定
│
▼
className="bg-app-panel" ← 模板中直接使用
代码组织
src/renderer/src/assets/styles/main.css:
/* 浅色主题(默认) */
:root {
color-scheme: light;
--app-panel-bg: #ffffff;
--app-panel-border: #737373;
--app-title: #1c1c1c;
--app-todo-text: #2d2d2d;
/* ...30+ 个变量 */
}
/* 深色主题 */
html[data-theme='dark'] {
color-scheme: dark;
--app-panel-bg: #18181b;
--app-panel-border: #3f3f46;
--app-title: #fafafa;
--app-todo-text: #e4e4e7;
/* 每个变量都有对应的暗色值 */
}
/* 注册到 Tailwind theme */
@theme {
--color-app-panel: var(--app-panel-bg);
--color-app-title: var(--app-title);
--color-app-todo-text: var(--app-todo-text);
/* ...每个变量一行 */
}
切换主题时,只需改 document.documentElement.dataset.theme,所有 var(--app-*) 自动生效,@theme 工具类跟着变。
ThemeContext:运行时切换的桥梁
// ThemeContext.jsx
function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
// 优先主进程同步读取 IPC(electron-store)
// 回退到 localStorage
})
useEffect(() => {
document.documentElement.dataset.theme = theme
localStorage.setItem('wuzi2do-theme', theme)
}, [theme])
// 监听来自托盘菜单的主题变更 IPC
useEffect(() => {
window.api.onThemeChanged(setTheme)
}, [])
}
初始化顺序:
main.jsx的bootstrapDocument()在 React 挂载前从 localStorage 读取主题,设置data-theme—— 防止 FOUC(闪白)。- React 挂载后,
ThemeProvider从 IPC 同步读取主进程设置(更权威),覆盖 localStorage 值。 - 此后所有切换(托盘菜单 or 渲染进程 UI)都走
setTheme→data-theme→ CSS 变量自动更新。
为什么保留 --app-* 不用纯 --color-app-*?
@theme 生成的工具类名固定为 bg-app-panel,但如果变量定义为 --color-app-panel 并且主题切换层也用这个名称,会导致 @theme 的 --color-app-panel: var(--color-app-panel) —— 自己引用自己。因此底层变量加了不同的命名空间 --app-panel-bg,主题层与注册层完全分离。
国际化:跨进程共享词表
难点
Electron 主进程(托盘菜单)和渲染进程(React UI)需要用同一份翻译数据。同步方式有两种:
| 方案 | 优点 | 缺点 |
|---|---|---|
| IPC 每次翻译拉取 | 唯一源 | 高频调用有延迟;托盘菜单无法动态生成 |
| 两份重复配置 | 无 IPC 依赖 | 不同步风险 |
| 共享模块 import | 编译时打包到两端 | 需要 vite 别名支持 @/shared |
项目选择了第三种:src/shared/locales.js 被主进程和渲染进程同时 import。
// shared/locales.js
export const messages = {
'zh-CN': {
appTitle: '吾之所向',
filterAll: '全部',
priorityHigh: '上'
// ...
},
en: {
/* 对应英文 */
}
}
export const trayMessages = {
'zh-CN': { theme: '主题', light: '浅色', quit: '退出应用' },
en: { theme: 'Theme', light: 'Light', quit: 'Quit Application' }
}
数据流
托盘菜单切换语言
└→ trayManager.updateTrayMenu()
└→ IPC 'locale-changed' → 渲染进程
└→ I18nContext.setState()
└→ 所有 useI18n().t() 重新计算
渲染进程切换语言
└→ I18nContext.setLocale()
├→ localStorage
└→ 没有 IPC 通知主进程(托盘菜单下次重建时从 Store 读取)
I18nContext 内部用了 useCallback + 参数插值:
const t = useCallback(
(key, params) => {
const raw = messages[locale][key]
if (!raw) return key
return raw.replace(/\{\{(\w+)\}\}/g, (_, k) => params?.[k] ?? '')
},
[locale]
)
⚠️ 注意这里没有使用 useMemo 对整个 { locale, t, setLocale } 做稳定化——但 t 本身的 useCallback 已足够,因为所有子组件都只依赖 t 函数引用。
待办数据流:单向 + 优先级排序
存储模型
每条待办是一个扁平对象:
{
id: number, // Date.now(),单机够用
text: string,
completed: boolean,
priority: 'high' | 'medium' | 'low',
createdAt: number
}
useTodos 设计
function useTodos() {
const [todos, setTodos] = useState(() => {
// 懒初始化:从 localStorage 读取
})
// 每次变更自动持久化
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos])
// 所有写操作都经过 sortByPriority
const addTodo = (text, priority) =>
setTodos(prev => sortByPriority([...prev, { id: Date.now(), text, ... }]))
const updateTodo = (id, updates) =>
setTodos(prev => sortByPriority(prev.map(t => t.id === id ? { ...t, ...updates } : t)))
const reorderTodos = (fromIndex, toIndex) =>
setTodos(prev => { /* splice 交换 */ })
}
关键点:每次变更后都重新按优先级排序(高→中→低),但保持同级内按 createdAt 降序。reorderTodos 例外——手动拖拽应该覆盖默认排序,因此 reorderTodos 只做 splice,不调用 sortByPriority。
优先级排序 vs 手动排序的矛盾
这是一个设计权衡:
- 如果用纯优先级排序,用户拖拽后下次增删会回到排序态,拖拽效果存不住。
- 当前方案:
addTodo/updateTodo/restoreTodo都调用sortByPriority,但reorderTodos保留用户的手动顺序。
更严谨的方案可能需要一个 order 字段,但当前优先级的三种级别(高/中/低)在典型场景下不会频繁变化,手工排序主要用于同级调整,因此不引入 order 字段。
组件架构与数据流
App
├─ AddTodoForm
│ ├─ textarea(输入)
│ └─ 优先级按钮组(high/medium/low)
│
├─ TodoList
│ ├─ TodoFilters(搜索框 + 过滤按钮 all/active/completed)
│ ├─ DndContext / SortableContext
│ │ └─ TodoItem[](每条任务)
│ │ ├─ 拖拽手柄
│ │ ├─ 优先级徽章
│ │ ├─ 行内编辑(双击切换 textarea ↔ span)
│ │ ├─ 文本展开(line-clamp-2 / group-hover)
│ │ └─ checkbox
│ └─ 底部统计
│
└─ UndoToast(绝对定位浮层)
关键设计决策
1. 行内编辑 vs 弹窗编辑
选择双击行内切换 textarea,而不是弹窗 Modal。理由:
- 减少上下文切换,操作路径更短。
- React 19 的并发渲染让 textarea 出现/消失无闪烁。
- 文本区域在编辑态变成
h-40的完整 textarea,提供足够编辑空间。
2. 文本展开:line-clamp-2 + hover
<span className="line-clamp-2 group-hover:line-clamp-none">{todo.text}</span>
默认两行截断,鼠标悬停在整个 <li>(group)上时完全展开。效果类似 macOS Finder 的 filename truncation 但更激进。
3. 拖拽组件的 pointer sensor
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
distance: 5 防止点击与拖拽误触:普通 click 只移动 0-2px,拖拽则需要至少 5px。这比 delay 方案更自然,因为用户期望点击 checkbox 不需要等待。
撤销删除:5 秒 Toast + 恢复
核心是一个延迟执行的引用队列:
const [showUndo, setShowUndo] = useState(false)
const undoTodoRef = useRef(null) // 保存被删的 todo 对象
const undoTimerRef = useRef(null) // setTimeout ID
function handleRemove(todo) {
removeTodo(todo.id) // 立即从列表删除
undoTodoRef.current = todo // 存快照
setShowUndo(true)
clearTimeout(undoTimerRef.current)
undoTimerRef.current = setTimeout(() => {
setShowUndo(false)
undoTodoRef.current = null // 5 秒后丢弃
}, 5000)
}
function handleUndo() {
if (undoTodoRef.current) {
restoreTodo(undoTodoRef.current) // 重新加入(含优先级重排)
}
setShowUndo(false)
}
为什么不用状态存储 todo? 因为 removeTodo 已经把它从 todos 数组中移除了。如果用 useState 保存待恢复的 todo,每次 todos 变化都会导致 [todos, pendingUndo] 平行状态——两个数组需要同步。useRef 完美避开了这个问题:ref 的变化不触发重渲染,只有在点击撤销时才读取。
Toast 组件本身用 CSS transition 控制出现/消失动画:
<div className={`transition-all duration-300 ${
show ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
}`}>
show → false 时用 pointer-events-none 防止透明 toast 拦截点击。
构建配置
electron-builder.yml 有两个定制点值得说明:
1. NSIS 安装包命名
nsis:
artifactName: ${name}-${version}-${os}-${arch}-beta.${ext}
产出:wuzi2do-0.0.3-win-x64-beta.exe。加 -beta 后缀区分发布版与内部测试版。
2. Portable + NSIS 双目标
win:
target:
- nsis
- portable
给用户两种选择:绿色便携版(单 exe,写注册表)或安装版。便携版同样命名 -portable-beta。
3. 后处理压缩
scripts/postbuild-win.mjs 用 archiver 将 unpacked 目录打包成 Zip。最初方案依赖系统 7-Zip(find7z()),但这意味着 CI 或新电脑必须装 7-Zip。迁移到纯 JS archiver 后无外部依赖,压缩级别 zlib.level: 9。
系列导航
- 上一篇:架构与边缘吸附系统《一》
小结
- 主题层使用 CSS 变量 +
@theme两层结构,既享受 Tailwind 工具类的便利,又保留运行态动态切换能力。 - 国际化通过
src/shared/locales.js在进程间编译共享,避免了 IPC 通信延迟和配置冗余。 - 待办数据流保持单向:
useTodos作为唯一数据源,所有写操作都收敛到setTodos,且通过useCallback保证引用稳定性。 - 撤销删除用
useRef保存快照而不是useState,避免了平行状态同步问题。 - 拖拽的
distance: 5与文本悬停展开(group-hover:line-clamp-none)是细节体验上的微优化。