一张壁纸的工程修养:装饰性功能的容错与降级设计

简介: 本项目为多端壁纸系统,以工程化思维应对性能、网络与容错挑战:采用隔离DOM子树+CSS命名空间实现静默降级;四档FPS自适应调节;代际Token+看门狗双机制解决加载竞态;分类熔断+七重上传校验保障稳定;全链路量化验证确保视觉与性能一致。(239字)

一、为什么一张壁纸需要工程修养

项目皮肤主题完成后,需要现实壁纸功能,对网页桌面进行美化。由于项目要实现一套代码,多端适配,有三条硬约束:

  1. 终端性能参差。用户设备从老旧安卓到高配桌面全覆盖,4K 壁纸加全屏模糊在低端机上可能直接吃掉主线程 —— 倒计时、实时推送这类高频组件可不能陪葬;
  2. 网络环境不可控。现场 Wi-Fi 拥挤是常态,壁纸加载失败可以接受,界面卡住不行;
  3. 装饰性功能的失败应当静默。壁纸挂了就回到渐变背景,不弹窗、不阻塞、不写持久化错误状态。

这三条约束推导出整个架构:壁纸层是独立 DOM 子树,配专属 --wp-* CSS 命名空间,可整体摘除 —— 这里「隔离」的语义是故障回滚(样式污染、JS 状态互不牵连,出缺陷时移除挂载即干净),不是性能隔离:全屏模糊的 GPU 合成开销与 DOM 结构无关,它靠性能档位(后文 L2 起渲染层模糊归零)与全屏页守卫治理;总开关默认关闭是上线灰度策略(兼作天然回滚位);所有异常路径以「静默降级回渐变」为兜底。

二、架构分层

┌─ 渲染层 AppWallpaper.vue ── 组内恒和交叉淡入 / 遮罩(纯 CSS 消费主题变量) / fullscreen 守卫
├─ 状态层 wallpaper.store ── Pinia + persist pick(仅用户配置与档位落盘,审计字段不落)
├─ 加载层 createWallpaperLoader() ── idle 排队 + fetchPriority + generation token + LRU(当前+下一张)
├─ 性能层 FPS 采样器 ── L0~L3 四档 / 粒子密度接口 / 迟滞升降档
├─ 容错层 熔断器 ── builtin/custom 分类计数桶 + 5min 冷却
└─ 后端上传链 ── 七道关卡 + sharp 重编码 / 埋点端点 sendBeacon

三、五个值得展开的技术点

3.1 换壁纸的竞态,比想象中多

快速连续切换时,在途的图片加载必须作废。第一反应是 AbortController —— 但它对 new Image() 预载无效(Image 不走 fetch 信号)。我们最终用了三层方案:

第一层:generation token 代际作废。 loader 工厂持有私有代际计数,每次新加载 ++gen,回调先校验自身是否属于当前代际,过期则直接自灭。源码核心(节选):

// web/src/utils/wallpaper-loader.ts(节选)
const myGen = opts?.bumpGen === false ? gen : ++gen  // false 仅看门狗竞速用
img.onload = () => {
   
  if (!isMounted.value) return reject(WP_CANCELLED)        // 卸载守卫
  if (myGen === gen && !settledGen.has(myGen)) {
   
    markSettled(myGen); resolve(img)                       // 胜者交付
  } else {
   
    disposeWallpaperImage(img); reject(WP_CANCELLED)      // 过期/落败:自灭
  }
}
img.onerror = () => {
   
  disposeWallpaperImage(img)                               // 失败也先释放
  if (myGen !== gen || settledGen.has(myGen)) return reject(WP_CANCELLED)
  markSettled(myGen); reject(new Error('wp-load-fail'))   // 只有首败上报熔断
}

function cancelAll() {
     // 卸载 / 手动切换 / 熔断统一调用
  gen++
  for (const r of pendingRejects) r(WP_CANCELLED)         // 全部在途以哨兵结算
  pendingRejects.clear()
}

配套「零悬空」承诺:卸载或手动切换时显式调用 cancelAll(),让所有在途 Promise 以哨兵值结算 —— 不给上层留一个 pending 的 Promise。前提是浏览器回调机制正常;Image 永不触发回调属于平台级故障,不在应用层防御射程内。预加载与代际的交互:预加载用默认参数发起,发生在主图已应用之后(此时无在途请求,++gen 无副作用);用户切换新图时 cancelAll() 会一并结算预加载 —— 预加载是尽力而为的缓存预热,让位于切换即时性。

第二层:10 秒看门狗并行竞速。 初始加载走 requestIdleCallback + fetchPriority='low' 双重低优先级(不抢首屏资源),但重度负载下可能饥饿 —— 这里针对的是「排队饥饿」(请求排不上队),不是带宽瓶颈:若请求已在传输中,补发的同 URL 请求共享同一目标主机的拥塞窗口,不会双倍变慢;而真正的弱网传输瓶颈由占位图与熔断链路兜住。10 秒未完成则补发一个 fetchPriority='auto' 的并行请求竞速,先完成且代际有效者生效,落败实例 dispose。诚实说明:浏览器无法中止 detached Image 的底层传输,dispose 只是释放引用 —— 多余的那一次传输是这套策略接受的成本(换来调度饥饿场景的确定性恢复)。

第三层:胜负标记防「迟到失败」污染。 竞速暴露了一个隐性 bug:A 先成功应用,B 后失败 —— 原实现会让 B 的失败逻辑把已应用的壁纸回退掉,还污染熔断计数。解法是引入 settledGen 集合:任何请求一旦分出胜负(成功或失败),就把代际记入;后到的同类请求无论成败,只以哨兵结算自身,不再触发业务副作用。

压力验收:连续快速切换 10 次加滑杆拖动风暴,Network 面板可见被作废请求的空洞,最终态无降档、无错乱。实测记录(快速操作压力用例,同轮采样):连点 10 次切换 + 滑杆 11 次快速跳变 —— Network 面板 reqid 空洞(代际作废的直接证据)+ 304 缓存命中,内存 +1.47MB 后稳定,最终态无降档。

3.2 性能档位:让低端机自己找到活法

FPS 采样器维护 120 帧滑动窗口,每秒结算一次:慢帧(>22ms,约 45fps 门槛 —— 低于 60fps 的流畅线但高于 30fps 的可感知卡顿线)占比超 15% 且持续 3 秒 → 降一档;连续达标 60 秒 → 升一档。迟滞区间(3 秒降 vs 60 秒升)防止档位震荡。参数标定的诚实声明:除 22ms 有帧率口径外,15% / 3 秒 / 60 秒是设计初值而非实验标定 —— 依赖的回调机制是埋点里的 perf-migrate 事件(携带 from→to 迁移明细),灰度上线后按真实迁移分布回调这些数字,而不是凭感觉锁死。页面切后台时 rAF 降频会制造假慢帧 —— visibilitychange 隐藏即冻结判档,恢复后清窗重统计。

判档核心源码(节选,迟滞防抖的实现):

// web/src/components/AppWallpaper.vue(节选)
function settlePerf(now: number) {
   
  let slow = 0
  for (const d of perfDeltas) if (d > PERF_SLOW_MS) slow++
  if (slow / perfDeltas.length > PERF_DOWN_RATIO) {
   
    perfUpSince = 0
    if (!perfDownSince) perfDownSince = now
    else if (now - perfDownSince >= 3_000 && wp.perfLevel > 0) wp.setPerfLevel(wp.perfLevel - 1)
  } else {
   
    perfDownSince = 0
    if (!perfUpSince) perfUpSince = now
    else if (now - perfUpSince >= 60_000 && wp.perfLevel < 3) wp.setPerfLevel(wp.perfLevel + 1)
  }
}

四档效果(关键:用户设定值永不被物理覆盖,降级只发生在渲染层):

档位 模糊 粒子 预加载 面板
L0 全特效 用户值 全量 可调
L1 渲染减半 全量 可调
L2 渲染归零 减半 可调
L3 渲染归零 停自动 * 模糊 / 透明度置灰(保留已存值)

* 停的是自动预加载;用户手动切换仍允许一次加载。

一个值得展开的产品决策:L2 为什么不禁滑杆? 因为调节模糊这个动作本身就是「重新检测性能」的触发器 —— 用户拖一下滑杆,档位归零重测,如果设备其实扛得住,立刻回到 L0。如果 L2 把滑杆锁死,用户就只剩被动等待 60 秒采样达标这一条路。代价的另一面也要说清:设备真的扛不住时,拖滑杆意味着重新经历完整的降级周期(L0 下两轮各 3 秒的慢帧窗口,约 6 秒渐进跌回 L2)—— 重测通道服务的是「被误降」设备的恢复出口,对真低端设备,用户主动拖滑杆的意图表明他愿意再试一次,这是可接受的权衡。

至于 L3 为什么置灰:它是终点态 —— 模糊渲染已归零、粒子已关,没有往下调的空间;往上调等于重新增加负载,在核心业务面前性能安全优先于视觉偏好。所以准确的原则表述是「降级保留安全的操作空间」:L2 调模糊重测是安全的,L3 的上调不是。实测记录(三档对照):L1/L2 下滑杆 aria-disabled 不存在、键盘可调,仅 L3 禁用;L1/L2 下调模糊即触发归零重测(设计行为实测确认)。

3.3 熔断器要分类,降级要有台阶

弱网链路是三级台阶:thumbnail 占位先行 → 高清主图跟上;占位图自身挂了就静默跳过、直升高清(底层恒有主题渐变,永远不白屏)。

熔断器按类别分桶:内置预设与用户上传各自计数,同类失败 3 次只灰显对应类别,顶部 banner 提示「内置壁纸加载异常,已熔断保护」,5 分钟冷却后自动恢复,也可手动重试。源码核心(节选):

// web/src/stores/wallpaper.store.ts(节选)
function markLoadFail(category: 'builtin' | 'custom', url: string) {
   
  cooldownMap.set(url, Date.now() + COOLDOWN_MS)      // 单 URL 5 分钟冷却
  if (category === 'builtin') builtinErrorCount++     // 分类计数桶
  else customErrorCount++
  const count = category === 'builtin' ? builtinErrorCount : customErrorCount
  if (count === CIRCUIT_THRESHOLD)                    // 恰达阈值那次额外埋点
    trackWallpaperEvent('circuit-trigger', category)
}

实测记录(熔断隔离用例):3 个不同 URL 各失败一次 → 自定义全部灰显 + banner,内置切换不受影响;重试按钮清零恢复。曾考虑全局单桶 —— 但自定义壁纸服务器挂掉时把内置预设也灰显,明显误伤。熔断状态只存内存,显式禁止写入 localStorage —— 这个决策的权衡面:服务器持续故障时,每次刷新都要重新付 3 次失败成本。之所以仍不持久化:单次失败成本极低(低优先级请求 + 占位先行 + 渐变兜底,用户视觉几乎无感),而持久化故障态需要跨会话时钟与恢复探测,复杂度不成比例;持续故障的真正出口是换图、删除或关开关 —— 熔断只是会话内防抖,不是故障记忆。

离线场景三层守卫:断网时入口直接短路(不发注定失败的请求)、空闲回调触发时二次校验、online 事件自动重派。两个细节:已显示的壁纸断网不撤除 —— background-image 纯内存渲染零成本,撤了反而闪一下;断网时用户主动切换新壁纸,行为是选择记入状态、视觉保持旧图、网络恢复后自动应用新选 —— 不闪、不白、不丢操作。

3.4 视觉一致性怎么断言:给「无脏色」定一个数

壁纸之上有一层主题色遮罩(保证任何壁纸下文字可读)。12 套皮肤 × 明暗两态 = 24 种遮罩色,要求与主题系统同源。方案评审抓出一个隐患:若由壁纸模块旁路调用调色库自行派生遮罩色,炭灰皮肤会取到蓝化的脏色(灰调皮肤配蓝色遮罩)—— 旁路取色不仅色值错误,还形成了第二依赖头。最终裁决改为管道单头:主题管道多输出一个深色变量,壁纸纯 CSS 消费。

怎么防回归?写了个零依赖校验脚本(jiti 直跑 TS),做四重断言:

  1. 口径一致:亮色遮罩 ≡ tone40(与主题主色同源推导);
  2. 快照回归:12 套暗色 tone30 固化成表 —— 这重断言兜住一切色值漂移(包括温和的色偏,比如暖灰变冷灰这种极差不变的漂移,diff 一样一目了然);品牌有意换色时一行 --snapshot 重录快照,维护成本是改一条命令;
  3. 炭灰真灰专项:RGB 三通道极差 ≤ 8(实测 #444748 极差 4,是低 chroma 配方的正常容差;蓝化脏色 #1360a5 极差 146,数量级隔离)—— 注意分工:这重只判「真灰性」(防大偏色),温和漂移由上一重快照负责;
  4. 三元组格式--*-rgb 变量与 hex 逐通道一致。

从此「遮罩同源无脏色」从 code review 的目测项,变成皮肤体系变更的必过门禁。实际运行输出(节选):

$ npm run verify:overlay
OK   [red]    tone30=#930013 rgb="147, 0, 19"
OK   [blue]   tone30=#004395 rgb="0, 67, 149"
OK   [slate]  tone30=#444748 rgb="68, 71, 72"    ← 炭灰:极差 |72-68| = 4
……
全部断言通过(12 套 × 口径一致性 + 快照回归 + 炭灰真灰 + 三元组格式)

3.5 上传链路的七道关卡(简述)

上传链路七道关卡:JWT 守卫 → 限流 10 次 / 分钟 → MIME + 扩展名双白名单(SVG 天然拒绝,防脚本注入)→ 落盘后魔数嗅探(防伪造 Content-Type)→ sharp 解析双上限(总像素 ≤ 16M 且最长边 ≤ 4000px,任一超标即拒 —— 4000×10000 这类单边合规但 40M 像素的图会被总像素拦下)→ 配额 5 张(防资源耗尽,算业务限制但同样是 DoS 面的防御)→ 重编码为随机名 webp(sharp 编码默认不写任何元数据,要保留 ICC 反而需显式开启 —— 我们登记在案的广色域色偏副作用正是这个默认行为的佐证,所以「剥掉全部 EXIF/GPS」是默认行为而非额外参数;重编码同时是防御层:规范化输出消除结构攻击面)。库层 RLS 纵深防御不在七道之内,如实登记为技术债(见「诚实的边界」)。响应只返回 id / url / 尺寸,不泄露存储路径。后三道关卡源码(节选):

// server/src/media/wallpaper.controller.ts(节选)
await assertImageMagic(file.path, WALLPAPER_KINDS)          // 防线 3:魔数嗅探
const meta = await sharp(file.path).metadata()              // 防线 4:像素校验
if (w * h > 16_000_000 || Math.max(w, h) > 4_000)
  throw new BadRequestException('图片像素超限:最长边不得超过 4000px')
// 防线 5:配额
if (count >= WALLPAPER_QUOTA) throw new BadRequestException('壁纸已达 5 张上限…')
// 防线 6:重编码 —— rotate 应用 EXIF 方位后丢弃全部元数据
const encoded = await sharp(file.path).rotate().webp({
    quality: 82 }).toFile(outPath)

冒烟序列:未登录 401 → 真实 jpg 上传 200(产物 12.5KB 随机名 webp)→ SVG 拒 400 → 文本伪装 .jpg 魔数拒 400 可读文案 → 删除 200 → 重放删除 404。

冒烟实录中最有说服力的一条:用文本文件伪装 .jpg 上传,魔数嗅探拒绝并给出可读错误文案;真实上传成功后,服务端产物是 12.5KB 的随机名 webp,响应中找不到原始文件名。

四、踩坑三则(验证方法论沉淀)

坑 1:dev server 的「假 404」。 验证「占位图缺失降级」时临时移走了 thumbnail 文件,Network 却显示 200 —— Vite dev 对 public 目录不存在的文件返回 SPA fallback(200 + text/html),浏览器 img 解码 HTML 失败走 onerror,恰好与 404 同路径。教训:行为断言(onerror 触发 / naturalWidth=0)比状态码断言更可移植,状态码口径属于部署环境。

坑 2:看门狗把调试通道「改回去」。 用调试接口 setPerfLevel(2) 强制档位观察效果,约 1 秒后被自动改回 —— 帧率良好时升档看门狗与手动设档并发竞争,观察窗口内档位已从 L2 升到 L3。曾据此误报「L2 滑杆被禁用」的缺陷,经源码核查加三档对照实测才澄清。教训:调试「写状态」接口与自动调度并发时,先停表(清采样窗口)再观察

坑 3:模拟失败标志的一次性语义。 调试通道的 simulateFail 是一次性标志(下次加载消费即复位),且失败 URL 会进入 5 分钟冷却表 —— 冷却检查在标志消费之前。想用同一 URL 连续失败 3 次触发熔断?第二次就提前 return 了。教训:调试标志与业务防抖叠加时,先读防抖逻辑再设计触发序列

五、验收数据(桌面)

结果
全屏页往返(登录页等全屏路由) 零壁纸残留、零 console 告警、粒子 canvas 像素采样实证恢复
弱网降级 thumb→主图序列正确;熔断 banner + 灰显 + 重试恢复闭环
内存 60 次切换三段采样 heap:29.61 → 30.07 → 30.49 → 29.63 MB(非单调、回落基线,LRU 生效无泄漏);快速操作压力用例内存 +1.47MB 后稳定
熔断隔离 自定义灰显时内置正常
screen 异常兜底 嵌入式设备 screen=0 → 落全特效档,无循环重载
快速操作压力 连点 + 滑杆风暴:代际作废可见、无降档、内存稳定
遮罩量化 12 套皮肤四重断言全过

两个「部分通过」项的明细(不是失败,是桌面不可复现的物理项):折叠屏 DPR 适配 —— 桌面近似验证已过(DPR 恒定下 30 次 resize 零重载 + 档位判定取 screen 物理宽 × DPR 的源码佐证),折叠展开 / 跨屏拖拽待物理设备;reduced-motion —— 代码层媒体查询与粒子跳过分支静态验证在位,待真机系统设置实测。

iOS Safari 真机项(blur 滚动掉帧、dvh 地址栏收展、安全区)同样待物理设备执行 —— 桌面不可复现的原生行为,我们选择如实标注,而不是拿近似值充数。

六、未竟之处

  1. 帧率门控版 idle 调度未启用:连续 2 帧空闲才执行加载的增强版,需真机双版本对比数据证实收益后才替换默认实现 —— 没有数据的优化不做;
  2. 上传表 RLS 策略是登记在册的技术债:当前靠 JWT 主体 + 查询过滤(应用层有效),库层纵深防御待接入项目迁移体系;
  3. 埋点 @Public 是权衡而非疏忽:sendBeacon 带不了 Authorization 头,安全边界转为白名单 + 限流 + 截断三重;
  4. 演进项全部标注前置约束:CDN 化先改 CSP、轮播要独立预加载数量上限(直接复用两实例逻辑会内存无限涨)…… 每条「以后可以做」都写清「先要做什么」;
  5. 功能当前处于「交付完成、灰度前」阶段:默认关闭是上线判据的灰度策略而非永久形态,开启率(toggle-on/off)、档位迁移分布(perf-migrate)、熔断触发率等埋点指标灰度后用数据决定放量节奏与阈值回调 —— 本文记录的是架构与机制,参数的最终形态由上线后的数据说话。

七、结语

这套系统最有复用价值的不是任何单个技术点,而是一条原则:装饰性功能的工程预算,应该花在「它出错时」而不是「它正常时」。正常路径只是换张图;出错路径 —— 竞态、饥饿、断网、低端机、恶意上传、卸载瞬间 —— 每一条都值得一个明确的设计决策,以及一句「失败时回到哪」。

壁纸坏了,业务还在继续。这就够了。


技术栈:Quasar v2.27 + Vue 3.6.0-rc + Pinia(persist pick)/ NestJS + Prisma + sharp;验证门禁 vue-tsc + build + verify:overlay(12 套皮肤量化断言)+ 浏览器代理桌面用例;实施细节见项目内方案文档(v2.6,759 行,六轮评审 49+6 项全处置)。

image.png

相关文章
|
1天前
|
人工智能 机器人 新能源
2026 年智能机器人如何赋能工厂?FANUC、ABB、KUKA领跑全球,仙工智能加速突围
本文梳理2026年全球10家智能机器人代表企业,涵盖FANUC、ABB、KUKA及仙工智能等,从产品体系、技术路线与工厂应用三维度对比分析。重点呈现中国企业在机器人大脑、具身智能与平台化发展上的突破,为制造业智能化升级提供选型参考。
|
28天前
|
监控 Shell API
Qoder CLI /loop 大升级:Agent 自调节奏,盯盘场景交给它就行了
Loop Engineering 新增动态唤醒机制:`/loop` 不再依赖固定间隔,Agent 可自主决定检查频率、触发时机与终止条件。支持 Monitor 事件秒级响应、三模式自动路由、TUI 管理面板及持久化任务,让盯盘、告警监控等弹性场景真正实现“交出判断权”。
258 0
|
17天前
|
IDE 网络安全 开发工具
如何在 WSL2 中使用 Qoder CN(Remote-SSH 免密连接)
本文完整梳理WSL2搭配qoder-cn的搭建流程, 通过Remote-SSH免密连接核心解决VSCode内置ssh2库兼容问题导致的OpenSSH agent连接失败报错。通过启用Windows ssh-agent服务、生成安全ED25519密钥、配置WSL SSH公钥认证、修复文件权限、优化Windows SSH配置规避agent调用,最终实现Windows PowerShell及VSCode免密直连WSL2。同时汇总实操高频踩坑点,明确WSL2 NAT网络特性、SSH权限严苛规则、VSCode内置库兼容缺陷等关键问题,提供终极稳定配置方案,可一键复现稳定的WSL远程开发环境。
232 0
|
2月前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
3718 141
|
10天前
|
JavaScript API 开发工具
DeepSeek Harness 0.1.1-rc.1 发布:我的插件和启动器已完成适配
DeepSeek Harness 发布 0.1.1-rc.1,本文整理官方版本状态,并介绍 dsh-billing、Error Lens、Git Inspect、Provider Probe、Concurrency Meter 与 dsh-launcher 的兼容更新。
DeepSeek Harness 0.1.1-rc.1 发布:我的插件和启动器已完成适配
|
11天前
|
存储 JavaScript 安全
dsh 拆解系列 Vol.01:没有特权内核的 Agent 运行时
DeepSeek Harness(dsh)是DeepSeek开源的Agent运行时框架,秉持“一切皆插件”理念,将模型适配器、工具、会话、主循环等全部解耦为可配置、可替换、可卸载的插件,基于Cordis元框架实现时空可组合性。当前v0.1.0-rc.7为开发者预览版,MIT协议,强调工程可扩展性而非仅功能堆砌。
150 2
dsh 拆解系列 Vol.01:没有特权内核的 Agent 运行时
|
4天前
|
人工智能 安全 API
阿里云百炼API‑Key完整实操指南:账号开通、免费额度领取与多方式调用实战教程与排错全流程手册
随着大模型技术普及,越来越多开发者、科研人员、业务团队需要通过API接口调用各类大模型服务。百炼作为一站式大模型服务平台,聚合多款主流文本、多模态大模型,对外提供兼容OpenAI协议的标准API接口。无论是自主开发AI应用、调试知识库RAG项目,还是对接Claude‑Code、Hermes Agent、OpenClaw这类终端智能体工具,都必须获取合法有效的API‑Key作为身份鉴权凭证。
139 2
|
11天前
|
Windows
【.NET 3.5】.NET Framework 3.5离线安装包使用教程(Win10/11通用,亲测好用)
.NET Framework 3.5是微软经典运行环境,Win7及更早系统默认内置,而Win10/11需手动启用。它兼容大量老软件与游戏,缺之则报错“找不到.NET 3.5”。最新版为SP1(2008年发布),稳定成熟,与4.x系列共存无冲突。(239字)
|
23小时前
|
自然语言处理 机器人 程序员
向量检索不准怎么办:混合检索与 Rerank 重排序召回优化实战
向量检索看不懂报错码和型号?一文讲透 BM25 关键词检索、RRF 混合检索与 Rerank 重排序,附 Python 代码,拉满 RAG 召回率与答案准确率
|
4天前
|
存储 安全 API
DeepSeek Harness 更新了什么?多模态图片输入、Codex/Claude Code 子代理与 Web UI 新特性
DeepSeek Harness 0.1.1-rc.2 是最新版:多模态图片输入支持原生图片请求、@ 引用与 DeepSeek-V4-Flash-Vision-Exp 视觉模型;Codex 与 Claude Code 子代理按需安装;Web UI 新体验含自动开浏览器与插件设置卡片,升级注意 SQLite 存储格式不兼容。
143 0