Files
DevFlow/src/stores/appSettings.ts
绝尘 b300bb721a 优化: CR-30 appSettings useSetting 值类型契约文档+changed helper JSON 深比较
useSetting JSDoc 加值类型/整体替换契约(对象/数组深 mutate r.value.x=1 不落库,须 ref.value=newObj 整体替换)+/示例,当前无 bug 注明防误用;加 changed(a,b) helper(Object.is 短路保循环守卫 + JSON.stringify 回退对象/数组深比较),双 watch 守卫 Object.is→changed 增强对象值鲁棒性。批10,vue-tsc 0
2026-06-15 08:50:09 +08:00

159 lines
5.8 KiB
TypeScript

//! 应用设置 KV Store — localStorage → SQLite 迁移的统一持久化层
//!
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
//!
//! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 ——
//! 那边承载 provider 业务数据,本文件承载运行时偏好,故单独成文,互不干扰。
import { reactive, ref, watch, type Ref } from 'vue'
import { settingsApi } from '../api/settings'
/// 按 key 合并连续写的 debounce 时长(毫秒)
const SET_DEBOUNCE_MS = 300
/// 已解析值的响应式缓存 —— 用 reactive 对象而非 Map,使 `useSetting` 的 watch 能
/// 在 `loadAll` / `set` / `remove` 写入时被触发,从而同步刷新组件视图
const cache = reactive<Record<string, unknown>>({})
/// 按 key 的 pending 写定时器:连续 set 在窗口内只发最后一次到 SQLite
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
const pendingValues = new Map<string, unknown>()
/** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */
async function loadAll(): Promise<void> {
const all = await settingsApi.getAll()
for (const [k, raw] of Object.entries(all)) {
cache[k] = parse(raw)
}
}
/** 同步取已解析值,未命中或缓存未加载时返回 defaultValue */
function get<T>(key: string, defaultValue: T): T {
const v = cache[key]
return v === undefined ? defaultValue : (v as T)
}
/**
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
*/
async function set(key: string, value: unknown): Promise<void> {
cache[key] = value
pendingValues.set(key, value)
const existing = pendingTimers.get(key)
if (existing) clearTimeout(existing)
const timer = setTimeout(() => {
pendingTimers.delete(key)
const latest = pendingValues.get(key)
pendingValues.delete(key)
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
settingsApi
.set(key, JSON.stringify(latest))
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
}, SET_DEBOUNCE_MS)
pendingTimers.set(key, timer)
}
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除 */
async function remove(key: string): Promise<void> {
delete cache[key]
pendingValues.delete(key)
const t = pendingTimers.get(key)
if (t) {
clearTimeout(t)
pendingTimers.delete(key)
}
await settingsApi.delete(key)
}
/**
* 组合式:把某 key 绑定到一个 ref,组件可直接 v-model。
* 读走缓存(同步、响应式),写触发 debounced set。
*
* 实现说明:用普通 `ref` 持有当前值,`watch(cache[key])` 单向同步缓存 → ref,
* 这样 `loadAll` 异步填充缓存后能正确刷新组件视图(customRef 的 trigger 不会被
* reactive cache 的异步 mutation 自动唤起,故改用 watch 显式同步)。
*
* 值类型契约(重要,误用将静默不落库):
* - 适用值类型(基本类型 boolean/number/string)及整体替换语义。
* - 对象/数组值必须以「整体替换」方式写入(ref.value = newObj),
* 严禁深 mutate(r.value.x = 1 / r.value.push(...))。
* 原因:落库守卫用 JSON.stringify 比较新旧值,深 mutate 后 ref 引用未变、
* 但值已与缓存同引用,若直接改 cache 引用又会破坏响应式;故约定对象/数组
* 一律整体替换赋值,使守卫捕获到值变化并触发 set 落库。
*
* @example
* const theme = useSetting('df-theme', 'dark')
* // 模板里 <a-switch v-model="theme" /> —— 改动自动落库
*
* @example 对象/数组须整体替换
* const layout = useSetting('df-layout', { sidebar: true })
* // ✅ 正确:整体替换
* layout.value = { sidebar: false }
* // ❌ 错误:深 mutate 不会落库
* // layout.value.sidebar = false
*/
function useSetting<T>(key: string, defaultValue: T): Ref<T> {
const r = ref(get<T>(key, defaultValue)) as Ref<T>
// 同引用浅比较恒真时,退化为 JSON.stringify 深比较 —— 这样对象/数组即便
// 同引用(整体替换前 ref 与 cache 指向同一对象),只要内容变化也能触发回写
// 与落库。注:仍依赖「整体替换」语义,深 mutate 不在覆盖范围内(见函数文档)。
const changed = (a: unknown, b: unknown): boolean =>
Object.is(a, b) ? false : JSON.stringify(a) !== JSON.stringify(b)
// 缓存 → ref:loadAll/set/remove 改 cache[key] 时同步到 ref
watch(
() => cache[key],
(v) => {
// 写回值与当前 ref 不同时才更新,避免组件 set 后被 watch 回写造成抖动
const next = v === undefined ? defaultValue : (v as T)
if (changed(next, r.value)) r.value = next
},
{ deep: true },
)
// ref → 缓存(及落库):组件改 .value 时触发 debounced set
watch(
r,
(v) => {
// 仅当与缓存当前值不同时才写,避免 watch 回写触发循环
if (changed(v, cache[key])) void set(key, v)
},
{ deep: true },
)
return r
}
/**
* 解析后端原始值:JSON.parse 失败时回退为原字符串(兼容历史非 JSON 数据)。
* null/空串直接返回,避免 `JSON.parse('')` 抛错。
*/
function parse(raw: string): unknown {
if (raw === null || raw === '') return raw
try {
return JSON.parse(raw)
} catch {
return raw
}
}
export function useAppSettingsStore() {
return {
cache,
loadAll,
get,
set,
remove,
useSetting,
}
}