diff --git a/.codebuddy/plans/图片本地缓存方案_f26b36d7.md b/.codebuddy/plans/图片本地缓存方案_f26b36d7.md
new file mode 100644
index 0000000..3781714
--- /dev/null
+++ b/.codebuddy/plans/图片本地缓存方案_f26b36d7.md
@@ -0,0 +1,165 @@
+---
+name: 图片本地缓存方案
+overview: 为小程序新增图片本地文件缓存机制(签名 URL 稳定 key 剥离 + downloadFile 落盘 + TTL/LRU 治理),封装 cached-image 组件并替换全项目所有网络图片渲染点,显著降低 CDN 重复流量。
+todos:
+ - id: create-image-cache
+ content: 新建 utils/image-cache.ts:稳定 key、双哈希落盘、并发队列去重、失败回退熔断、TTL 容量清扫
+ status: completed
+ - id: create-cached-image
+ content: 新建 components/cached-image 组件:占位后显示本地文件,透传 mode/lazy-load,布局与圆角零回归
+ status: completed
+ dependencies:
+ - create-image-cache
+ - id: replace-images
+ content: 替换 11 个页面与 clothes-card 共 22 处 image 并注册组件,逐处核对 mode 与 lazy-load 保持原样
+ status: completed
+ dependencies:
+ - create-cached-image
+ - id: verify-coverage
+ content: Use [subagent:code-explorer] 全仓复核网络图片接入无遗漏,本地临时图与包内素材未受影响
+ status: completed
+ dependencies:
+ - replace-images
+ - id: typecheck-lint
+ content: 运行 TS 严格类型检查与 lint,修复新增/改动文件问题,复查日志级别使用合规
+ status: completed
+ dependencies:
+ - replace-images
+---
+
+## 产品概述
+
+为现有微信小程序(衣橱与穿搭社区)新增统一的图片本地缓存机制:网络图片首次加载后自动下载并持久化到本地,后续展示直接读取本地文件,避免图片被反复从 CDN 拉取,从源头降低流量。
+
+## 核心功能
+
+- 两级缓存:内存映射 + 本地文件持久化,自动完成下载、落盘与命中读取
+- 签名地址兼容:按剥离签名/时间戳参数后的稳定地址命中缓存,地址参数每次变化也不再重复下载
+- 省流量展示:缓存未命中时先显示骨架占位(复用现有骨架风格),下载完成后显示本地图片;每张图仅向 CDN 请求一次
+- 失败兜底:下载失败自动回退为网络地址显示;下载域名未配置时自动熔断,功能完全不受影响
+- 全站覆盖:衣橱、穿搭社区、AI 推荐、分享详情、个人资料等约 22 处网络图片统一接入
+- 自动清理:按有效期与容量上限自动淘汰旧缓存,避免占满本地空间
+- 视觉零变化:除短暂骨架占位外,图片尺寸、圆角、裁剪模式、懒加载表现与现状完全一致
+
+## 现状与根因
+
+- 全仓 22 处网络图片(头像、抠图衣物图、分享封面、拼图封面等)均直接绑定接口返回 URL 渲染,无任何本地缓存逻辑(已全仓检索 downloadFile / FileSystemManager / 预加载,均无结果)。
+- 图片 URL 为带签名/时间戳的临时链接且每次请求会变化;微信 image 组件按「完整 URL(含 query)」做客户端缓存、CDN 也按 URL 缓存,签名一变即双双失效,导致同一张图被反复下载。
+- wxss 中的 url(...) 均为内联 SVG,无 CSS 网络背景图;无 canvas 绘制、无保存到相册场景,改造面收敛在 image 标签内。
+
+## 技术方案(Implementation Approach)
+
+采用「核心缓存模块 + 通用图片组件」的前端两级缓存方案,落地路径如下:
+
+1. 新增 `utils/image-cache.ts`(与 UI 无关的纯逻辑模块,通过 logger 输出日志):
+
+- 稳定 key:剥离大小写不敏感的签名类参数(sign/signature/sig/t/ts/timestamp/time/expires/expire/auth/token/nonce/x-cos-security-token/q-sign-time 等,清单在模块头部集中维护并注释),其余 query 排序后与 host+path 拼接;保证「同一张图即使签名变化」也命中同一缓存条目。
+- 落盘指纹:不引入任何依赖,自实现 FNV-1a + djb2 双 32 位哈希拼接为 16 位十六进制文件名(降低碰撞概率),扩展名从 URL pathname 提取(jpg/png/webp/gif,缺省 .jpg)。
+- 存储位置:`${wx.env.USER_DATA_PATH}/imgcache/`(本地用户文件,随代码包清理,生命周期与代码包一致,安全可靠;总量上限 200MB,本方案自设 80MB 上限留足余量)。
+- 命中判定:内存 Map(O(1))→ `fs.access`(异步)判断磁盘文件 → 均未命中才进入下载。
+- 下载:`wx.downloadFile` 指定 `filePath` 直写目标文件(先惰性 mkdir 递归创建目录);并发队列上限 4;同一稳定 key 的任务去重(复用同一 Promise);statusCode 非 200 视为失败并删除半截文件。
+- 失败与熔断:单图失败仅 debug 日志并让组件回退网络地址;若失败信息包含「url not in domain list」(downloadFile 合法域名未配置),会话级熔断禁用缓存流程,warn 一次(写实时日志)后全部直接回退原逻辑。
+- 容量治理(异步、不阻塞渲染):TTL 7 天 + 容量 80MB / 300 个文件,超出时按文件 mtime 从旧到新删除;清扫节流触发(启动后首次图片解析 5 秒执行一次,运行期内每累计约 50 次成功落盘再执行一次),执行中不重复触发;文件删除失败忽略。
+
+2. 新增 `components/cached-image` 通用图片组件(替换原 image 标签):
+
+- 属性:`src`(网络地址或本地路径)、`mode`(默认 aspectFill)、`lazyLoad`(wxml 用 lazy-load 传入,默认 false,保持各调用点原行为)。
+- 流程:src 非 https(wxfile://、http://tmp、http://usr 等本地临时/包内路径)直接渲染不进入缓存;https 地址先查内存/磁盘,命中直接显示本地文件(全程零网络请求);未命中显示骨架占位并进入下载队列,成功后显示本地文件。
+- 关键约束:占位与下载期间绝不渲染原始网络 URL,避免 image 组件自发请求抵消缓存收益;仅下载失败后回退渲染网络地址,保证功能可用。
+- 自愈:内层 image 触发 error 且当前为本地路径时,删除该缓存文件并回退网络地址一次(应对文件被系统清理)。
+- 样式兼容(零布局回归):组件 wxss 用 `:host { display: inline-block; }` 对齐原 image 元素默认布局行为,内层 image `width/height: 100%; display: block; border-radius: inherit` 保证填满宿主并继承圆角;调用点原有 class(.pic 等尺寸体系)、mode、lazy-load 原样保留,class 落在组件宿主节点上,无需改动任何现有 wxss。
+
+3. 接入范围:11 个页面 wxml + clothes-card 组件共 22 处 image 标签替换为 cached-image,并沿用现有注册惯例(`"cached-image": "/components/cached-image/cached-image"`)在对应页面/组件 json 的 usingComponents 中注册;wardrobe 页经 clothes-card 间接生效,upload 页本地预览、damo 包内素材、图标等一律不动。
+
+## 系统架构
+
+```mermaid
+flowchart LR
+ A[cached-image 组件收到 src] --> B{是否 https 网络图?}
+ B -- 否 --> C[直接渲染本地路径]
+ B -- 是 --> D{内存命中?}
+ D -- 是 --> E[显示本地文件]
+ D -- 否 --> F{磁盘已存在?}
+ F -- 是 --> E
+ F -- 否 --> G[并发队列 downloadFile 落盘]
+ G -- 成功 --> E
+ G -- 失败 --> H[回退显示网络 URL]
+```
+
+## 目录结构
+
+```
+zt-closet-wxmini-frontend/miniprogram/
+├── utils/
+│ └── image-cache.ts # [NEW] 缓存核心:稳定 key 剥离、双哈希文件名、内存+磁盘命中、
+│ # downloadFile 并发队列与去重、失败回退与域名熔断、
+│ # TTL/容量清扫、logger 日志(LOG='image-cache')
+├── components/
+│ ├── cached-image/
+│ │ ├── cached-image.ts # [NEW] 组件逻辑:属性定义、src 监听、本地/网络分流、占位与回退、
+│ │ │ # 出错自愈(删坏文件并回退网络地址)
+│ │ ├── cached-image.wxml # [NEW] 占位层 + 内层 image(mode/lazy-load 透传)
+│ │ ├── cached-image.wxss # [NEW] :host 布局对齐、内层 100% 填充与 border-radius 继承、
+│ │ │ # 骨架占位(对齐现有 .sk 视觉,局部 keyframes)
+│ │ └── cached-image.json # [NEW] { "component": true }
+│ └── clothes-card/
+│ ├── clothes-card.wxml # [MODIFY] 1 处 image(衣物瓦片,lazy-load 保留)
+│ └── clothes-card.json # [MODIFY] 注册 cached-image
+├── pages/
+│ ├── outfit/ # [MODIFY] outfit.wxml 4 处 + outfit.json 注册
+│ ├── ai-recommend/ # [MODIFY] ai-recommend.wxml 2 处 + json 注册
+│ ├── outfit-detail/ # [MODIFY] outfit-detail.wxml 2 处 + json 注册
+│ ├── outfit-edit/ # [MODIFY] outfit-edit.wxml 1 处 + json 注册
+│ ├── outfit-share-detail/ # [MODIFY] outfit-share-detail.wxml 3 处 + json 注册
+│ ├── closet-share/ # [MODIFY] closet-share.wxml 2 处(封面/头像)+ json 注册
+│ ├── closet-share-create/ # [MODIFY] closet-share-create.wxml 1 处(头像)+ json 注册
+│ ├── closet-share-detail/ # [MODIFY] closet-share-detail.wxml 2 处 + json 注册
+│ ├── ai-pending/ # [MODIFY] ai-pending.wxml 1 处(封面格)+ json 注册
+│ ├── profile/ # [MODIFY] profile.wxml 2 处(头像)+ json 注册
+│ └── clothes-detail/ # [MODIFY] clothes-detail.wxml 1 处(详情大图)+ json 注册
+```
+
+不改动:pages/wardrobe(经 clothes-card 生效)、pages/upload(本地临时图预览)、damo 素材、custom-tab-bar、全局样式、config.ts、接口层与所有 .ts 业务逻辑。
+
+## 关键代码结构
+
+```ts
+// miniprogram/utils/image-cache.ts —— 对外接口(仅签名)
+/** 内存命中快速通道:命中返回本地路径,否则 null(供组件首帧零延迟显示) */
+export function getCachedLocalPath(src: string): string | null
+
+/** 解析图片:磁盘命中直接返回本地路径;未命中入队下载落盘后返回;失败 reject(调用方回退网络地址) */
+export function resolveImage(src: string): Promise<string>
+
+/** 手动清空图片缓存目录(预留维护入口) */
+export function clearImageCache(): Promise<void>
+```
+
+组件属性约定:`src: String`;`mode: String = 'aspectFill'`;`lazyLoad: Boolean = false`(wxml 以 `lazy-load` 传入)。
+
+## 实施注意(Implementation Notes)
+
+- 占位/下载期间严禁渲染网络 URL(核心防漏点);仅下载失败回退。
+- 缓存范围判定统一为「仅 https 开头的地址进入缓存流程」,天然兼容真机与开发者工具的本地路径(wxfile://、http://tmp、http://usr)。
+- 文件系统操作全部用异步 API(access/mkdir/unlink/readdir/stat),仅内存 Map 同步;清扫异步执行且去重,避免主线程卡顿。
+- downloadFile 并发 ≤4 且同 key 任务合并,防止列表页瞬时大量图片同时下载。
+- 日志复用 `utils/logger.ts`:debug 记命中/miss/落盘与清扫摘要,warn 仅一次用于域名未配置熔断;不输出签名 URL 原文,只输出稳定 key 或哈希文件名,保护实时日志配额。
+- 零新增依赖:双哈希自实现;TS strict(noUnusedLocals/noImplicitAny)下编写;注释保持项目现有中文风格。
+- 需在微信公众平台「开发设置-服务器域名」配置 **downloadFile 合法域名**(图片 CDN 域名);开发者工具本地验证可勾选「不校验合法域名」。
+
+## 验证与上线
+
+1. 首次冷启动进入衣橱/AI 穿搭:先占位后出图,日志呈现 miss → download → saved。
+2. 返回重进或杀进程重进:日志 hit(内存/磁盘),Network 面板无同图请求。
+3. 列表刷新后签名参数变化:仍命中本地缓存(稳定 key 生效)。
+4. 头像、拼图封面、详情大图、分享封面视觉零变化(圆角、aspectFill/Fit、lazy-load)。
+5. 模拟域名未配置:自动熔断回退网络图,页面功能正常。
+6. `npx tsc --noEmit`(或开发者工具编译)无类型错误,read_lints 无新增告警。
+
+## Agent Extensions
+
+### SubAgent
+
+- **code-explorer**
+- Purpose: 在全部替换完成后,对 miniprogram 目录做一轮全仓交叉复核,确认所有网络图片使用点均已接入 cached-image、无遗漏或误改(含 wardrobe 经 clothes-card 的间接使用、upload 本地预览与 damo 包内素材未受影响)。
+- Expected outcome: 输出完整的图片使用点核对清单与结论,明确 22 处替换全覆盖、无残留的网络 image 标签,作为验收依据。
\ No newline at end of file
diff --git a/miniprogram/components/cached-image/cached-image.json b/miniprogram/components/cached-image/cached-image.json
new file mode 100644
index 0000000..97dfcde
--- /dev/null
+++ b/miniprogram/components/cached-image/cached-image.json
@@ -0,0 +1,4 @@
+{
+ "component": true,
+ "styleIsolation": "isolated"
+}
diff --git a/miniprogram/components/cached-image/cached-image.ts b/miniprogram/components/cached-image/cached-image.ts
new file mode 100644
index 0000000..b597953
--- /dev/null
+++ b/miniprogram/components/cached-image/cached-image.ts
@@ -0,0 +1,77 @@
+// cached-image —— 通用图片组件:网络图经 utils/image-cache 落盘后展示(命中时零网络请求),
+// 本地路径(wxfile:// / http://tmp / 包内路径等)原样直出;
+// 未命中缓存时先骨架占位、下载完成后显示本地文件,失败自动回退网络地址,功能不受影响。
+
+import { getCachedLocalPath, resolveImage, dropImageCache } from '../../utils/image-cache'
+
+Component({
+ properties: {
+ src: { type: String, value: '' },
+ // 与 image 组件对齐:未显式传入时保持 scaleToFill 默认行为
+ mode: { type: String, value: 'scaleToFill' },
+ lazyLoad: { type: Boolean, value: false },
+ },
+ data: {
+ // 实际渲染地址:本地文件路径或回退的网络地址;空串 = 骨架占位中
+ shown: '',
+ },
+ observers: {
+ src() {
+ this.refresh()
+ },
+ },
+ lifetimes: {
+ attached() {
+ ;(this as any)._alive = true
+ this.refresh()
+ },
+ detached() {
+ ;(this as any)._alive = false
+ },
+ },
+ methods: {
+ refresh() {
+ const src = this.data.src
+ const seq = (((this as any)._seq as number) || 0) + 1
+ ;(this as any)._seq = seq
+
+ if (!src) {
+ this.setData({ shown: '' })
+ return
+ }
+ if (src.indexOf('https://') !== 0) {
+ // 本地/包内路径直接渲染,不进入缓存流程
+ this.setData({ shown: src })
+ return
+ }
+ const hit = getCachedLocalPath(src)
+ if (hit) {
+ this.setData({ shown: hit })
+ return
+ }
+ // 未命中:先占位,下载落盘后显示本地文件(期间不渲染网络地址,避免额外请求抵消缓存收益)
+ if (this.data.shown) this.setData({ shown: '' })
+ resolveImage(src).then(
+ (localPath) => {
+ if ((this as any)._alive === false || (this as any)._seq !== seq) return
+ this.setData({ shown: localPath })
+ },
+ () => {
+ if ((this as any)._alive === false || (this as any)._seq !== seq) return
+ // 缓存不可用时回退网络地址,保证功能可用
+ this.setData({ shown: src })
+ }
+ )
+ },
+ onImgError() {
+ const shown = this.data.shown
+ const src = this.data.src
+ // 网络地址自身失败或非网络图:不再处理,避免循环回退
+ if (!shown || !src || shown.indexOf('https://') === 0) return
+ ;(this as any)._seq = (((this as any)._seq as number) || 0) + 1
+ // 本地文件失效(被系统清理/损坏):清理缓存并回退网络地址一次
+ dropImageCache(src)
+ this.setData({ shown: src })
+ },
+ },
+})
diff --git a/miniprogram/components/cached-image/cached-image.wxml b/miniprogram/components/cached-image/cached-image.wxml
new file mode 100644
index 0000000..c3116fc
--- /dev/null
+++ b/miniprogram/components/cached-image/cached-image.wxml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/miniprogram/components/cached-image/cached-image.wxss b/miniprogram/components/cached-image/cached-image.wxss
new file mode 100644
index 0000000..e7c2b23
--- /dev/null
+++ b/miniprogram/components/cached-image/cached-image.wxss
@@ -0,0 +1,26 @@
+/* 宿主节点:对齐 image 组件默认 inline-block 布局,尺寸与圆角由调用方 class 控制 */
+:host {
+ display: inline-block;
+ overflow: hidden;
+}
+
+/* 内层图片:填满宿主并继承圆角,保证既有版式零回归 */
+.ci-img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+}
+
+/* 骨架占位:与全局 .sk 同款视觉,组件内独立定义避免依赖外部样式加载顺序 */
+.ci-ph {
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, #F2F2F0 25%, #E9E9E6 37%, #F2F2F0 63%);
+ background-size: 400% 100%;
+ animation: ciSkLoad 1.5s ease infinite;
+}
+@keyframes ciSkLoad {
+ 0% { background-position: 100% 50%; }
+ 100% { background-position: 0 50%; }
+}
diff --git a/miniprogram/components/clothes-card/clothes-card.json b/miniprogram/components/clothes-card/clothes-card.json
index 79f2730..cc367c7 100644
--- a/miniprogram/components/clothes-card/clothes-card.json
+++ b/miniprogram/components/clothes-card/clothes-card.json
@@ -1,4 +1,7 @@
{
"component": true,
- "styleIsolation": "apply-shared"
+ "styleIsolation": "apply-shared",
+ "usingComponents": {
+ "cached-image": "/components/cached-image/cached-image"
+ }
}
diff --git a/miniprogram/components/clothes-card/clothes-card.wxml b/miniprogram/components/clothes-card/clothes-card.wxml
index d74c58e..b7efe57 100644
--- a/miniprogram/components/clothes-card/clothes-card.wxml
+++ b/miniprogram/components/clothes-card/clothes-card.wxml
@@ -1,12 +1,12 @@
-
{{statusText}}
diff --git a/miniprogram/pages/ai-pending/ai-pending.json b/miniprogram/pages/ai-pending/ai-pending.json
index 959f217..d62c13d 100644
--- a/miniprogram/pages/ai-pending/ai-pending.json
+++ b/miniprogram/pages/ai-pending/ai-pending.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
diff --git a/miniprogram/pages/ai-pending/ai-pending.wxml b/miniprogram/pages/ai-pending/ai-pending.wxml
index 36873b1..bddae02 100644
--- a/miniprogram/pages/ai-pending/ai-pending.wxml
+++ b/miniprogram/pages/ai-pending/ai-pending.wxml
@@ -63,7 +63,7 @@
-
+
+{{item._moreCount}}
diff --git a/miniprogram/pages/ai-recommend/ai-recommend.json b/miniprogram/pages/ai-recommend/ai-recommend.json
index 959f217..d62c13d 100644
--- a/miniprogram/pages/ai-recommend/ai-recommend.json
+++ b/miniprogram/pages/ai-recommend/ai-recommend.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
diff --git a/miniprogram/pages/ai-recommend/ai-recommend.wxml b/miniprogram/pages/ai-recommend/ai-recommend.wxml
index f56cdda..5cab66e 100644
--- a/miniprogram/pages/ai-recommend/ai-recommend.wxml
+++ b/miniprogram/pages/ai-recommend/ai-recommend.wxml
@@ -44,7 +44,7 @@ module.exports = { cls: cls }
-
+
+{{clothes.length - 4}}
@@ -77,7 +77,7 @@ module.exports = { cls: cls }
-
+
{{item.name || '未命名'}}
diff --git a/miniprogram/pages/closet-share-create/closet-share-create.json b/miniprogram/pages/closet-share-create/closet-share-create.json
index ccfb194..6b9d50a 100644
--- a/miniprogram/pages/closet-share-create/closet-share-create.json
+++ b/miniprogram/pages/closet-share-create/closet-share-create.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"clothes-card": "/components/clothes-card/clothes-card",
"v2-overlay": "/components/v2-overlay/index"
}
diff --git a/miniprogram/pages/closet-share-create/closet-share-create.wxml b/miniprogram/pages/closet-share-create/closet-share-create.wxml
index 30b2f8c..f485bb6 100644
--- a/miniprogram/pages/closet-share-create/closet-share-create.wxml
+++ b/miniprogram/pages/closet-share-create/closet-share-create.wxml
@@ -95,7 +95,7 @@
-
+
{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物
diff --git a/miniprogram/pages/closet-share-detail/closet-share-detail.json b/miniprogram/pages/closet-share-detail/closet-share-detail.json
index 959f217..d62c13d 100644
--- a/miniprogram/pages/closet-share-detail/closet-share-detail.json
+++ b/miniprogram/pages/closet-share-detail/closet-share-detail.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
diff --git a/miniprogram/pages/closet-share-detail/closet-share-detail.wxml b/miniprogram/pages/closet-share-detail/closet-share-detail.wxml
index 17549c9..acdef99 100644
--- a/miniprogram/pages/closet-share-detail/closet-share-detail.wxml
+++ b/miniprogram/pages/closet-share-detail/closet-share-detail.wxml
@@ -28,7 +28,7 @@
-
+
@@ -41,7 +41,7 @@
-
+
{{item.name || '未命名'}}
diff --git a/miniprogram/pages/closet-share/closet-share.json b/miniprogram/pages/closet-share/closet-share.json
index aaf395a..532814c 100644
--- a/miniprogram/pages/closet-share/closet-share.json
+++ b/miniprogram/pages/closet-share/closet-share.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"empty-state": "/components/empty-state/empty-state"
}
}
diff --git a/miniprogram/pages/closet-share/closet-share.wxml b/miniprogram/pages/closet-share/closet-share.wxml
index fea819b..88955b6 100644
--- a/miniprogram/pages/closet-share/closet-share.wxml
+++ b/miniprogram/pages/closet-share/closet-share.wxml
@@ -59,13 +59,13 @@
-
+
-
+
diff --git a/miniprogram/pages/clothes-detail/clothes-detail.json b/miniprogram/pages/clothes-detail/clothes-detail.json
index 959f217..d62c13d 100644
--- a/miniprogram/pages/clothes-detail/clothes-detail.json
+++ b/miniprogram/pages/clothes-detail/clothes-detail.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
diff --git a/miniprogram/pages/clothes-detail/clothes-detail.wxml b/miniprogram/pages/clothes-detail/clothes-detail.wxml
index ace4260..da7d287 100644
--- a/miniprogram/pages/clothes-detail/clothes-detail.wxml
+++ b/miniprogram/pages/clothes-detail/clothes-detail.wxml
@@ -110,7 +110,7 @@ module.exports = { hex: hex, tags: tags }
-
-
+
+{{clothes.length - 4}}
@@ -100,7 +100,7 @@ module.exports = { cls: cls }
bindtap="onItemTap"
>
-
+
{{item.name || '未命名'}}
diff --git a/miniprogram/pages/outfit-edit/outfit-edit.json b/miniprogram/pages/outfit-edit/outfit-edit.json
index ccfb194..6b9d50a 100644
--- a/miniprogram/pages/outfit-edit/outfit-edit.json
+++ b/miniprogram/pages/outfit-edit/outfit-edit.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"clothes-card": "/components/clothes-card/clothes-card",
"v2-overlay": "/components/v2-overlay/index"
}
diff --git a/miniprogram/pages/outfit-edit/outfit-edit.wxml b/miniprogram/pages/outfit-edit/outfit-edit.wxml
index 9547467..2c760c3 100644
--- a/miniprogram/pages/outfit-edit/outfit-edit.wxml
+++ b/miniprogram/pages/outfit-edit/outfit-edit.wxml
@@ -32,7 +32,7 @@ module.exports = { cls: cls }
bindtap="openSelect"
>
-
+
+{{selectedItems.length - 4}}
diff --git a/miniprogram/pages/outfit-share-detail/outfit-share-detail.json b/miniprogram/pages/outfit-share-detail/outfit-share-detail.json
index 959f217..d62c13d 100644
--- a/miniprogram/pages/outfit-share-detail/outfit-share-detail.json
+++ b/miniprogram/pages/outfit-share-detail/outfit-share-detail.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
diff --git a/miniprogram/pages/outfit-share-detail/outfit-share-detail.wxml b/miniprogram/pages/outfit-share-detail/outfit-share-detail.wxml
index 51642af..1337fb0 100644
--- a/miniprogram/pages/outfit-share-detail/outfit-share-detail.wxml
+++ b/miniprogram/pages/outfit-share-detail/outfit-share-detail.wxml
@@ -38,7 +38,7 @@ module.exports = { cls: cls }
-
+
@@ -51,7 +51,7 @@ module.exports = { cls: cls }
-
+
+{{outfit.items.length - 4}}
@@ -84,7 +84,7 @@ module.exports = { cls: cls }
bindtap="onItemTap"
>
-
+
diff --git a/miniprogram/pages/outfit/outfit.json b/miniprogram/pages/outfit/outfit.json
index aaf395a..532814c 100644
--- a/miniprogram/pages/outfit/outfit.json
+++ b/miniprogram/pages/outfit/outfit.json
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image",
"empty-state": "/components/empty-state/empty-state"
}
}
diff --git a/miniprogram/pages/outfit/outfit.wxml b/miniprogram/pages/outfit/outfit.wxml
index 25b1a32..7c482e8 100644
--- a/miniprogram/pages/outfit/outfit.wxml
+++ b/miniprogram/pages/outfit/outfit.wxml
@@ -68,7 +68,7 @@
-
+
@@ -98,7 +98,7 @@
>
-
+
@@ -109,7 +109,7 @@
-
+
+{{item._moreCount}}
@@ -172,7 +172,7 @@
-
+
{{item.name}}
{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}
diff --git a/miniprogram/pages/profile/profile.json b/miniprogram/pages/profile/profile.json
index 8632c9b..9d9e759 100644
--- a/miniprogram/pages/profile/profile.json
+++ b/miniprogram/pages/profile/profile.json
@@ -1,5 +1,6 @@
{
"usingComponents": {
- "navigation-bar": "/components/navigation-bar/navigation-bar"
+ "navigation-bar": "/components/navigation-bar/navigation-bar",
+ "cached-image": "/components/cached-image/cached-image"
}
}
diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml
index e351378..1ac7bc8 100644
--- a/miniprogram/pages/profile/profile.wxml
+++ b/miniprogram/pages/profile/profile.wxml
@@ -27,7 +27,7 @@
-
+
@@ -150,7 +150,7 @@
-
+
diff --git a/miniprogram/utils/image-cache.ts b/miniprogram/utils/image-cache.ts
new file mode 100644
index 0000000..04d5d4b
--- /dev/null
+++ b/miniprogram/utils/image-cache.ts
@@ -0,0 +1,433 @@
+// 图片本地缓存:网络图片首次展示时下载落盘,后续直接复用本地文件,减少 CDN 重复流量。
+// 背景:接口返回的图片是带签名/时间戳的临时 URL,每次请求都会变化;微信 image 自带的
+// 缓存按「完整 URL(含 query)」记录,签名一变即失效,同一张图会被反复下载。
+// 本模块按「剥离签名参数后的稳定地址」作为缓存 key 落盘,同一张图只请求 CDN 一次。
+//
+// 命中链路:内存 Map → 磁盘文件 → downloadFile 落盘(并发 ≤4、同 key 任务去重)。
+// 失败处理:单图失败由组件回退网络地址;检测到 downloadFile 域名未配置时整会话熔断,
+// 避免继续发起必然失败的请求。
+// 空间治理:TTL + 容量上限,空闲时异步清扫,不阻塞渲染。
+
+import { logger } from './logger'
+
+// 日志模块名
+const LOG = 'image-cache'
+
+// 缓存目录(本地用户文件,随小程序代码包生命周期清理)
+const CACHE_DIR = `${wx.env.USER_DATA_PATH}/imgcache`
+// 过期时间:超过 7 天未修改的缓存文件优先淘汰
+const TTL_MS = 7 * 24 * 60 * 60 * 1000
+// 目录容量上限:本地文件总量上限约 200MB,自设 80MB 留足业务余量
+const MAX_BYTES = 80 * 1024 * 1024
+// 文件数量上限
+const MAX_FILES = 300
+// 内存映射上限,超出时整体重建(后续重新走磁盘命中,代价可控)
+const MAX_MEM_ENTRIES = 500
+// downloadFile 并发上限(微信侧总上限 10,留余量给业务请求)
+const MAX_CONCURRENCY = 4
+// 清扫延迟:首次解析后延迟执行,避免与首屏图片加载抢资源
+const PRUNE_DELAY_MS = 5000
+// 运行期每成功落盘 50 张再触发一次清扫
+const PRUNE_EVERY_SAVES = 50
+
+// 签名类参数名(小写,集中维护):签名 URL 每次返回都不同,必须从缓存 key 中剥离,否则永远无法命中
+const SIGN_PARAMS = new Set([
+ // 通用签名 / 时间戳
+ 'sign',
+ 'signature',
+ 'sig',
+ 't',
+ 'ts',
+ 'timestamp',
+ 'time',
+ 'expires',
+ 'expire',
+ 'auth',
+ 'token',
+ 'nonce',
+ // 腾讯云 COS
+ 'authorize',
+ 'x-cos-security-token',
+ 'q-sign-algorithm',
+ 'q-sign-time',
+ 'q-key-time',
+ 'q-signature',
+ 'q-header-list',
+ 'q-url-param-list',
+ // 阿里云 OSS
+ 'ossaccesskeyid',
+ 'security-token',
+ 'auth_key',
+ // AWS S3 / CloudFront
+ 'x-signature',
+ 'x-amz-algorithm',
+ 'x-amz-credential',
+ 'x-amz-date',
+ 'x-amz-expires',
+ 'x-amz-signature',
+ 'x-amz-security-token',
+ 'x-amz-signedheaders',
+ 'key-pair-id',
+ 'policy',
+])
+
+// ===== 运行状态 =====
+
+// 稳定 key → 本地文件路径(内存快速通道)
+const memCache = new Map()
+// 稳定 key → 进行中的下载任务(并发复用,避免同一张图重复下载)
+const inflight = new Map>()
+// 下载域名未配置等不可恢复失败的会话级熔断
+let disabled = false
+// 缓存目录就绪状态与初始化 Promise
+let dirReady = false
+let dirPromise: Promise | null = null
+// downloadFile 并发闸门
+let running = 0
+const waiters: Array<() => void> = []
+// 清扫节流状态
+let firstPruneDone = false
+let pruneScheduled = false
+let savesSincePrune = 0
+
+function fs(): WechatMiniprogram.FileSystemManager {
+ return wx.getFileSystemManager()
+}
+
+// 仅 https 网络图进入缓存流程;本地路径(wxfile:// / http://tmp / http://usr / 包内路径)原样返回
+function isRemote(src: string): boolean {
+ return src.indexOf('https://') === 0
+}
+
+// ===== 稳定 key 与文件指纹 =====
+
+// 剥离签名/时间戳参数,其余 query 排序后与 host+path 拼接;同一张图签名变化也命中同一缓存
+function stableKey(src: string): string {
+ const idx = src.indexOf('?')
+ if (idx < 0) return src
+ const base = src.slice(0, idx)
+ const kept: string[] = []
+ for (const part of src.slice(idx + 1).split('&')) {
+ if (!part) continue
+ const eq = part.indexOf('=')
+ const rawKey = (eq >= 0 ? part.slice(0, eq) : part).toLowerCase()
+ let name = rawKey
+ try {
+ name = decodeURIComponent(rawKey)
+ } catch (e) {
+ // 参数名解码失败按原样处理
+ }
+ if (SIGN_PARAMS.has(name)) continue
+ kept.push(part)
+ }
+ if (!kept.length) return base
+ kept.sort()
+ return `${base}?${kept.join('&')}`
+}
+
+function hex32(n: number): string {
+ return ('00000000' + (n >>> 0).toString(16)).slice(-8)
+}
+
+// FNV-1a 32 位:不引入依赖的快速哈希
+function fnv1a(str: string): string {
+ let h = 0x811c9dc5
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i)
+ h = Math.imul(h, 0x01000193) >>> 0
+ }
+ return hex32(h)
+}
+
+// djb2 32 位:与 FNV-1a 拼接为 16 位十六进制文件名,降低碰撞概率
+function djb2(str: string): string {
+ let h = 5381
+ for (let i = 0; i < str.length; i++) {
+ h = (Math.imul(h, 33) + str.charCodeAt(i)) >>> 0
+ }
+ return hex32(h)
+}
+
+function hashKey(key: string): string {
+ return fnv1a(key) + djb2(key)
+}
+
+// 扩展名取自 URL 路径,白名单外统一按 .jpg 处理(仅影响本地文件名,不影响渲染)
+function extOf(src: string): string {
+ const path = src.split('?')[0].split('#')[0]
+ const m = /\.(jpg|jpeg|png|webp|gif)$/i.exec(path)
+ if (!m) return '.jpg'
+ const ext = m[1].toLowerCase()
+ return ext === 'jpeg' ? '.jpg' : `.${ext}`
+}
+
+function localPathOf(key: string, src: string): string {
+ return `${CACHE_DIR}/${hashKey(key)}${extOf(src)}`
+}
+
+// ===== 文件系统操作(全部异步包装,失败静默由调用方决定兜底) =====
+
+function ensureDir(): Promise {
+ if (dirReady) return Promise.resolve()
+ if (!dirPromise) {
+ dirPromise = new Promise((resolve) => {
+ fs().mkdir({
+ dirPath: CACHE_DIR,
+ recursive: true,
+ success: () => resolve(),
+ // 目录已存在等失败一律放行,落盘失败会在下载阶段回退
+ fail: () => resolve(),
+ })
+ }).then(() => {
+ dirReady = true
+ })
+ }
+ return dirPromise
+}
+
+function accessFile(path: string): Promise {
+ return new Promise((resolve) => {
+ fs().access({
+ path,
+ success: () => resolve(true),
+ fail: () => resolve(false),
+ })
+ })
+}
+
+function unlinkQuiet(path: string): Promise {
+ return new Promise((resolve) => {
+ fs().unlink({
+ filePath: path,
+ success: () => resolve(),
+ fail: () => resolve(),
+ })
+ })
+}
+
+function readdir(): Promise {
+ return new Promise((resolve, reject) => {
+ fs().readdir({
+ dirPath: CACHE_DIR,
+ success: (res) => resolve(res.files || []),
+ fail: (err) => reject(new Error((err && err.errMsg) || 'readdir fail')),
+ })
+ })
+}
+
+function statFile(path: string): Promise<{ size: number; mtime: number } | null> {
+ return new Promise((resolve) => {
+ fs().stat({
+ path,
+ success: (res) => {
+ const s = res.stats
+ if (typeof s.isFile === 'function' && !s.isFile()) {
+ resolve(null)
+ return
+ }
+ // 官方单位是秒,但个别端返回毫秒,统一归一化为毫秒
+ const raw = Number(s.lastModifiedTime) || 0
+ const mtime = raw > 1e11 ? raw : raw * 1000
+ resolve({ size: Number(s.size) || 0, mtime })
+ },
+ fail: () => resolve(null),
+ })
+ })
+}
+
+// ===== 下载:并发闸门 + 直写目标文件 =====
+
+function acquireSlot(): Promise {
+ if (running < MAX_CONCURRENCY) {
+ running++
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => {
+ waiters.push(() => {
+ running++
+ resolve()
+ })
+ })
+}
+
+function releaseSlot(): void {
+ running--
+ const next = waiters.shift()
+ if (next) next()
+}
+
+function download(url: string, filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ wx.downloadFile({
+ url,
+ filePath,
+ success: (res) => {
+ if (res.statusCode === 200) resolve()
+ else reject(new Error(`http ${res.statusCode}`))
+ },
+ fail: (err) => reject(new Error((err && err.errMsg) || 'download fail')),
+ })
+ })
+}
+
+// ===== 空闲清扫:TTL 过期 + 超容量/超数量按最旧优先删除 =====
+
+async function pruneCache(): Promise {
+ try {
+ const names = await readdir()
+ const now = Date.now()
+ const alive: Array<{ path: string; size: number; mtime: number }> = []
+ let expired = 0
+
+ for (const name of names) {
+ const path = `${CACHE_DIR}/${name}`
+ const st = await statFile(path)
+ if (!st) continue
+ if (now - st.mtime > TTL_MS) {
+ await unlinkQuiet(path)
+ expired++
+ continue
+ }
+ alive.push({ path, size: st.size, mtime: st.mtime })
+ }
+
+ alive.sort((a, b) => a.mtime - b.mtime)
+ let bytes = 0
+ for (const it of alive) bytes += it.size
+ let removed = 0
+ let i = 0
+ while (i < alive.length && (bytes > MAX_BYTES || alive.length - i > MAX_FILES)) {
+ await unlinkQuiet(alive[i].path)
+ bytes -= alive[i].size
+ removed++
+ i++
+ }
+
+ logger.debug(LOG, 'prune done, total=', names.length, 'expired=', expired, 'overflow=', removed)
+ } catch (e) {
+ // 目录尚未创建等情况直接跳过,不重试不报错
+ logger.debug(LOG, 'prune skipped')
+ }
+}
+
+function notePrune(): void {
+ // 首次解析后延迟清扫一次
+ if (!firstPruneDone && !pruneScheduled) {
+ pruneScheduled = true
+ setTimeout(runPrune, PRUNE_DELAY_MS)
+ }
+}
+
+function noteSaved(): void {
+ savesSincePrune++
+ // 运行期累计落盘后再触发一次
+ if (firstPruneDone && !pruneScheduled && savesSincePrune >= PRUNE_EVERY_SAVES) {
+ pruneScheduled = true
+ setTimeout(runPrune, PRUNE_DELAY_MS)
+ }
+}
+
+function runPrune(): void {
+ pruneCache().then(() => {
+ firstPruneDone = true
+ savesSincePrune = 0
+ pruneScheduled = false
+ })
+}
+
+// ===== 对外接口 =====
+
+function memSet(key: string, localPath: string): void {
+ if (memCache.size >= MAX_MEM_ENTRIES) memCache.clear()
+ memCache.set(key, localPath)
+}
+
+/** 内存命中快速通道:命中返回本地路径,否则 null(供组件首帧零延迟直出) */
+export function getCachedLocalPath(src: string): string | null {
+ if (!src || !isRemote(src) || disabled) return null
+ return memCache.get(stableKey(src)) || null
+}
+
+async function loadOrDownload(key: string, src: string): Promise {
+ const localPath = localPathOf(key, src)
+ const id = hashKey(key)
+
+ if (await accessFile(localPath)) {
+ logger.debug(LOG, 'hit disk', id)
+ return localPath
+ }
+
+ await ensureDir()
+ await acquireSlot()
+ try {
+ await download(src, localPath)
+ noteSaved()
+ logger.debug(LOG, 'saved', id)
+ return localPath
+ } catch (e) {
+ // 清理可能残留的半截文件,交由组件回退网络地址
+ await unlinkQuiet(localPath)
+ const msg = e instanceof Error ? e.message : String(e)
+ if (!disabled && /not in domain list/i.test(msg)) {
+ disabled = true
+ logger.warn(LOG, 'downloadFile 域名未配置,停用图片缓存并回退网络图(请在小程序后台配置 downloadFile 合法域名)')
+ } else {
+ logger.debug(LOG, 'download fail, fallback network, id=', id, 'reason=', msg)
+ }
+ throw e
+ } finally {
+ releaseSlot()
+ }
+}
+
+/** 解析图片:磁盘命中直接返回本地路径;未命中入队下载落盘后返回;失败 reject(调用方回退网络地址) */
+export function resolveImage(src: string): Promise {
+ if (!src || !isRemote(src)) return Promise.resolve(src)
+ if (disabled) return Promise.reject(new Error('image cache disabled'))
+
+ const key = stableKey(src)
+ const hit = memCache.get(key)
+ if (hit) {
+ logger.debug(LOG, 'hit mem', hashKey(key))
+ return Promise.resolve(hit)
+ }
+
+ const pending = inflight.get(key)
+ if (pending) return pending
+
+ notePrune()
+
+ const task = loadOrDownload(key, src).then(
+ (localPath) => {
+ memSet(key, localPath)
+ inflight.delete(key)
+ return localPath
+ },
+ (err) => {
+ inflight.delete(key)
+ throw err
+ }
+ )
+ inflight.set(key, task)
+ return task
+}
+
+/** 删除某个网络图对应的本地缓存(组件自愈用:本地文件损坏时清理后回退网络地址) */
+export function dropImageCache(src: string): void {
+ if (!src || !isRemote(src)) return
+ const key = stableKey(src)
+ const localPath = memCache.get(key) || localPathOf(key, src)
+ memCache.delete(key)
+ unlinkQuiet(localPath).then(() => undefined)
+}
+
+/** 清空全部图片缓存(预留维护入口) */
+export function clearImageCache(): Promise {
+ memCache.clear()
+ return readdir()
+ .then((names) => Promise.all(names.map((name) => unlinkQuiet(`${CACHE_DIR}/${name}`))))
+ .then(() => {
+ logger.debug(LOG, 'clear done')
+ })
+ .catch(() => undefined)
+}