Skip to content

Go back

Electron主题、国际化与待办事项核心系统《二》

Published:  at 

系列导航


主题系统: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)
  }, [])
}

初始化顺序

  1. main.jsxbootstrapDocument() 在 React 挂载前从 localStorage 读取主题,设置 data-theme —— 防止 FOUC(闪白)。
  2. React 挂载后,ThemeProvider 从 IPC 同步读取主进程设置(更权威),覆盖 localStorage 值。
  3. 此后所有切换(托盘菜单 or 渲染进程 UI)都走 setThemedata-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 手动排序的矛盾

这是一个设计权衡:

更严谨的方案可能需要一个 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。理由:

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.mjsarchiver 将 unpacked 目录打包成 Zip。最初方案依赖系统 7-Zip(find7z()),但这意味着 CI 或新电脑必须装 7-Zip。迁移到纯 JS archiver 后无外部依赖,压缩级别 zlib.level: 9


系列导航


小结



Next Post
Electron应用架构与边缘吸附系统《一》