Compare commits

...

91 Commits

Author SHA1 Message Date
浅念 53c25697a6 feat(lookup): 用户前缀查找放宽至单字符——中文姓氏单字搜索(全链路)
契约 minLength 2→1 + 服务端 bounds 1..64 + admin 两处/App 一处前端门槛
同步放宽;防抖测试基线更新('s' 与合并后 'sta' 共两条)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 15:37:41 +08:00
浅念 b0a3515bf2 feat(api): 用户前缀查找并入 displayName——成员添加可按姓名搜人(方案 A)
线上根因:V1.1 账号=手机号,运营按姓名搜索恒空(accountPrefix 仅匹配
account)。扩展为 account OR displayName 前缀(括号分组保资格过滤不被
短路),大小写敏感/激活过滤/account 升序语义不变;与管理列表 query 模式
对齐。契约描述同步。附 members-categories e2e 冷启动超时对齐 120s。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 15:20:09 +08:00
浅念 8c47a88c79 fix(viewer): 环回瞬时回位强制重排——同帧恢复过渡导致可见反向长滚
真浏览器样式计算时过渡仍在,回跳变成动画;强制 offsetWidth 重排 +
双 rAF 确保无过渡状态先绘制一帧(测试假时钟下掩盖的根因)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 15:00:50 +08:00
浅念 38b913b429 style(viewer): 广告轮播无缝环回——首尾克隆帧 + 到位瞬时回位
末帧→首帧不再反向长滚:经尾克隆单帧宽右→左自然滑入,过渡结束后
关过渡瞬时跳回等视觉真实帧;首帧→末帧(右滑/点末点)对称走头克隆。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:45:40 +08:00
浅念 048732dfef style(viewer): 广告轮播改从右往左滑动切换(translateX 轨道,替代淡入淡出)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:26:41 +08:00
浅念 f88c1c170a feat(app): 选图拦截 v2——硬封锁+仅本机判定,治点选卡顿与连点强穿
问题(用户反馈):v1 服务器单项预检(2s 帽)弱网下把每次点选的勾选卡
0.5~2s(selectPredicate await 期间 selectAsset 不执行);配合「二次点击
强制选择」活口,重复照片被连点强制选中,拦截形同虚设。

- 判定仅查本机 TaskStore(毫秒级)——服务器查重退至入队统一弹窗+409 兜底
- 硬封锁:本机命中每次点击均拒选(_forced 放行集移除),文案不再引导再点
- 判定缓存升级 in-flight Future 共享(putIfAbsent):同一照片连点只判一次
  (清偿 9-29 评审登记 Minor 遗留)
- 构造去 client/serverCheckTimeout;iOS title 空放行(跨设备判定在入队弹窗)
- 已接受限制(用户拍板 A):后台已删照片重传同文件被本机历史永拦

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:24:53 +08:00
浅念 d0755aa466 style(viewer): 广告轮播去固定比例——活跃帧撑自然高度(运营上传统一高度图)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:22:46 +08:00
浅念 07f81ff59b style(viewer): 广告轮播切换柔化——全帧堆叠淡入淡出
堆叠帧常驻 DOM 预加载(首轮切换不空白),活跃帧独占交互/聚焦;
恢复 10:3 高度锚点(各帧自然高度不一致会让切换时容器跳动)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:18:39 +08:00
浅念 29204bc08a docs(plan): 选图拦截 v2 设计——硬封锁+仅本机判定
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:14:06 +08:00
浅念 a90cb9953e style(viewer): 广告徽标/关闭钮缩小淡化 + 信息区去底部分隔线(用户调整)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:09:29 +08:00
浅念 3728eb93d9 feat(app): 服务端历史行首缩略图 + 上传中心空态引导至服务端历史
- UploadSessionPhoto 解析可选 preview;历史行首有 thumb 渲染 PhotoThumbnail
 (SizedBox 定寸——Row 内裸 AspectRatio 无界约束),缺省回退中性占位
- 上传中心整体空态加「仅本机存储」说明 + 「查看服务端历史」tonal 按钮
 (与右上 History 同路由);「需关注」空态保持专属文案不加引导
- l10n 增 queueEmptyDeviceHint / queueViewServerHistory

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 12:04:06 +08:00
浅念 f1912998a8 feat(api): listUploads 会话条目内嵌照片预览(现签 thumb/browse)
- 契约 UploadSessionPhoto 增可选 preview(PhotoPreview;旧客户端零 break)
- list() 批量查 photo_variants,uploader 视角签发:thumb+browse 齐备且
  未隐藏/删除才签(「隐藏是运营动作」口径从严;无变体→字段缺省)
- presign 本地 SigV4 无网络开销,TTL 同全局 900s
- 顺手修 batch-ops ⑦:交接后上传 bytes 撞查重改 71007(登记旧账清偿)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 12:04:06 +08:00
浅念 3833bc3bc6 docs(plan): 服务端历史缩略图 + 上传中心空态引导设计方案
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 11:38:23 +08:00
浅念 744d45cc61 fix(ads): 轮播图裂图修复——广告现签 TTL 24h + 失败节流重拉
根因:广告图现签 900s(照片域 TTL),观众长留直播页超时后重拉 403。
广告为匿名公开内容,短轮换无安全收益——专属 AD_SIGNED_URL_TTL_SECONDS=24h;
另加兜底:img error → 页面层 60s 冷却重拉广告列表换新签名 URL。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 11:27:06 +08:00
浅念 8741bded7f fix(app): 海报下载反馈改弹层内呈现——SnackBar 被模态弹层遮挡不可见
根因:保存成功/失败的 SnackBar 弹在弹层底下的页面 Scaffold 上,被模态
bottom sheet + 遮罩整层盖住,用户永远看不到(保存本身大概率成功)。

- 成功:下载按钮翻成功态(✓+已保存到相册,kColorSuccess)2.5s 自动复原
- 失败:按钮下方弹层内红字留置可重试(对齐隐藏照片弹层面板内提示模式)
- 删除被遮挡的 SnackBar 路径;复原 Timer 于 dispose 取消

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 11:25:49 +08:00
浅念 a02d7a5e98 feat(app): 海报弹层内中/English 切换——二维码海报支持英文版
- 弹层顶部 SegmentTabs(light) 两档,默认跟 App 语言前缀;只影响海报内容
  与二维码 ?lang=,不动 App 语言设置
- 提示文案经 lookupAppLocalizations 按海报语言取(arb 中英双全,零硬编码)
- 标题/场地/地址复用相册多语言回退链按海报语言取
- QrImageView 补 semanticsLabel(读屏原本无名,兼作测试断言口)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 11:04:13 +08:00
浅念 c68e7115e4 docs(plan): 海报语言切换设计方案(弹层内中/English 切换)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 10:57:17 +08:00
浅念 0673f394d5 fix(app): picker dup gate — 2s server-check timeout, single snackbar, fileSize-throw allow
评审跟进三项(拦截面加固,判定口径不变):

- 服务器半场时间帽:checkUploadDuplicates 竞速 serverCheckTimeout
  (构造参数,默认 2s)——弱网下点选不再静默挂到 dio 连接超时
  (10s);超时=无命中,同各半场失败降级放行(底层请求不取消,
  dio 自身超时照常兜底)。测试 ⑥:永不回包适配器 + 注入 50ms 帽
  → 放行且请求确已发出;
- SnackBar 不堆积:呈现前 clearSnackBars——快速点多个重复资产
  只留当前一条(原会排队 N 条 4s 提示)。测试 ① 扩展:第二条
  隐藏后无排队等待的提示条;
- fileSize 失败路径钉住(测试 ⑦):资产事实 fileSize 抛错 → 放行
  且两半场零查询。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 10:35:04 +08:00
浅念 7f8cda1cd3 fix(viewer): 去除头部下方残留的旧广告位实例(位置迁移漏删)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 10:14:55 +08:00
浅念 7e5c550fb3 feat(app): block selecting already-uploaded photos at picker tap (double-tap to force)
方案 A(选图点选拦截):multiImagePickerProvider 经 wechat_assets_picker
10.1.3 官方钩子 AssetPickerConfig.selectPredicate 挂 PickerDuplicateGate——
点选「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张
强制选择(escape hatch)。

- MultiImagePicker typedef 增参 accountId/albumId(拦截判定范围=当前
  目标相册;相册详情/连拍页两处调用点传入);
- 判定口径同 checkDuplicatesUnified:本机 TaskStore(同账号+相册+
  completed,filename+bytes)∪ 服务器单项预检(bytes;本机命中短路
  服务器,点选延迟考量);任一半场异常按该半场无命中,fileSize 失败等
  整体异常直接放行——enqueue 弹窗 + init 409 兜底不变;
- per-asset 判定缓存(二次点击瞬回)+ 二次点击放行集;取消选择恒放行
  且不预热放行集;
- AssetEntity 事实适配(PickerAssetFact 测试接缝):fileSize 为
  photo_manager 3.12 的 Future<int> 平台通道 getter(entity.dart:778),
  惰性求值;title 为 String?(Android 恒有值;iOS 需 needTitle——null
  时本机半场跳过,服务器按 bytes 仍可判);
- l10n pickerDuplicateBlocked(zh/en);测试替身签名同步
  (album_detail/capture_page 页测 + integration_test app_boot),
  页测补 (accountId, albumId) 接线断言;gate 五态用例入 enqueue_test。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 10:10:49 +08:00
浅念 2261b8aebc style(viewer): 广告轮播移至宣传图与标题之间(after-banner 插槽)
用户定稿:广告位放标题上面——AlbumHeader 增具名插槽注入,
组件不感知广告域;无 banner 时仍居标题上方。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 10:03:15 +08:00
浅念 328140494f docs(plan): picker tap-time duplicate gate (option A) — incremental plan
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:51:41 +08:00
浅念 c64db3c53a docs: 广告位轮播验收记录与实施计划 Task 13 勾选(线上部署完成)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:35:37 +08:00
浅念 19d4fd143a feat(app): banner crop ratio 3:1 -> 16:9 (output <=1920x1080, preview height aligned with cover)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:32:28 +08:00
浅念 bfba76b5e6 fix(ads): 终审修复——staging 前缀+运行时上限核验/失效绑定编辑/白名单去 heic/杂项
- verifyStaging 硬化(services/api/src/ads/ads.service.ts):新增键前缀核验
  (仅 OBJECT_KEY_ROOT/ads/staging/ 布局,防照片域任意键被提升为公开广告)
  与运行时 2MB 上限(head.bytes > AD_UPLOAD_MAX_BYTES → 400 报文含限额值
  ——init 申明管不住实际 PUT 体,create/update 是唯一核验点);e2e ⑰ 以
  测试 storage 直接 presignPut 真实对象覆盖两分支
- 失效绑定编辑 400(admin AdsFormDialog):PATCH 仅在 albumIds 实际变化时
  携带(albumIdsChanged 序敏感深比较,纯函数落 adsLogic + vitest 用例);
  preloadOptions 解析失败的绑定 ID 加「已失效,可移除」后缀(zh/en 新增
  adsManage.form.staleAlbum)——软删绑定不再挡住其它字段保存
- 白名单去 heic(原图直出无变体兜底,Chromium/Firefox 不渲染=破图):
  contracts 三处 enum 收窄 [image/jpeg, image/png] 并重生成类型;dto 定义
  广告域独立 AD_IMAGE_CONTENT_TYPES(与照片域 UPLOAD_CONTENT_TYPES 分离),
  AD_IMAGE_EXTENSIONS 由其派生;init 对 .heic 文件名以广告域集合二次核验
  拒绝(共享 extOfFilename 仍认 heic,杜绝 init 放行→create 中段断裂);
  e2e heic 矩阵 400;admin accept/文案/类型同步收窄
- searchAlbums 补 catch(http 层已弹 toast,本地静默保持旧选项)
- ActivityPage 关闭记忆测试补位(activity.spec):dismissed 不拉取不渲染、
  close 写 sessionStorage 并清空轮播两用例(getPublicAds 入替身防真实 fetch)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 02:20:21 +08:00
浅念 041cfc77b7 docs: 广告位实施计划 Task 5-12 勾选
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 01:59:57 +08:00
浅念 b3e89f7efb fix(admin): 广告位排序并列簇修正——create 追加 max+1/move 纯逻辑抽取/相册下拉预载
评审跟进(C1/I1/M3/M5):

- C1 排序并列簇:新建此前不送 sortOrder(服务端缺省 0)→ 3+ 广告即全 0
  并列,首次上移/下移即产出错误顺序且经 outbox 立即传导 ov。修复双管:
  ① 新建载荷带 sortOrder=父页 max(现有)+1(空列表=0,尾部追加保证全序,
  并列簇从源头不再产生——无存量数据,评审选项 B);② 上移/下移目标值
  计算抽取为纯函数 planAdjacentMove(异值互换/并列错开抬邻居避免负值),
  ≥3 并列簇局限以 spec 文档化用例登记(create 追加后为 dead insurance),
  index.vue 改用纯函数并跳过值未变侧的 no-op PATCH
- I1 相册下拉:对话框每次打开均预载选项(新建态同样,对照 SlotBindDialog
  打开即 searchAlbums('') 先例),消除未输入时误显「无匹配相册」;编辑态
  绑定 ID 兜底回显逻辑合并入 preloadOptions
- M3 en 复数:scopeTargeted 用 vue-i18n 复数 "{n} album | {n} albums"
  (t(key, n))
- M5 预览对象 URL 兜底:onUnmounted(releasePreview) 防非常规卸载路径泄漏

已登记暂缓(非本次):M2 move 时序守卫细化、M4 相册搜索与 SlotBindDialog
的同构抽取、M6 编辑 diff-only 载荷。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 01:35:09 +08:00
浅念 239ef5648f feat(admin): 广告位管理页——上传/链接/绑定相册/开关/排序
Task 11(广告位轮播):admin 后台广告位治理页。

- utils/ads/adsLogic:scopeKind/normalizeAlbumIds/isValidAdLinkUrl 纯逻辑
  (与服务端 dto LINK_URL_PATTERN、albumIds 归一语义同源)+ vitest 6 例
- api/ads + types/api/ads.d.ts:list/create/update/delete/initUpload 五端点
  (对照 embedSlots.ts 逐字风格;列表全量不分页、imageUrl 现签)
- views/system/ads:列表(缩略图/备注名/链接截断/投放范围标签/启停开关
  失败重拉回滚/上移下移 sortOrder 互换/硬删确认)+ 新建编辑对话框
  (staging 直传 init→裸 fetch PUT、Content-Type 与申明一致;相册多选
  远程搜索复用 /v1/albums 分页+本地过滤,绑定 ID 超限/失效以裸 ID 兜底;
  编辑换图才带 image 三字段;albumIds 归一空→null 转全局)
- 路由/菜单:system 组 ads 页(ri:advertisement-line,workspace 分组);
  locales zh/en 补 menus.system.ads + adsManage 子树(键集一致入单测)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 01:21:33 +08:00
浅念 7f9789eb46 polish(viewer): 广告轮播 a11y 补全——aria-current/focus-visible/touch-action/键名对齐
评审跟进(Task 10 a11y 两项 + 三个顺手项):
- 圆点当前项程序化状态 :aria-current(ThumbnailStrip:103 同形),active 圆点
  类名对齐 sibling 约定 ad-dot--active → .active(viewer 无 BEM -- 修饰符)
- .ad-slide/.ad-close/.ad-dot 补 :focus-visible 2px 主色描边
  (AlbumHeader/ThumbnailStrip 同规;主色对暗色衬底/蒙层对比 ≈4:1)
- .ad-carousel 补 touch-action: pan-y(ThumbnailStrip pan-x 对偶:
  水平翻页自持、纵向滚动交还页面)
- sessionStorage 键名 viewer:ads-dismissed → viewer.adsDismissed(sibling
  点号命名约定;仅 ActivityPage 两处引用,测试无引用)
- ad-carousel.spec:类名断言随改 + aria-current 前后迁移断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 01:08:13 +08:00
浅念 f1c13bff7c feat(viewer): 相册页广告轮播——4s 自动/暂停/滑动/徽标/会话级关闭
Task 10(广告位轮播实施计划):
- ads/carousel.ts 纯逻辑:nextIndex 环形推进(末→首、单条恒 0)+ swipeStep
  滑动判定(≥40px 且水平分量严格大于垂直),carousel.spec 直测 7 例
- AdCarousel.vue:10:3 轮播,4s 自动推进(本地常量 ROTATE_MS);pointerenter/
  pointerdown 暂停、pointerleave 恢复;圆点跳页;touchstart/touchend 滑动翻页
  (cancel 合成 click);单条静态无圆点;左上角常驻「广告」徽标 + 右上角 ≥44px
  ✕ 关闭(emit close,组件无副作用);视觉一律 var(--viewer-*)(半透明层经
  color-mix 自 token 派生)
- client.ts:PublicAd/PublicAdListResponse 契约类型 + getPublicAds(同形
  apiRequest);__tests__/ad-carousel.spec.ts 组件级 6 例(圆点/关闭/window.open
  spy/假时钟自动轮播与暂停/滑动/单条静态)
- ActivityPage:load() 成功分支独立拉取广告(失败静默、会话级关闭记忆
  viewer:ads-dismissed 先行、沿用 sequence 防跨相册竞态);AlbumHeader 与
  PhotoFeed 之间挂 AdCarousel;i18n zh-CN/en 补 ads.* 词条

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 00:58:48 +08:00
浅念 80405cfcc1 chore(api): 公开广告端点补 enabled 投影不变量注释(Task 9 评审建议)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 00:50:46 +08:00
浅念 373f0fc533 feat(api): GET /v1/public/albums/{token}/ads 公开广告(全局+定向/现签)
ov 公开端点(ads-carousel Task 9,设计 §5.2):相册校验复用
findPublicAlbumOv(不存在/offline/紧急停用统一 404 ALBUM_NOT_PUBLIC);
可见集 = 全局投放(albumIds=null)∨ 绑定本相册,sortOrder 升序、并列
createdAt 升序;imageUrl 逐条现签 GET(TTL=SIGNED_URL_TTL_SECONDS)。
匿名无 guard、不加独立限速档(观众页单次加载一次,与公开相册读同量级,
量级假设 ≤ 百条:全表捞 + 应用层 JSON 过滤)。

public-ov.e2e 追加 ⑧:prismaOv 直种 albums_ov/ads_ov——过滤/排序/现签
(可取回真实字节)/404 同码同 path/空 items/匿名 200;9/9 通过,
ads.e2e 16/16 回归通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 00:44:23 +08:00
浅念 9ec8226577 fix(sync): reconciler 清扫环补 ad——错过的 ad.* 推送恢复自愈通道
SWEEP_ENTITY_TYPES 追加 'ad':生产对账循环阶段一逐类拉 cn reconcile
清单时纳入广告当前态(清单恒全量、确定性排序),重放走既有
content-batches 幂等管道。此前 ad 只有 cn 端点而无调用方——ad.* outbox
事件「投后即失」时 ads_ov 无自愈路径(digest 是相册维度计数,覆盖不了
无相册归属的广告;含 enabled=false 下线与硬删墓碑后的终态收敛)。

sweep() 对各类型均一处理(无 per-type 簿记),'ad' 与 'asset' 同构接入;
文件头/Stats 注释同步(四类→五类)。

测试:reconcile.e2e ⑤ 追加 runOnce 生产环断言——sweptEvents=5
(album+photo×2+ad×2)、replayApplied=2(仅 ad 新 eventId)、
ads_ov 两行投影就位(全局 albumIds NULL/定向数组)、digest 稳态不受
影响。sync 全量 e2e 4 suites/28 tests、api ads 16/16、typecheck/lint 净。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 00:26:21 +08:00
浅念 60b675a908 feat(sync): 对账清单 ad 实体当前态清扫
Task 8(ads-carousel):cn reconcile 增 entityType='ad' 分支——纯 SELECT
当前态映射(sortOrder/createdAt 确定性排序,albumId 恒空串信封约定),
载荷复用 ads/ad-payload.ts 单一出处(与业务事务内即时事件逐字节同形,
纯函数模块无依赖环);DTO SYNC_ENTITY_TYPES 追加 'ad'(对齐契约 enum)。

随附质量审查两项:
- ads.service list() 行→AdminAdItem 映射复用 toItem(删 ~14 行内联重复,
  ⑮ 补 admin list 200 形状断言覆盖重构路径);
- 新增 test/ads-extensions.spec.ts 守护 AD_IMAGE_EXTENSIONS ===
  UPLOAD_CONTENT_TYPES 派生归一扩展名集合(jpeg→jpg),防三处白名单漂移。

测试基建:reset.ts RESET_TABLES 补 'ads'(Task 6 建表遗漏,固定 id 直种
依赖跨 spec 清场);reconcile.e2e-spec 追加 ⑤(红→绿,5/5)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 00:22:13 +08:00
浅念 4979091d2c feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图
Task 7(ads-carousel):/v1/admin/ads list/create/patch/delete 四端点。
- 事务三写:ads 行 + outbox(ad.updated 全量快照/ad.deleted 墓碑)+ audit
  (ad.create/update/delete);ad.* 信封 albumId 恒空串;停用迁移 true→false
  与删除均 priority='high';
- 创建/换图:staging head 三核(存在/bytes/contentType,assets complete 同
  口径)→ 事务内 copyIfAbsent 先于行写入(失败=孤儿终键对象,OSS 生命周期
  兜底)→ 版本化终键 adObjectKey(adId, v, ext);
- 更新:embed 乐观重试模式(条件 updateMany WHERE entityVersion),保证换图
  终键版本段与行版本严格对齐;换图三字段同时性校验(缺一 400);albumIds
  去重+相册存在性 count 校验([]/null 归一全局 null);
- 删除:条件 deleteMany 重跑保墓碑 entityVersion=末+1;重复删除 404;
- 载荷构建器独立 ad-payload.ts(Task 8 对账共用,逐字节同形约定);
- dto:AdCreateRequestDto.albumIds 收窄为 string[] | null(JSON null 在
  @IsOptional 下可达,embed settings 先例);
- e2e:describe 更名 ads domain (e2e),追加 ⑨-⑯ 八用例(真图直传/定向/
  非法 linkUrl/字段级 PATCH/换图/DELETE/权限/同步贯通),16/16 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 23:57:43 +08:00
浅念 121afadf1e feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 23:34:11 +08:00
浅念 4db90b0e9e feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)
- dto.ts SYNC_ENTITY_TYPES 追加 'ad'(对齐契约 SyncEntityType)。
- decideAndApply ③依赖检查豁免 ad:albumId 恒空串,不豁免则事件被永久
  暂存且无解锁路径(slot-state 同款空 albumId 危害)。
- project() 新增 ad 分支:全量快照直投 ads_ov;墓碑或 enabled=false 均
  deleteMany(disabled=公开不可见=无投影);albumIds 整体替换;createdAt
  取 cn 原值、updatedAt 为 ov 应用时刻(载荷无该字段)。
- sender.toWireEvent 新增 ad 分支:entityId 取载荷 adId(outbox 表无列),
  泛化 categoryId/assetId 兜底之前,缺料 fail-fast。
- e2e ads.e2e-spec:手工构造 outbox 行(绕过 CRUD,Task 7 才有)走真实
  sender→HTTP→inbox 链路,5 用例覆盖快照落位/disable 删行/复活 upsert/
  迟到旧版本不回滚/墓碑删行;reset-ov 增清 ads_ov。
- db-ov schema 附带注释修:ads_ov updatedAt 语义纠正(迁移头过度声明,
  迁移文件不动防 checksum 漂移)+ sync_inbox.entityType 枚举补 ad。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 23:18:37 +08:00
浅念 d86f452bed docs: 广告位设计 404 码 ALBUM_NOT_PUBLIC 纠正入档 + 实施计划 Task 1-4 勾选
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 23:03:32 +08:00
浅念 539597e9f0 feat(storage): 广告对象键布局(staging + 版本化终键)
adsStagingObjectKey(两步上传 presignPut 直传位)+ adObjectKey
(版本段=entityVersion:换图必然 bump 版本→新键,copyIfAbsent
禁止覆盖语义下天然防重复/防串图);index.ts 按名再导出供 Task 6/7
ads.service 消费。设计方案 §2.1/§3 终键布局同步回填(偏差登记)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 23:01:09 +08:00
浅念 318f69dae7 fix(db): photos 查重索引补 schema 声明——消除 migrate dev 误提议 DROP 漂移
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 22:49:43 +08:00
浅念 726f6a1be8 feat(db): ads 写权威表与 ads_ov 投影表(含迁移)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 22:43:25 +08:00
浅念 001d3aaf73 fix(contracts): 广告位 404 码对齐 NOT_FOUND/title 必填/空 PATCH 语义/上限漂移守护
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 22:29:45 +08:00
浅念 ec55d5718b feat(contracts): 广告位 openapi 端点/schema 与 AD_UPLOAD_MAX_BYTES,类型再生
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 22:16:12 +08:00
浅念 af8f3b78dd feat(contracts): ad.updated/ad.deleted 事件类型与 ad 实体映射
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 21:58:32 +08:00
浅念 ac37794d43 fix(app): path-precise duplicate matching; shared file stat; union test
Review follow-ups on 0fa23e0 (Task 2), applied as one commit:

- Path-precise dupPaths (#2, user-visible): DuplicateMatch gains
  sourcePath (local match fills it from the matched pick; unified
  server branch from picks[hit.index].path). Both pages' duplicate
  dialogs derive dupPaths from duplicates' sourcePath instead of
  basename matching, so same-basename siblings (different dir/bytes)
  are no longer mis-flagged — "跳过重复" no longer drops the
  non-duplicate sibling. Stale basename-derivation comment removed.
- Single shared stat (#3): files are stat'd once in
  checkDuplicatesUnified (_statPicks) and the picks feed both the
  local half (extracted _matchLocalHistory; public checkDuplicates
  folded — it had no remaining callers) and the server request.
  Halves stat IO for large batches (was ~2x).
- Union test (#1): new test/features/uploads/enqueue_test.dart pins
  that a file hit by BOTH local and server history yields ONE
  DuplicateMatch with the local uploadedAt (server's echoed time does
  not override). Merge dedup is now keyed by sourcePath
  (putIfAbsent, local first).

Tests: +1 enqueue union test (real-async chain inside one runAsync
window; sync file/dir setup — fake-async body must not await real
IO), +1 album_detail same-basename sibling test (skip-duplicates
enqueues exactly the non-hit sibling, distinguished by bytes since
sourceFilePath becomes the preparer's staging copy).

Gates: flutter analyze clean; full flutter test 429/429 green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 20:22:37 +08:00
浅念 0fa23e015e feat(app): server-side duplicate pre-check at enqueue moment
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.

- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
  (commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
  index translated to global; empty list short-circuits (no request);
  errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
  history; server hits map back to filename via global index into the
  stat'd picks; merged, de-duplicated (filename+bytes) list; server
  failure (network/permission/any) silently degrades to local-only —
  init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
  unified entry; dialog/skip/force logic untouched.

Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 19:17:35 +08:00
浅念 64e1be09d3 test(api): pin earliest-uploadedAt + uploadEnabled-agnostic pre-check; fix comment
评审硬化三项(checkUploadDuplicates 后续,不 amend):
- e2e 补「同 bytes 多条可见命中取最早 uploadedAt」:两条同 bytes 不同
  uploadedAt 的种子行,断言回显更早值。
- e2e 补「uploadEnabled=false 预检仍 200 且照常命中」:钉住只读预览刻意
  不校验开关的偏差——防止未来一致性重构把检查加回来,破坏 App 关册时的
  降级体验(init 入队时仍有 409 兜底)。
- 修正 uploads.service checkDuplicates 的 null-uploadedAt 注释:原「谓词
  命中与否不受影响」不准确,改为「仅直改库等理论形状(bytes>0 且
  uploadedAt=null)下预检可能漏报而 init 仍会 409」。

uploads e2e 30/30 ✅ · api typecheck ✅

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 19:06:47 +08:00
浅念 805f77be76 feat(api): upload duplicate pre-check endpoint (checkUploadDuplicates)
「上传查重前置到入队前」Task 1:POST /v1/uploads/duplicates/check 只读批量
查重预检——用户点上传(确认入队)时刻预判服务端历史重复(跨设备/重装/改名
均可查到),不再等任务在上传中心以 PHOTO_DUPLICATE 失败才暴露。

- 契约纯增量:/uploads/duplicates/check + UploadDuplicateCheck(Item|Request|
  Duplicate|Response) schemas;regen openapi.d.ts。
- 判定谓词与 init ③.5 服务端查重逐字一致:同 albumId + uploaderId=当前用户
  + bytes 相等 + visibility != 'deleted'(filename 刻意不参与),保证「预检
  即真相」;命中条目按请求下标逐项返回(同 bytes 多请求条目各一条)。
- 权限链同 init(canView 404 防枚举 / canUpload 403 / 相册空值兜底 404),
  刻意不校验 uploadEnabled:只读预览,init 真正入队时仍按开关 409 兜底。
- 查询走 idx_photos_dedup:请求条目去重字节集一次 findMany,内存建
  bytes→最早 uploadedAt 映射后按请求下标回射。
- items 1..500(minItems/maxItems + 嵌套逐项校验,越界 400)。
- e2e 追加 describe 7 用例:命中含双条目同 bytes/未命中/uploaderId 隔离/
  deleted 不命中/非成员 404/items 边界 400/未认证 401。

契约 lint ✅ · contracts+api typecheck ✅ · api 单测 56/56 ✅ ·
uploads e2e 28/28 ✅

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:56:07 +08:00
浅念 7d744da56e docs: 广告位实施计划——评审修订(inbox 依赖暂存豁免 ad 关键补丁/常量指针纠正/组件测试补齐/compose 全路径)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:41:54 +08:00
浅念 4b5940c0d8 test(api): 修复 uploads e2e 与服务端查重(90dcc58)的字节数冲突
90dcc58 引入 init ③.5 服务端查重(同 albumId+uploaderId+bytes 非 deleted
→ 409 PHOTO_DUPLICATE)时未同步更新本 spec:renew/complete describe 内多个
init 沿用 bytes=1000,C-bytes complete 落定 1000 字节照片后,C-type/C-happy/
DEV-1/DEV-2 的 init 即被查重 409 拦截(该提交仅跑了 app 397 测试,api e2e
漏跑)。为各冲突 init 改用互异字节数(1002/1003/1004/1005),并同步
putStaging 与 bytes/expectedAt 断言;各用例语义不变。

本地库补apply 20260928120000_photos_dedup_index(idx_photos_dedup)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:38:36 +08:00
浅念 3b7d37110a docs: 广告位设计补轮播图左上角「广告」可见小标签(合规可识别性)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:25:49 +08:00
浅念 678225e8b5 docs(plan): upload duplicate pre-check at enqueue moment — incremental plan
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:25:24 +08:00
浅念 8453167b39 docs: 广告位设计补部署顺序硬约束(ov 先 cn 后,DTO IsIn 整批拒收已核实)、广告图 2MB 上限、量级假设登记
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:24:00 +08:00
浅念 ab412a3630 docs: 广告位设计补 enabled 翻转的 sync inbox 直测用例(评审建议)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:16:15 +08:00
浅念 0220a9cc05 docs: 广告位设计评审修订——createdAt 进投影/载荷、inbox disabled=删行、albumId 恒空串、head 校验补 contentType
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:14:00 +08:00
浅念 573a559e4a docs: 广告位轮播设计方案(全局+定向投放/两步上传/outbox 同步投影/sessionStorage 关闭)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 18:09:35 +08:00
浅念 46f9d93fd3 fix(app): guard force-column migration against downgrade-then-reupgrade
Review hardening for Task 8 (test-server workflow: testers install older
APKs over a newer DB):

- Old APK opening a v2 database hits drift's downgrade path, which leaves
  the force column in place but writes user_version back to 1. Reinstalling
  the new APK then re-runs onUpgrade(1,2) and addColumn crashes with
  "duplicate column name: force", failing the DB open.
- onUpgrade now checks PRAGMA table_info(upload_tasks) and only adds the
  force column when absent; the normal v1 -> v2 path is unchanged.
- Migration test simulates the downgrade-reupgrade cycle on a file-backed
  database (v2 open -> user_version forced back to 1 -> re-upgrade),
  asserting the open succeeds, legacy rows survive, and the column stays
  writable. RED before the guard (SqliteException: duplicate column name).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 17:27:52 +08:00
浅念 29f3bde9c2 feat(app): wire server dedup - force passthrough + PHOTO_DUPLICATE classification & l10n
Task 8: close the gap between server-side duplicate detection (90dcc58)
and the app.

- ApiClient.mapDioException: normalize 409 wire body reusing
  VALIDATION_FAILED with message=PHOTO_DUPLICATE to
  ErrorCodes.photoDuplicate (message-based; auto-compat if the contract
  later adds a dedicated code)
- l10n zh/en errorPhotoDuplicate; error_l10n maps the code (queue page
  inherits via fallback)
- UploadEngine._classifyFailure: PHOTO_DUPLICATE joins the no-auto-retry
  bucket (retrying the same init body would 409 again); force to skip
  dedup travels only through the enqueue path
- UploadTasks drift table +force column (schema v2, addColumn migration;
  legacy rows default false); UploadTaskDraft/UploadTask/prepare carry it
- enqueueForUpload +forcePaths; duplicate dialog "全部上传" (capture page
  + album detail page) now returns DuplicateDialogChoice so confirmed
  duplicates enqueue with force=true (server adjudicates owner/admin)
- engine init body carries force only when true (absent otherwise)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 17:06:19 +08:00
浅念 4f89c25bad fix(app): lock capture page portrait; gate zoom badge on 2 pointers; drop dead getter
- MainActivity locked to portrait: the capture page top bar / bottom
  controls and the preview aspect swap all assume portrait; landscape
  misaligns the preview ratio and control layers.
- Zoom badge now requires details.pointerCount >= 2 on onScaleStart so a
  single stationary tap no longer pops a 1.0x badge for 1s; base-zoom
  rebasing stays unconditional (single-finger-into-pinch still resumes
  from the current level).
- Removed unused CameraPluginSource.currentZoom getter.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 16:33:15 +08:00
浅念 ac602acf47 feat(app): camera preview WYSIWYG aspect + pinch zoom with double-tap reset
Preview now centers at the sensor aspect ratio (letterboxed on the dark
backdrop) instead of FittedBox cover, which cropped ~38% horizontally and
made the preview differ from the full-frame takePicture output.

Zoom capability hangs off CaptureSource as default implementations
(minZoom/maxZoom = 1, setZoom no-op) so existing fakes stay untouched;
CameraPluginSource probes getMin/MaxZoomLevel after initialize (failure
keeps 1/1 and disables the gesture). The page attaches a scale
GestureDetector only when maxZoom > 1: pinch drives setZoom (throttled at
0.05 delta, serialized to prevent out-of-order platform calls), a badge
shows the current level and hides after 1s, double-tap resets to 1.0x,
and camera flip/reopen resets zoom state. _StaticCaptureSource in
album_detail_page_test.dart switches implements -> extends to inherit the
new defaults.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 16:17:07 +08:00
浅念 65cd6849cc docs(plan): Task 9 checklist — GIF/truncated source cases for crop
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 16:02:57 +08:00
浅念 683e19bdc1 feat(app): crop cover & banner before upload (crop_your_image, 16:9 / 3:1)
review fixes: drop GIF from pre-sniff (package maps gif→png decode → never-ready hang); PopScope blocks back during processing

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 16:01:24 +08:00
浅念 97db504e25 docs(plan): fix manifest snippet (VISUAL_USER_SELECTED has no maxSdkVersion) + expand Task 9 checklist
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 15:04:42 +08:00
浅念 2e5fc40636 feat(app): in-app asset picker (wechat_assets_picker) - unlimited multi-select with range selection
Review fixes: per-asset originFile error isolation (failed asset skipped, not whole batch); drop maxSdkVersion from READ_MEDIA_VISUAL_USER_SELECTED (API 34+, no upper bound); originFile doc accuracy (API 30+ direct path vs Android 10 ScopedCache copy).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 15:03:44 +08:00
浅念 aea797c7c7 docs+test(app): correct okhttp version comments; harden throttle test assertions
- UploadPutExecutor.kt / build.gradle.kts:OkHttp 版本注释改为实情——声明 4.12.0,Gradle 冲突仲裁实际解析 5.2.1(fluwx 传递依赖胜出)
- UploadPutExecutorTest.kt:节流测试补 isNotEmpty 守护与 size>=2 断言(堵住过度节流只报终值的盲区)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 14:22:24 +08:00
浅念 45dbde3719 perf(app): throttle upload progress callbacks to 256KB steps
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 14:10:29 +08:00
浅念 203f62e7cb test(app): align mockwebserver with resolved okhttp 5.2.1
fluwx 6.0.2 传递 okhttp 5.x,冲突调解覆盖本模块 4.12 声明(debug
运行时同为 5.2.1);mockwebserver 4.12 在 okhttp 5.x 运行时缺
okhttp3.internal.Util → 单测 setUp 即 NoClassDefFoundError(本改动
前 main 上已复现)。对齐后 11/11 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 14:10:14 +08:00
浅念 93e7046332 feat(app): enqueue gallery batch concurrently via Future.wait
Parallelize the enqueueForUpload collect loop (preparer's internal
bounded semaphore from the previous task keeps IO in check). Also two
test hardenings from that task's code review: pin that failed and
thrown prepare operations release their concurrency slot (cap=1,
subsequent prepare still completes), and pin the maxConcurrentPrepares
<1 -> 1 clamp (peak 1, no deadlock).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 13:53:42 +08:00
浅念 b79edf447b feat(app): bounded-parallel prepare (default 3) replaces serial queue
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 13:43:07 +08:00
浅念 dc5ae4a24a feat(app): raise default upload parallelism 2 -> 4
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 13:34:48 +08:00
浅念 cc47278f50 docs: upload speedup plan v2 — 9 tasks after full re-review
- Task 7 camera WYSIWYG aspect + pinch zoom (two-pointer test, throttled setZoom)
- Task 8 server-dedup app integration (force passthrough, PHOTO_DUPLICATE
  classification, l10n) — closes the 409 dead-end from 90dcc58
- Task 6 crop: crop_your_image 2.0.0 callback API + Isolate.run pixel pipeline
- Stale claims fixed (album_detail dedup already wired in 638dd9b), pre-flight
  parallel-session check added

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 13:34:03 +08:00
浅念 441a24a54c docs: upload speed optimization plan draft
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:33:04 +08:00
浅念 90dcc58c33 feat(uploads): P0-1 batch 4 — server-side duplicate photo detection (409 + force + index)
契约 UploadInitRequest 加 force(bool,仅 owner/admin 跳过查重)。
服务端 init 事务前置查重:同 albumId + uploaderId + bytes 的非 deleted
照片已存在 → 409 PHOTO_DUPLICATE。photos 表加 idx_photos_dedup 索引
(迁移 20260928120000)。
App UploadInitRequest 加 force 字段 + toJson 序列化。
本地查重(层 1)+ 服务端查重(层 2)双层防线到位。

API typecheck ✅ · app analyze ✅ · 397/397 ✅ · contract lint ✅

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:29:29 +08:00
浅念 33f347cdea feat(app): P0 batch 3 — offline detection with engine pause/resume + UI banner
P0-4 离线感知:
  - connectivity_plus → NetworkAvailability(online/offline/unknown)
  - engine_scope 三重绑定:有账号+有网 → start;任一失去 → stop(在途不中断)
  - 网络恢复 → start + onNetworkRestored(waitingNetwork 即时重试)
  - HomePage 顶部离线黄条(wifi_off + i18n 双语)

P0-5 更正:EXIF 方向已由服务端 sharp .rotate() 自动转正——v2 分析误报

397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:23:51 +08:00
浅念 638dd9b45d feat(app): P0 batch 2+2.5 — local upload dedup with batch dialog, album switch check, file size guard
P0-1 层 1 本地查重:
  - checkDuplicates:同账号+同相册+state=completed+filename+bytes 匹配
  - EnqueueResult 返回 enqueued+duplicates+skipped
  - capture_page + album_detail_page:批量单次弹窗(非逐张)三选一
    [跳过重复上传非重复] [全部上传] [取消]

P0-8 相册上传开关预检:album_detail_page 入队前检查 uploadEnabled,
  关闭时提示「该相册已关闭上传」+ 不入队

P1-8 文件大小预检:preparer 限单文件 ≤100MB(磁盘满由 on Object 兜底)

P0-7 更正:上传并发限制已有(kMaxParallelUploads=2),v2 分析误报

397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:06:40 +08:00
浅念 9edcb3f2b1 feat(app): P0 batch 1 — photo viewer zoom, camera lifecycle, image memory management
P0-2 查看器缩放:InteractiveViewer(5x max,放大后平移,1x 时穿透到
  PageView 滑动切页)
P0-3 相机后台释放:WidgetsBindingObserver——paused 时 dispose(省电+
  隐私指示灯),resumed 时重开(沿用前后摄/闪光灯偏好)
P0-6 图片内存管理:PhotoThumbnail 加 maxCacheWidth 参数——网格/列表
  缩略图限制解码宽度(600/400px),查看器大图不限

397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 10:35:14 +08:00
浅念 98e073d8c8 docs: App potential issues v2 — full analysis with server-side dedup (confirmed, ready to execute)
v2 补齐 v1 自查发现的 8 个漏洞:三重指纹(filename+bytes+CRC32)、批量弹窗
(单次确认非逐张)、photos 表 dedup 索引、force 权限约束(owner/admin)、
上传并发限制(3)、相册开关预检、上传进度百分比、磁盘空间预检。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 10:24:46 +08:00
浅念 984fe8981c docs(viewer): prod verification — share sheet copy-only (native share button removed)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 09:15:30 +08:00
浅念 a5d71686ff feat(viewer): remove native share button from share sheet (user request) — copy link only
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 09:08:39 +08:00
浅念 dc8d3ff967 feat(app): launcher icon — brand logo 1024px + adaptive (blue #1A73E8 background)
flutter_launcher_icons 自动生成全尺寸 mipmap(48-192px + 自适应前景/背景)。
正式签名生产环境 APK 已打好。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 17:07:14 +08:00
浅念 2ce529dd5b docs(viewer): prod verification — share sheet without link text
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:55:07 +08:00
浅念 d279cefd71 feat(viewer): hide link text under QR in share sheet (user request) — QR + copy button remain
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:51:36 +08:00
浅念 643f837bca feat(viewer): hide view count in album header (user request) — counting/reporting unaffected
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:24:05 +08:00
浅念 cf490c7892 feat(app): all album date displays date-only — formatAlbumDateTime drops HH:mm
相册列表卡/详情信息/分享页 meta/照片查看器日期一律纯日期(y/M/d)。
全量测试断言同步;397/397 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:20:47 +08:00
浅念 07761b1b26 fix(app): camera preview distortion — swap sensor W/H for portrait
previewSize 报横向(W>H),竖屏直用会把画面拉扁。交换宽高转纵向比例再
FittedBox cover 铺满(裁切不变形)。397/397 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:07:44 +08:00
浅念 c199d0630c feat(embed): photo click opens album page in a new tab (user decision 2026-09-24)
Reverts the in-host detail modal interaction: click now window.open's the album page (/a/:token?lang=, no /p/ deep link) with noopener — same target for middle-click/right-click via the real <a> href. The loader's open-photo modal code remains but is no longer triggered.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 15:25:41 +08:00
浅念 bd4db7b458 style(viewer): 相册 banner 限高 320px + cover 裁切(PC 端观感修正)
height:100% + contain 在桌面宽屏下整图 letterbox 压扁留白——改为限高裁切。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 15:00:12 +08:00
浅念 32eb4ceb8e docs: 观众链接统一境外源定稿(后台分享/嵌入/App二维码均 ov 域名)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 14:46:54 +08:00
浅念 76380477a0 docs: 验收记录补嵌入组件 EMBED_ORIGIN 修正(cn 后台嵌入代码改指 ov 域名)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 14:01:12 +08:00
浅念 e5302e3929 docs: 正式环境双地域部署计划与验收记录
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 13:41:47 +08:00
浅念 30a3520669 fix(deploy): formalize nginx entrypoint ipv6 script removal in web.Dockerfile
G4 §4.8 事件 B:服务器新拉的 nginx:alpine 最新 digest 的
10-listen-on-ipv6-by-default.sh 在服务器上挂起(nginx 不启动、
healthcheck 拒连)。此前补丁只在服务器侧临时 Dockerfile,本次正式
进仓库;钉基础镜像版本后可移除(G4 待办)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:53:11 +08:00
163 changed files with 13300 additions and 545 deletions
+114
View File
@@ -0,0 +1,114 @@
# /opt/photo-live/deploy/docker-compose-cn.yml —— 正式环境 cn 栈(2026-09-24)。
# 基于 infra/deploy/docker-compose.cn.yml 改造(同 ov 版偏差登记):
# ① 自管 mysql:8.4 + redis:7(1Panel MySQL 是 5.7.44 不满足基线)
# ② 独立桥网络 photo-net(与机上其它项目隔离)
# ③ web 回环端口 18088(避开 1Panel phpMyAdmin 的 8089 段)
# ④ mysql 内存调优:cn 机仅 3.5G(可用 ~1.4G),关 performance_schema、
# buffer pool 显式 128M(另建 2G swap 兜底,见部署记录)
# cn 侧多两个角色:worker(sharp 图片流水线)+ sync(跨境推送/对账/统计回传)。
# sync 需出网可达:OV_API_URL(https 443)与 DATABASE_URL_OV(47.254.176.10:13306,
# 安全组白名单 cn 出口 IP)。
name: photo-live-cn
x-logging: &default-logging
driver: json-file
options: { max-size: "10m", max-file: "3" }
services:
mysql:
image: mysql:8.4
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_bin --performance-schema=OFF --innodb-buffer-pool-size=128M
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: photo_cn
MYSQL_USER: photo_cn
MYSQL_PASSWORD: ${MYSQL_APP_PASSWORD}
volumes:
- mysql-cn-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
retries: 24
restart: unless-stopped
logging: *default-logging
networks: [photo-net]
redis:
image: redis:7-alpine
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 12 }
restart: unless-stopped
logging: *default-logging
networks: [photo-net]
api-cn:
image: ${SERVER_IMAGE}
command: ["api-cn"]
init: true
env_file: .env.production-cn
environment:
PORT: "8080" # 容器内固定;对外经 web 反代
restart: unless-stopped
depends_on:
mysql: { condition: service_healthy }
redis: { condition: service_healthy }
healthcheck:
test: ["CMD", "curl", "-fs", "http://127.0.0.1:8080/v1/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
logging: *default-logging
networks: [photo-net]
worker:
image: ${SERVER_IMAGE}
command: ["worker"]
init: true
env_file: .env.production-cn
# 无 HTTP 端口:健康面由 restart 策略 + 日志监控承担(RUNBOOK.md)
restart: unless-stopped
depends_on:
mysql: { condition: service_healthy }
redis: { condition: service_healthy }
logging: *default-logging
networks: [photo-net]
sync:
image: ${SERVER_IMAGE}
command: ["sync"]
init: true
env_file: .env.production-cn
# 需出网:OV_API_URL(443) + DATABASE_URL_OV(13306,待安全组放行)
restart: unless-stopped
depends_on:
mysql: { condition: service_healthy }
redis: { condition: service_healthy }
logging: *default-logging
networks: [photo-net]
web:
image: ${WEB_IMAGE}
init: true
environment:
API_UPSTREAM: http://api-cn:8080 # /v1 反代目标(含 WS /v1/realtime)
ROOT_REDIRECT: /admin/ # cn 根路径直达后台
ports:
- "127.0.0.1:18088:80" # 1Panel OpenResty 反代到此(站点已建)
restart: unless-stopped
depends_on:
api-cn: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
logging: *default-logging
networks: [photo-net]
volumes:
mysql-cn-data:
networks:
photo-net: { driver: bridge }
+88
View File
@@ -0,0 +1,88 @@
# /opt/photo-live/deploy/docker-compose-ov.yml —— 正式环境 ov 栈(2026-09-24)。
# 基于 infra/deploy/docker-compose-ov.yml 改三点(计划 §2 T0 偏差登记):
# ① 自管 mysql:8.4 + redis:7(1Panel MySQL 是 5.7.44,不满足 8.4/utf8mb4_0900_bin 基线)
# ② 独立桥网络 photo-net(不挂 1panel-network,与机上其它项目隔离)
# ③ web-ov 回环端口 18089(8089 被 1Panel phpMyAdmin 占用)
# ④ mysql 公网映射 13306:cn 侧 sync 跨境直连读库——安全组仅放行 101.37.116.184!
# 同目录配套:.env(SERVER_IMAGE/WEB_IMAGE/MYSQL_ROOT_PASSWORD/MYSQL_APP_PASSWORD)
# 与 .env.production-ov(应用 env,模板=仓库根 .env.production.example-ov)。
name: photo-live-ov
x-logging: &default-logging
driver: json-file
options: { max-size: "10m", max-file: "3" }
services:
mysql:
image: mysql:8.4
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_bin
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: photo_ov
MYSQL_USER: photo_ov
MYSQL_PASSWORD: ${MYSQL_APP_PASSWORD}
ports:
- "0.0.0.0:13306:3306" # 仅安全组白名单 cn IP 可达(阿里云控制台)
volumes:
- mysql-ov-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
retries: 24
restart: unless-stopped
logging: *default-logging
networks: [photo-net]
redis:
image: redis:7-alpine
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 12 }
restart: unless-stopped
logging: *default-logging
networks: [photo-net]
api-ov:
image: ${SERVER_IMAGE}
command: ["api-ov"]
init: true
env_file: .env.production-ov
environment:
PORT_OV: "8081" # main-ov.ts 读 PORT_OV;容器内固定
restart: unless-stopped
depends_on:
mysql: { condition: service_healthy }
redis: { condition: service_healthy }
healthcheck:
test: ["CMD", "curl", "-fs", "http://127.0.0.1:8081/v1/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
logging: *default-logging
networks: [photo-net]
web-ov:
image: ${WEB_IMAGE}
init: true
environment:
API_UPSTREAM: http://api-ov:8081 # /v1 反代目标(含 WS /v1/realtime)
ROOT_REDIRECT: /index.html # 观众端安全自指
ports:
- "127.0.0.1:18089:80" # 1Panel OpenResty 反代到此(站点已建)
restart: unless-stopped
depends_on:
api-ov: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
logging: *default-logging
networks: [photo-net]
volumes:
mysql-ov-data:
networks:
photo-net: { driver: bridge }
+4
View File
@@ -0,0 +1,4 @@
SERVER_IMAGE=photo-live-server:30a3520-20260924
WEB_IMAGE=photo-live-web:30a3520-20260924-cn
MYSQL_ROOT_PASSWORD=2d88f24a008eac264944141c69b61b22dfbd00aaecd133c7
MYSQL_APP_PASSWORD=20a6f359f685b6a0ae725698d722cfc00357503d7a27ecd7
+4
View File
@@ -0,0 +1,4 @@
SERVER_IMAGE=photo-live-server:30a3520-20260924
WEB_IMAGE=photo-live-web:30a3520-20260924-ov
MYSQL_ROOT_PASSWORD=127bd53921e208fbc0f9ff9c0b64a5fa71069ef0b083c617
MYSQL_APP_PASSWORD=70e4a6edc39800bd160723cb62a9a8e6a91d4c8f4c0ce399
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# 一次性:PC 适配验证测试相册(真实链路上传 6 张不同宽高比测试图)
set -eu
CN=https://live-album.inside.hnxinyouhe.com
OV=https://live-album.outside.hnxinyouhe.com
IMGDIR="C:/Users/30997/AppData/Local/Temp/pc-test"
PW=$(cat /d/project/photo/.env.prod-secrets/ADMIN_PASSWORD | tr -d '\n\r')
extract() { printf '%s' "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p" | head -1; }
BODY=$(curl -sS $CN/v1/auth/password -H 'content-type: application/json' -d "{\"account\":\"admin\",\"password\":\"$PW\"}")
TOKEN=$(extract "$BODY" sessionToken); [ -n "$TOKEN" ] || { echo "login FAIL: $BODY"; exit 1; }
echo "login ok"
BODY=$(curl -sS $CN/v1/albums -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"translations":[{"locale":"zh-CN","title":"PC适配验证(测试相册可隐藏)","description":"桌面端样式优化验证用,6 张不同宽高比测试图"}],"defaultLocale":"zh-CN","startAt":"2026-09-24T00:00:00.000Z"}')
ALBUM_ID=$(extract "$BODY" id); PUB=$(extract "$BODY" publicToken)
[ -n "$ALBUM_ID" ] || { echo "album FAIL: $BODY"; exit 1; }
echo "album: $ALBUM_ID publicToken: $PUB"
i=0
for f in "$IMGDIR"/img*.jpg; do
i=$((i+1))
BYTES=$(stat -c %s "$f")
BODY=$(curl -sS $CN/v1/uploads -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"albumId\":\"$ALBUM_ID\",\"clientTaskId\":\"pc-test-$i\",\"filename\":\"pc-$i.jpg\",\"bytes\":$BYTES,\"contentType\":\"image/jpeg\"}")
UP=$(extract "$BODY" uploadId); URL=$(extract "$BODY" url)
[ -n "$UP" ] || { echo "init$i FAIL: $BODY"; exit 1; }
CODE=$(curl -sS -X PUT "$URL" -H 'Content-Type: image/jpeg' --data-binary @"$f" -o /dev/null -w '%{http_code}')
[ "${CODE:0:1}" = 2 ] || { echo "PUT$i FAIL: $CODE"; exit 1; }
CODE=$(curl -sS -X POST $CN/v1/uploads/$UP/complete -H "authorization: Bearer $TOKEN" -o /dev/null -w '%{http_code}')
[ "${CODE:0:1}" = 2 ] || { echo "complete$i FAIL: $CODE"; exit 1; }
echo "img$i uploaded ($BYTES B)"
done
echo "waiting for ov visibility..."
for t in $(seq 1 30); do
N=$(curl -sS "$OV/v1/public/albums/$PUB/photos" | grep -c photoId || true)
[ "$N" -ge 6 ] && { echo "ov visible: $N photos (${t}x2s)"; break; }
sleep 2
done
echo "VIEWER_URL=https://live-album.outside.hnxinyouhe.com/a/$PUB"
+39
View File
@@ -0,0 +1,39 @@
/**
* 广告位管理接口(对齐 @photo/contracts tags [ads]:listAdminAds /
* createAd / updateAd / deleteAd / initAdUpload,广告位轮播 Task 11;仅 admin 可用)
*
* - 列表全量不分页(sortOrder 升序、并列 createdAt 升序);imageUrl 为
* 现签 GET,过期后重拉本列表刷新
* - 直传两步:init 签发 staging 凭证 → 裸 fetch PUT(预签名 URL 自带
* 凭证,Content-Type 与申明一致);无 complete 步——创建/更新端点核验
* staging 对象后 copyIfAbsent 转存版本化终键
* - PATCH 部分更新:换图三字段(imageStagingKey/imageBytes/imageContentType)
* 必须同时提供;albumIds null=转全局
* - DELETE 硬删(数据不保留、无恢复端点;幂等 404)
*/
import request from '@/utils/http'
/** 广告位列表(全量不分页) */
export function fetchAds() {
return request.get<Api.Ads.ListResponse>({ url: '/v1/admin/ads' })
}
/** 初始化广告图直传(staging 键 + 预签名凭证) */
export function fetchInitAdUpload(data: Api.Ads.UploadInitPayload) {
return request.post<Api.Ads.UploadInitResponse>({ url: '/v1/admin/ads/uploads', data })
}
/** 创建广告位(服务端核验 staging 后转存终键;albumIds 空=全局) */
export function fetchCreateAd(data: Api.Ads.CreatePayload) {
return request.post<Api.Ads.Item>({ url: '/v1/admin/ads', data })
}
/** 更新广告位(部分更新;换图三字段同时提供) */
export function fetchUpdateAd(id: string, data: Api.Ads.UpdatePayload) {
return request.patch<Api.Ads.Item>({ url: `/v1/admin/ads/${id}`, data })
}
/** 硬删除广告位(无恢复;重复删除 404) */
export function fetchDeleteAd(id: string) {
return request.del<void>({ url: `/v1/admin/ads/${id}` })
}
+64 -1
View File
@@ -235,7 +235,8 @@
"users": "Account Management",
"userSync": "User Sync",
"usage": "Usage",
"embedSlots": "Embed Slots"
"embedSlots": "Embed Slots",
"ads": "Ad Management"
},
"albums": {
"title": "Albums",
@@ -922,6 +923,68 @@
"enabledOff": "Disabled (configVersion increased to {version}; public feed returns 404)"
}
},
"adsManage": {
"list": {
"title": "Ad Management",
"subtitle": "Manage album-page ad carousels: images, links, targeting and ordering.",
"tableTitle": "Ads",
"create": "New Ad",
"empty": "No ads yet; click \"New Ad\" to create one",
"loadFailed": "Failed to load ads",
"retry": "Retry",
"colImage": "Image",
"colTitle": "Label",
"colLink": "Link",
"colScope": "Targeting",
"colEnabled": "Enabled",
"colActions": "Actions",
"scopeGlobal": "Global",
"scopeTargeted": "{n} album | {n} albums",
"imageAlt": "Ad image",
"actionUp": "Move Up",
"actionDown": "Move Down",
"actionEdit": "Edit",
"actionDelete": "Delete",
"deleteConfirm": "Delete ad \"{name}\"? This cannot be undone (data is not retained).",
"deleted": "Ad deleted"
},
"toast": {
"enabledOn": "Ad enabled",
"enabledOff": "Ad disabled (hidden from viewer pages immediately)"
},
"form": {
"imageLabel": "Ad image",
"imagePick": "Choose Image",
"imageRepick": "Replace Image",
"imageHint": "Recommended 1200×360, up to 2MB; jpeg/png (heic not supported: some browsers cannot display it)",
"imageRequired": "Please upload an ad image first",
"imageInvalidType": "Only jpeg/png images are supported (heic not supported)",
"imageTooLarge": "Image exceeds the 2MB limit",
"imageUploadFailed": "Image upload failed: {message}",
"imageStagedTag": "New image pending save",
"linkLabel": "Link",
"linkPlaceholder": "https://example.com/promo",
"linkRequired": "Please enter a link",
"linkInvalid": "Link must start with http(s):// and be at most 2048 characters",
"titleLabel": "Label",
"titlePlaceholder": "e.g. Autumn campaign (back-office only, optional)",
"albumsLabel": "Bound albums",
"albumsPlaceholder": "Search by album title or ID",
"albumsSearching": "Searching…",
"albumsNoMatch": "No matching album",
"albumsHint": "Leave empty = global (carousel on all album viewer pages)",
"staleAlbum": " (no longer available, removable)",
"submit": "Save"
},
"create": {
"title": "New Ad",
"created": "Ad created"
},
"edit": {
"title": "Edit Ad",
"saved": "Ad saved"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "Invalid account or password",
"AUTH_RATE_LIMITED": "Too many failed login attempts; try again later",
+64 -1
View File
@@ -235,7 +235,8 @@
"users": "账号管理",
"userSync": "用户同步",
"usage": "用量统计",
"embedSlots": "展示位管理"
"embedSlots": "展示位管理",
"ads": "广告位管理"
},
"albums": {
"title": "相册管理",
@@ -922,6 +923,68 @@
"enabledOff": "已停用(configVersion 已递增至 {version},公开端随即 404)"
}
},
"adsManage": {
"list": {
"title": "广告位管理",
"subtitle": "管理相册页广告轮播:图片、跳转链接、投放范围与排序。",
"tableTitle": "广告位列表",
"create": "新建广告位",
"empty": "暂无广告位,点击「新建广告位」创建",
"loadFailed": "广告位列表加载失败",
"retry": "重试",
"colImage": "图片",
"colTitle": "备注名",
"colLink": "跳转链接",
"colScope": "投放范围",
"colEnabled": "启用",
"colActions": "操作",
"scopeGlobal": "全局",
"scopeTargeted": "定向 {n} 相册",
"imageAlt": "广告图",
"actionUp": "上移",
"actionDown": "下移",
"actionEdit": "编辑",
"actionDelete": "删除",
"deleteConfirm": "确定删除广告位「{name}」?删除后不可恢复(数据不保留)。",
"deleted": "广告位已删除"
},
"toast": {
"enabledOn": "广告位已启用",
"enabledOff": "广告位已停用(观众页随即不可见)"
},
"form": {
"imageLabel": "广告图",
"imagePick": "选择图片",
"imageRepick": "重选图片",
"imageHint": "建议 1200×360、不超过 2MB,支持 jpeg/png(不支持 heic:部分浏览器无法显示)",
"imageRequired": "请先上传广告图",
"imageInvalidType": "仅支持 jpeg/png 图片(不支持 heic)",
"imageTooLarge": "图片超过 2MB 上限",
"imageUploadFailed": "图片上传失败:{message}",
"imageStagedTag": "新图待保存",
"linkLabel": "跳转链接",
"linkPlaceholder": "https://example.com/promo",
"linkRequired": "请输入跳转链接",
"linkInvalid": "链接须以 http(s):// 开头且不超过 2048 字符",
"titleLabel": "备注名",
"titlePlaceholder": "如:秋季活动推广(仅后台可见,可空)",
"albumsLabel": "绑定相册",
"albumsPlaceholder": "输入相册标题或 ID 搜索",
"albumsSearching": "搜索中…",
"albumsNoMatch": "无匹配相册",
"albumsHint": "留空=全局投放(全部相册观众页轮播)",
"staleAlbum": "(已失效,可移除)",
"submit": "保存"
},
"create": {
"title": "新建广告位",
"created": "广告位已创建"
},
"edit": {
"title": "编辑广告位",
"saved": "广告位已保存"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "账号或密码错误",
"AUTH_RATE_LIMITED": "登录失败次数过多,请稍后再试",
+13 -1
View File
@@ -1,6 +1,6 @@
/**
* 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计;
* P4b Task 5:展示位管理)
* P4b Task 5:展示位管理;广告位轮播 Task 11:广告位管理)
*/
import { AppRouteRecord } from '@/types/router'
@@ -58,6 +58,18 @@ export const systemRoutes: AppRouteRecord = {
keepAlive: false,
menuGroup: 'workspace'
}
},
{
path: 'ads',
name: 'SystemAds',
component: '/system/ads',
meta: {
title: 'menus.system.ads',
// ri:advertisement-line(Remix 基础集广告语义图标,同 embed-slots 坑位避雷)
icon: 'ri:advertisement-line',
keepAlive: false,
menuGroup: 'workspace'
}
}
]
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 广告位域接口类型(对齐 @photo/contracts openapi.yaml tags [ads]:
* listAdminAds / createAd / updateAd / deleteAd / initAdUpload,广告位轮播
* Task 11 后台侧)
*
* 与 api.d.ts 同用全局 Api 命名空间(declare namespace 自动合并)。
* 契约要点:
* - 列表全量不分页(sortOrder 升序、并列 createdAt 升序;运营量级 <20)
* - imageUrl 为现签 GET(TTL 过期后重拉列表刷新)
* - albumIds null=全局投放(空数组提交服务端归一 null;绑定相册删除不
* 级联清理,后台编辑容失效 ID——设计 §2.1)
* - 换图三字段(imageStagingKey/imageBytes/imageContentType)必须同时提供
* - 删除为硬删(cn 侧数据不保留、无恢复端点;重复删除 404)
*
* @module types/api/ads
*/
declare namespace Api {
/** 广告位域 */
namespace Ads {
/** 广告图 MIME 白名单(契约收窄为 jpeg/png:广告无变体流水线、原图直出,
heic 在 Chromium/Firefox 桌面浏览器不渲染=破图,与照片域白名单分离) */
type ImageContentType = 'image/jpeg' | 'image/png'
/** staging 直传凭证(预签名 URL 模式,与相册资产 UploadCredential 同构) */
interface UploadCredential {
readonly mode: 'presigned'
/** 预签名上传 URL(裸 fetch PUT,不带会话头) */
readonly url: string
readonly method: 'PUT'
/** 上传时必须携带的请求头(如 Content-Type,须与申明一致) */
readonly headers: Record<string, string>
readonly expiresAt: string
}
/** 广告位条目(管理视角) */
interface Item {
id: string
/** 后台备注名(不展示观众;可空) */
title: string | null
/** 版本化终键(换图递增版本,禁止覆盖;readOnly) */
readonly imageObjectKey: string
/** 现签 GET(TTL=SIGNED_URL_TTL_SECONDS;过期重拉列表刷新) */
readonly imageUrl: string
/** 跳转链接(观众点击新开标签) */
linkUrl: string
/** 是否启用;false 公开端不可见(ov 投影行删除) */
enabled: boolean
/** 排序权重(升序,并列按 createdAt 升序决胜) */
sortOrder: number
/** 绑定相册 ID 列表;null=全局投放 */
albumIds: string[] | null
readonly createdAt: string
readonly updatedAt: string
}
/** 广告位列表响应(全量不分页) */
interface ListResponse {
items: Item[]
}
/** 直传初始化载荷(无会话概念,幂等由创建端 copyIfAbsent 承担) */
interface UploadInitPayload {
filename: string
/** 申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB) */
bytes: number
contentType: ImageContentType
}
/** 直传初始化响应(objectKey 创建/更新时引用) */
interface UploadInitResponse {
/** staging 键 photo-live/{env}/ads/staging/{cuid}.{ext} */
readonly objectKey: string
credential: UploadCredential
}
/** 创建载荷(albumIds 未提供或空数组=全局投放;title 1..120 可空) */
interface CreatePayload {
title?: string
linkUrl: string
imageStagingKey: string
imageBytes: number
imageContentType: ImageContentType
sortOrder?: number
albumIds?: string[]
}
/** 更新载荷(部分更新;空请求体 400;albumIds null=转全局、数组=整体替换;换图三字段同时提供) */
interface UpdatePayload {
title?: string
linkUrl?: string
enabled?: boolean
sortOrder?: number
albumIds?: string[] | null
imageStagingKey?: string
imageBytes?: number
imageContentType?: ImageContentType
}
}
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import zhMessages from '@/locales/langs/zh.json'
import enMessages from '@/locales/langs/en.json'
import {
albumIdsChanged,
isValidAdLinkUrl,
normalizeAlbumIds,
planAdjacentMove,
scopeKind
} from './adsLogic'
describe('scopeKind 投放范围分类', () => {
it('null / 空数组 → global;非空 → targeted', () => {
expect(scopeKind(null)).toBe('global')
expect(scopeKind([])).toBe('global')
expect(scopeKind(['album-1'])).toBe('targeted')
expect(scopeKind(['album-1', 'album-2'])).toBe('targeted')
})
})
describe('normalizeAlbumIds 提交前归一化(服务端语义对齐)', () => {
it('null 透传;空数组 → null(全局投放落库归一)', () => {
expect(normalizeAlbumIds(null)).toBeNull()
expect(normalizeAlbumIds([])).toBeNull()
})
it('去重保序;非空结果原样返回', () => {
expect(normalizeAlbumIds(['a', 'a'])).toEqual(['a'])
expect(normalizeAlbumIds(['b', 'a', 'b', 'c'])).toEqual(['b', 'a', 'c'])
expect(normalizeAlbumIds(['a', 'b'])).toEqual(['a', 'b'])
})
})
describe('albumIdsChanged 编辑态变更判定(序敏感深比较)', () => {
it('未变化(同序同元素 / 双 null / 双空归一 null)→ false:软删绑定的未变集合不随 PATCH 提交', () => {
expect(albumIdsChanged(['a', 'b'], ['a', 'b'])).toBe(false)
expect(albumIdsChanged(null, null)).toBe(false)
// 两侧均为 normalizeAlbumIds 归一值:空数组归一 null,双空视同未变化。
expect(albumIdsChanged(normalizeAlbumIds([]), normalizeAlbumIds([]))).toBe(false)
expect(albumIdsChanged(null, normalizeAlbumIds([]))).toBe(false)
})
it('实际变化(增删元素 / null↔数组)→ true:携带 albumIds 整体替换', () => {
expect(albumIdsChanged(['a'], ['a', 'b'])).toBe(true)
expect(albumIdsChanged(['a', 'b'], ['b'])).toBe(true)
expect(albumIdsChanged(null, ['a'])).toBe(true)
expect(albumIdsChanged(['a'], null)).toBe(true)
})
it('数组序敏感:同元素不同序视为变化(服务端整体替换语义,序也是载荷一部分)', () => {
expect(albumIdsChanged(['a', 'b'], ['b', 'a'])).toBe(true)
})
it('软删场景兜底:行值含失效 ID 而表单未动 → false(其它字段仍可保存)', () => {
const rowValue = normalizeAlbumIds(['stale-album', 'live-album'])
const formValue = normalizeAlbumIds(['stale-album', 'live-album'])
expect(albumIdsChanged(rowValue, formValue)).toBe(false)
// 运营在「已失效」标记提示下手动移除 → 变化,提交剩余集合。
expect(
albumIdsChanged(rowValue, normalizeAlbumIds(['live-album']))
).toBe(true)
})
})
describe('isValidAdLinkUrl 表单链接校验(与服务端 pattern 同源)', () => {
it('http/https 前缀放行;其他协议/裸域名拒绝', () => {
expect(isValidAdLinkUrl('https://example.com/promo')).toBe(true)
expect(isValidAdLinkUrl('http://a.cn')).toBe(true)
expect(isValidAdLinkUrl('ftp://example.com')).toBe(false)
expect(isValidAdLinkUrl('example.com/promo')).toBe(false)
expect(isValidAdLinkUrl('')).toBe(false)
})
it('总长 ≤2048 边界:恰 2048 放行、2049 拒绝(契约 maxLength)', () => {
expect(
isValidAdLinkUrl(`https://example.com/${'a'.repeat(2048 - 'https://example.com/'.length)}`)
).toBe(true)
expect(
isValidAdLinkUrl(`https://example.com/${'a'.repeat(2049 - 'https://example.com/'.length)}`)
).toBe(false)
})
})
describe('planAdjacentMove 上移/下移目标值计算', () => {
it('异值互换:双方互换 sortOrder(上移/下移同式)', () => {
expect(planAdjacentMove(5, 8, -1)).toEqual({ rowNext: 8, neighborNext: 5 })
expect(planAdjacentMove(5, 8, 1)).toEqual({ rowNext: 8, neighborNext: 5 })
expect(planAdjacentMove(0, 3, -1)).toEqual({ rowNext: 3, neighborNext: 0 })
})
it('并列 2 行:下移抬本行 +1、上移抬邻居 +1(sortOrder ≥0 契约),另一侧保持', () => {
expect(planAdjacentMove(0, 0, 1)).toEqual({ rowNext: 1, neighborNext: 0 })
expect(planAdjacentMove(0, 0, -1)).toEqual({ rowNext: 0, neighborNext: 1 })
expect(planAdjacentMove(3, 3, 1)).toEqual({ rowNext: 4, neighborNext: 3 })
expect(planAdjacentMove(3, 3, -1)).toEqual({ rowNext: 3, neighborNext: 4 })
})
it('已知局限(文档化):并列簇 ≥3 时单对错开不完备——B 上移后第三方 C 仍并列压过被抬的 A', () => {
// [A,B,C] 全 sortOrder=0(id 序=createdAt 序代理);B 上移 → A=1、B=0、C=0
// → 排序结果 B,C,A(期望 B,A,C):C 越过 A。新建恒为尾部追加(父页传
// max+1)后线上不会出现并列簇,此分支为历史数据兜底保险(dead insurance)。
const plan = planAdjacentMove(0, 0, -1) // row=B, neighbor=A
expect(plan).toEqual({ rowNext: 0, neighborNext: 1 })
const after = [
{ id: 'A', sortOrder: plan.neighborNext },
{ id: 'B', sortOrder: plan.rowNext },
{ id: 'C', sortOrder: 0 }
]
const sorted = [...after].sort((a, b) => a.sortOrder - b.sortOrder || a.id.localeCompare(b.id))
expect(sorted.map((row) => row.id)).toEqual(['B', 'C', 'A'])
})
it('边界 no-op 判定:异值互换双方均变更;并列恰一侧变更(调用方据此跳过 no-op PATCH)', () => {
const swap = planAdjacentMove(2, 7, 1)
expect(swap.rowNext).not.toBe(2)
expect(swap.neighborNext).not.toBe(7)
const bumpDown = planAdjacentMove(7, 7, 1)
expect([bumpDown.rowNext !== 7, bumpDown.neighborNext !== 7].filter(Boolean)).toHaveLength(1)
const bumpUp = planAdjacentMove(7, 7, -1)
expect([bumpUp.rowNext !== 7, bumpUp.neighborNext !== 7].filter(Boolean)).toHaveLength(1)
})
})
describe('adsManage 词条 zh/en 键集一致(对照 embedSlotsManage 先例)', () => {
it('zh/en 键集合一致(adsManage 子树)', () => {
const keysOf = (obj: Record<string, unknown>, prefix = ''): string[] =>
Object.entries(obj).flatMap(([key, value]) =>
typeof value === 'object' && value !== null
? keysOf(value as Record<string, unknown>, `${prefix}${key}.`)
: [`${prefix}${key}`]
)
expect(keysOf(enMessages.adsManage as Record<string, unknown>)).toEqual(
keysOf(zhMessages.adsManage as Record<string, unknown>)
)
})
})
+73
View File
@@ -0,0 +1,73 @@
/**
* 广告位管理纯逻辑(广告位轮播 Task 11)——无 Vue 依赖,vitest 直测。
*
* - scopeKind/normalizeAlbumIds 对齐服务端归一语义:albumIds null=全局投放,
* 空数组提交落库归一 null(AdCreateRequestDto 注释口径)
* - albumIdsChanged 编辑态变更判定(序敏感深比较):仅变化时随 PATCH 携带
* albumIds——软删绑定的未变集合不再提交,避免服务端存在性校验 400 挡住
* 其它字段的保存
* - isValidAdLinkUrl 与服务端 pattern 同源(dto.ts LINK_URL_PATTERN:
* ^https?:// 前缀 + 契约 maxLength 2048)
* - planAdjacentMove 上移/下移目标值计算:新建恒为尾部追加(父页传
* max+1,空列表=0),线上 sortOrder 全序无并列;并列分支为历史数据
* 兜底保险(≥3 并列簇的局限见 spec 文档化用例)
*/
/** 投放范围:null 或空数组 → 'global';非空 → 'targeted'。 */
export function scopeKind(albumIds: string[] | null): 'global' | 'targeted' {
return albumIds === null || albumIds.length === 0 ? 'global' : 'targeted'
}
/** 提交前归一化:去重 + 空数组→null(服务端语义对齐)。 */
export function normalizeAlbumIds(v: string[] | null): string[] | null {
if (v === null) return null
const uniq = [...new Set(v)]
return uniq.length === 0 ? null : uniq
}
/**
* 编辑态 albumIds 变更判定:两侧均为 normalizeAlbumIds 归一值(去重/空→
* null),数组序敏感逐位比较——仅实际变化时随 PATCH 携带。未变化的集合
* 不提交:绑定相册被软删后服务端存在性校验会对整组 albumIds 400,跳过
* 提交让其它字段(title/linkUrl/换图)仍可保存(失效 ID 由表单选项的
* 「已失效」标记提示运营手动移除)。
*/
export function albumIdsChanged(
before: string[] | null,
after: string[] | null
): boolean {
if (before === null || after === null) return before !== after
return !(
before.length === after.length && before.every((id, i) => id === after[i])
)
}
/** 表单链接校验(与服务端 pattern 同源:^https?://,总长 ≤2048)。 */
export function isValidAdLinkUrl(u: string): boolean {
return /^https?:\/\//.test(u) && u.length <= 2048
}
/** 上移/下移的目标 sortOrder(值未变的一侧调用方跳过 no-op PATCH) */
export interface AdjacentMovePlan {
rowNext: number
neighborNext: number
}
/**
* 邻位移动目标值:异值直接互换;并列(相等)时错开一档——下移抬本行
* +1、上移抬邻居 +1(契约 sortOrder ≥0,不可产出 -1),另一侧保持原值。
* 已知局限:并列簇 ≥3 时单对错开不完备(第三方仍并列压位,见 spec
* 文档化用例);新建恒为尾部追加(max+1)后并列不会在线上出现。
*/
export function planAdjacentMove(
rowSortOrder: number,
neighborSortOrder: number,
dir: -1 | 1
): AdjacentMovePlan {
if (rowSortOrder !== neighborSortOrder) {
return { rowNext: neighborSortOrder, neighborNext: rowSortOrder }
}
return dir === 1
? { rowNext: neighborSortOrder + 1, neighborNext: neighborSortOrder }
: { rowNext: rowSortOrder, neighborNext: neighborSortOrder + 1 }
}
@@ -175,7 +175,7 @@
async function searchUsers(prefix: string) {
query.value = prefix
options.value = []
if (prefix.trim().length < 2) return
if (prefix.trim().length < 1) return
searching.value = true
try {
const result = await fetchLookupUsers(prefix.trim())
@@ -112,7 +112,7 @@
async function searchUsers(prefix: string) {
query.value = prefix
options.value = []
if (prefix.trim().length < 2) return
if (prefix.trim().length < 1) return
searching.value = true
try {
const result = await fetchLookupUsers(prefix.trim())
@@ -0,0 +1,389 @@
<!-- 广告位新建/编辑对话框(广告位轮播 Task 11):广告图直传(init 签发
staging 凭证 → 裸 fetch PUT 预签名 URL,Content-Type 与申明一致——无
complete 步,创建/更新端点核验后转存终键)+ 跳转链接(isValidAdLinkUrl
与服务端 pattern 同源)+ 备注名(≤120 可空)+ 绑定相册(多选远程搜索,
/v1/albums 游标拉取 + 本地过滤;留空=全局投放)。编辑态换图才带 image
三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局),且仅
实际变化时随 PATCH 携带(albumIdsChanged 序敏感比较——软删绑定的
未变集合不提交,避免服务端存在性校验 400 挡住其它字段的保存)。 -->
<template>
<ElDialog
v-model="visible"
:title="ad === null ? $t('adsManage.create.title') : $t('adsManage.edit.title')"
width="640px"
destroy-on-close
data-test="ad-form-dialog"
>
<ElForm ref="formRef" label-width="110px" :model="form" :rules="rules">
<ElFormItem :label="$t('adsManage.form.imageLabel')">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<ElButton :loading="uploading" data-test="ad-form-pick" @click="fileInput?.click()">
{{
staging || ad !== null
? $t('adsManage.form.imageRepick')
: $t('adsManage.form.imagePick')
}}
</ElButton>
<span class="text-gray-400 text-sm">{{ $t('adsManage.form.imageHint') }}</span>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png"
class="hidden"
data-test="ad-form-file-input"
@change="onFileChange"
/>
</div>
<!-- 预览:新选文件用本地对象 URL;未换图的编辑态沿用行内现签 URL -->
<div v-if="previewSrc" class="flex items-center gap-2" data-test="ad-form-preview">
<img
:src="previewSrc"
:alt="$t('adsManage.list.imageAlt')"
class="ads-form-preview-img"
data-test="ad-form-preview-img"
/>
<ElTag v-if="staging" type="warning" effect="light" data-test="ad-form-staged-tag">
{{ $t('adsManage.form.imageStagedTag') }}
</ElTag>
</div>
<div v-if="uploadError" class="text-red-500 text-sm" data-test="ad-form-upload-error">
{{ uploadError }}
</div>
</div>
</ElFormItem>
<ElFormItem :label="$t('adsManage.form.linkLabel')" prop="linkUrl">
<ElInput
v-model="form.linkUrl"
:placeholder="$t('adsManage.form.linkPlaceholder')"
maxlength="2048"
data-test="ad-form-link"
/>
</ElFormItem>
<ElFormItem :label="$t('adsManage.form.titleLabel')">
<ElInput
v-model="form.title"
:placeholder="$t('adsManage.form.titlePlaceholder')"
maxlength="120"
show-word-limit
data-test="ad-form-title"
/>
</ElFormItem>
<ElFormItem :label="$t('adsManage.form.albumsLabel')">
<ElSelect
v-model="form.albumIds"
multiple
filterable
remote
clearable
:remote-method="searchAlbums"
:loading="searching"
:placeholder="$t('adsManage.form.albumsPlaceholder')"
style="width: 100%"
data-test="ad-form-albums"
>
<ElOption
v-for="album in options"
:key="album.id"
:value="album.id"
:label="album.label"
:data-test="`ad-form-album-option-${album.id}`"
/>
<template #empty>
<span class="px-4 py-2 text-gray-400 text-sm" data-test="ad-form-albums-empty">
{{
searching ? t('adsManage.form.albumsSearching') : t('adsManage.form.albumsNoMatch')
}}
</span>
</template>
</ElSelect>
<div class="text-gray-400 text-xs mt-1">{{ $t('adsManage.form.albumsHint') }}</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton data-test="ad-form-cancel" @click="visible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="submitting" data-test="ad-form-submit" @click="submit">
{{ $t('adsManage.form.submit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { fetchAlbumList } from '@/api/albums'
import { fetchCreateAd, fetchInitAdUpload, fetchUpdateAd } from '@/api/ads'
import { albumIdsChanged, isValidAdLinkUrl, normalizeAlbumIds } from '@/utils/ads/adsLogic'
/** 广告图字节上限(契约 AD_UPLOAD_MAX_BYTES=2MB,独立于照片域 50MB) */
const AD_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
/** 广告图 MIME 白名单(契约收窄为 jpeg/png:广告无变体流水线、原图直出,
heic 桌面浏览器不渲染=破图——与照片域白名单分离) */
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png'])
/** 相册搜索拉取页数上限(每页 50:列表端点无标题过滤,本地匹配;管理
规模有界,5 页 ≈ 250 本封顶,超出以 ID 兜底标签回显) */
const MAX_SEARCH_PAGES = 5
const props = defineProps<{
/** 编辑目标(null = 新建) */
ad: Api.Ads.Item | null
/** 新建追加的排序权重(父页 max+1;空列表=0)——尾部追加保证全序,
从源头杜绝并列簇 */
nextSortOrder: number
}>()
const emit = defineEmits<{
/** 新建/编辑成功(父页重拉列表对齐排序与现签 imageUrl) */
saved: [ad: Api.Ads.Item]
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
const formRef = ref<FormInstance | null>(null)
const submitting = ref(false)
const form = ref({
linkUrl: '',
title: '',
albumIds: [] as string[]
})
const rules = computed<FormRules>(() => ({
linkUrl: [
{ required: true, message: t('adsManage.form.linkRequired'), trigger: 'blur' },
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
callback(isValidAdLinkUrl(value) ? undefined : new Error(t('adsManage.form.linkInvalid')))
},
trigger: 'blur'
}
]
}))
// ── 广告图直传:init → 裸 fetch PUT staging(无 complete;创建/更新核验)──
interface StagingImage {
objectKey: string
bytes: number
contentType: Api.Ads.ImageContentType
}
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const uploadError = ref('')
const staging = ref<StagingImage | null>(null)
/** 新选文件预览(本地对象 URL;替换时回收,对话框重开时回收) */
const previewObjectUrl = ref<string | null>(null)
const previewSrc = computed(() => previewObjectUrl.value ?? props.ad?.imageUrl ?? null)
function releasePreview(): void {
if (previewObjectUrl.value !== null) {
URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = null
}
}
// 兜底:组件卸载时回收对象 URL(正常路径关闭对话框已回收)
onUnmounted(releasePreview)
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// 允许同一文件连续重选:change 处理完即复位
input.value = ''
if (!file) return
if (!CONTENT_TYPES.has(file.type)) {
uploadError.value = t('adsManage.form.imageInvalidType')
return
}
if (file.size > AD_UPLOAD_MAX_BYTES) {
uploadError.value = t('adsManage.form.imageTooLarge')
return
}
uploadError.value = ''
uploading.value = true
try {
// ① init:staging 键 + 预签名凭证(失败重试重新 init,staging 键不复用)
const init = await fetchInitAdUpload({
filename: file.name,
bytes: file.size,
contentType: file.type as Api.Ads.ImageContentType
})
// ② PUT staging:裸 fetch(预签名 URL 自带凭证;headers 必须原样携带)
const response = await fetch(init.credential.url, {
method: init.credential.method,
headers: init.credential.headers,
body: file
})
if (!response.ok) throw new Error(`PUT ${response.status}`)
staging.value = {
objectKey: init.objectKey,
bytes: file.size,
contentType: file.type as Api.Ads.ImageContentType
}
releasePreview()
previewObjectUrl.value = URL.createObjectURL(file)
} catch (e) {
uploadError.value = t('adsManage.form.imageUploadFailed', { message: (e as Error).message })
} finally {
uploading.value = false
}
}
// ── 绑定相册:多选远程搜索(/v1/albums 分页拉取 + 本地按标题/ID 过滤)──
interface AlbumOption {
id: string
label: string
}
const options = ref<AlbumOption[]>([])
const searching = ref(false)
/** 列表标题按 defaultLocale 解析(回退第一条翻译,同相册管理页) */
function titleOf(album: Api.Album.Summary): string {
const preferred = album.translations.find((tr) => tr.locale === album.defaultLocale)
return preferred?.title ?? album.translations[0]?.title ?? album.id
}
async function searchAlbums(query: string): Promise<void> {
const keyword = query.trim().toLowerCase()
searching.value = true
try {
const all: Api.Album.Summary[] = []
let cursor: string | undefined
for (let page = 0; page < MAX_SEARCH_PAGES; page++) {
const res = await fetchAlbumList({ cursor, limit: 50 })
all.push(...res.items)
if (!res.hasMore || res.nextCursor === null) break
cursor = res.nextCursor
}
options.value =
keyword === ''
? all.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
: all
.filter(
(album) =>
titleOf(album).toLowerCase().includes(keyword) ||
album.id.toLowerCase().includes(keyword)
)
.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
} catch {
// 拉取失败静默吸收:http 层已统一弹 toast(ElMessage),此处仅保持
// 旧选项(finally 复位 searching),避免未处理拒绝穿透 ElSelect。
} finally {
searching.value = false
}
}
/** 打开即预载选项(新建态同样预载——避免未输入时误显「无匹配相册」,
对照 SlotBindDialog 打开即 searchAlbums('') 先例);编辑态已绑定 ID
超出拉取上限或相册已删除的以裸 ID 兜底并加「已失效」后缀(绑定不
级联清理,容失效)——提示运营可手动移除;不移除也能保存其它字段
(未变化的 albumIds 不随 PATCH 提交,见 submit) */
async function preloadOptions(): Promise<void> {
await searchAlbums('')
if (props.ad === null) return
const ids = props.ad.albumIds ?? []
const known = new Set(options.value.map((option) => option.id))
const missing = ids
.filter((id) => !known.has(id))
.map((id) => ({ id, label: `${id}${t('adsManage.form.staleAlbum')}` }))
if (missing.length) options.value = [...missing, ...options.value]
}
watch(visible, (open) => {
if (!open) {
releasePreview()
return
}
uploadError.value = ''
staging.value = null
releasePreview()
if (props.ad !== null) {
form.value.linkUrl = props.ad.linkUrl
form.value.title = props.ad.title ?? ''
form.value.albumIds = props.ad.albumIds ?? []
} else {
form.value.linkUrl = ''
form.value.title = ''
form.value.albumIds = []
}
options.value = []
void preloadOptions()
})
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (valid === false) return
// 新建必须先传图;编辑态未换图沿用原图(不带 image 三字段)
if (props.ad === null && staging.value === null) {
uploadError.value = t('adsManage.form.imageRequired')
return
}
uploadError.value = ''
const title = form.value.title.trim()
const albumIds = normalizeAlbumIds(form.value.albumIds)
// 编辑态仅 albumIds 实际变化时携带(与行值序敏感深比较,两侧同为
// normalizeAlbumIds 归一值):软删绑定的未变集合不再提交——服务端对
// 整组 albumIds 做存在性校验会 400,跳过提交让其它字段仍可保存。
const albumIdsDelta =
props.ad !== null && !albumIdsChanged(normalizeAlbumIds(props.ad.albumIds), albumIds)
? {}
: { albumIds }
submitting.value = true
try {
const saved =
props.ad !== null
? await fetchUpdateAd(props.ad.id, {
// title 服务端 @IsNotEmpty:空串不可提交,清空=保持原值
...(title === '' ? {} : { title }),
linkUrl: form.value.linkUrl.trim(),
...albumIdsDelta,
...(staging.value !== null
? {
imageStagingKey: staging.value.objectKey,
imageBytes: staging.value.bytes,
imageContentType: staging.value.contentType
}
: {})
})
: await fetchCreateAd({
...(title === '' ? {} : { title }),
linkUrl: form.value.linkUrl.trim(),
imageStagingKey: staging.value!.objectKey,
imageBytes: staging.value!.bytes,
imageContentType: staging.value!.contentType,
// 尾部追加(父页 max+1):sortOrder 全序,杜绝并列簇
sortOrder: props.nextSortOrder,
albumIds: albumIds ?? undefined
})
ElMessage.success(
props.ad !== null ? t('adsManage.edit.saved') : t('adsManage.create.created')
)
visible.value = false
emit('saved', saved)
} catch {
// 错误已由 http 层统一弹出(ElMessage),此处仅吸收避免未处理拒绝
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.ads-form-preview-img {
width: 160px;
height: 80px;
object-fit: cover;
display: block;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<!-- 广告位管理页(广告位轮播 Task 11):全量列表(缩略图/备注名/跳转链接/
投放范围/启停开关)+ 新建/编辑对话框 + 上移/下移排序 + 硬删除。列表按
sortOrder 升序返回,imageUrl 为现签 GET——过期后重拉本列表刷新。启停/
排序/删除均直接变更观众端可见内容(ov 投影实时跟随)。 -->
<template>
<div class="ui06-page">
<!-- 页头(示例 13 .page-heading) -->
<div class="ui06-page-heading">
<div>
<h1>{{ $t('adsManage.list.title') }}</h1>
<p>{{ $t('adsManage.list.subtitle') }}</p>
</div>
<div class="actions">
<ElButton type="primary" data-test="ad-create-btn" @click="openCreate">
{{ $t('adsManage.list.create') }}
</ElButton>
</div>
</div>
<ElCard data-test="ads-card">
<template #header>
<div class="flex items-center justify-between">
<span class="ui06-card-title">{{ $t('adsManage.list.tableTitle') }}</span>
</div>
</template>
<ElAlert v-if="error && !items.length" type="error" :closable="false" data-test="ads-error">
<div class="flex items-center gap-2">
<span>{{ $t('adsManage.list.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="ads-retry" @click="loadFirst()">
{{ $t('adsManage.list.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="ads-table">
<ElTableColumn :label="$t('adsManage.list.colImage')" width="130">
<template #default="{ row }">
<img
:src="row.imageUrl"
:alt="$t('adsManage.list.imageAlt')"
class="ads-thumb"
data-test="ad-image"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colTitle')" min-width="150">
<template #default="{ row }">
<span class="font-medium" data-test="ad-title">{{ row.title ?? '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colLink')" min-width="220">
<template #default="{ row }">
<span class="ads-link" :title="row.linkUrl" data-test="ad-link">
{{ row.linkUrl }}
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colScope')" width="130">
<template #default="{ row }">
<ElTag
v-if="scopeKind(row.albumIds) === 'global'"
type="success"
effect="plain"
data-test="ad-scope-global"
>
{{ $t('adsManage.list.scopeGlobal') }}
</ElTag>
<ElTag v-else type="warning" effect="plain" data-test="ad-scope-targeted">
{{ $t('adsManage.list.scopeTargeted', row.albumIds.length) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colEnabled')" width="90">
<template #default="{ row }">
<ElSwitch
:model-value="row.enabled"
:loading="togglingId === row.id"
:disabled="togglingId !== null && togglingId !== row.id"
data-test="ad-enabled-switch"
@change="toggleEnabled(row as Api.Ads.Item)"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colActions')" width="260" fixed="right">
<template #default="{ row, $index }">
<ElButton
link
type="primary"
:disabled="$index === 0 || moving"
data-test="ad-up-btn"
@click="move($index, -1)"
>
{{ $t('adsManage.list.actionUp') }}
</ElButton>
<ElButton
link
type="primary"
:disabled="$index === items.length - 1 || moving"
data-test="ad-down-btn"
@click="move($index, 1)"
>
{{ $t('adsManage.list.actionDown') }}
</ElButton>
<ElButton link type="primary" data-test="ad-edit-btn" @click="openEdit(row)">
{{ $t('adsManage.list.actionEdit') }}
</ElButton>
<ElButton link type="danger" data-test="ad-delete-btn" @click="remove(row)">
{{ $t('adsManage.list.actionDelete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('adsManage.list.empty')" />
</template>
</ElTable>
</ElCard>
<AdsFormDialog
v-model="formVisible"
:ad="editing"
:next-sort-order="nextSortOrder"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdsFormDialog from './AdsFormDialog.vue'
import { fetchAds, fetchDeleteAd, fetchUpdateAd } from '@/api/ads'
import { planAdjacentMove, scopeKind } from '@/utils/ads/adsLogic'
defineOptions({ name: 'SystemAds' })
const { t } = useI18n()
// ── 列表:契约全量不分页(sortOrder 升序),整页替换式加载 ──
const items = ref<Api.Ads.Item[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
async function loadFirst(): Promise<void> {
loading.value = true
error.value = null
try {
const res = await fetchAds()
items.value = res.items
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// ── 启停开关:PATCH enabled(false 公开端不可见)──
const togglingId = ref<string | null>(null)
async function toggleEnabled(row: Api.Ads.Item): Promise<void> {
togglingId.value = row.id
try {
const updated = await fetchUpdateAd(row.id, { enabled: !row.enabled })
replaceById(updated)
ElMessage.success(
updated.enabled ? t('adsManage.toast.enabledOn') : t('adsManage.toast.enabledOff')
)
} catch {
// 失败回滚+重拉:ElSwitch 单向绑定行数据未变即回弹,重拉对齐服务端状态
await loadFirst()
} finally {
togglingId.value = null
}
}
function replaceById(next: Api.Ads.Item): void {
const index = items.value.findIndex((item) => item.id === next.id)
if (index >= 0) items.value.splice(index, 1, next)
}
// ── 上移/下移:与相邻行互换 sortOrder(两次 PATCH 后重拉列表对齐回显)──
const moving = ref(false)
async function move(index: number, dir: -1 | 1): Promise<void> {
const target = index + dir
if (moving.value || target < 0 || target >= items.value.length) return
const row = items.value[index]
const neighbor = items.value[target]
// 目标值计算收敛于纯函数 planAdjacentMove(异值互换/并列错开,含
// ≥3 并列簇局限文档化;值未变的一侧跳过 no-op PATCH)
const plan = planAdjacentMove(row.sortOrder, neighbor.sortOrder, dir)
moving.value = true
try {
if (plan.rowNext !== row.sortOrder) await fetchUpdateAd(row.id, { sortOrder: plan.rowNext })
if (plan.neighborNext !== neighbor.sortOrder) {
await fetchUpdateAd(neighbor.id, { sortOrder: plan.neighborNext })
}
await loadFirst()
} catch {
// 错误已由 http 层统一弹出;重拉恢复服务端真实顺序
await loadFirst()
} finally {
moving.value = false
}
}
// ── 新建 / 编辑 ──
const formVisible = ref(false)
const editing = ref<Api.Ads.Item | null>(null)
/** 新建追加位:现有最大 sortOrder + 1(空列表=0)——尾部追加保证全序,
从源头杜绝并列簇(planAdjacentMove 并列分支仅为兜底保险) */
const nextSortOrder = computed(
() => items.value.reduce((max, item) => Math.max(max, item.sortOrder), -1) + 1
)
function openCreate(): void {
editing.value = null
formVisible.value = true
}
function openEdit(row: Api.Ads.Item): void {
editing.value = row
formVisible.value = true
}
/** 新建/编辑成功 → 重拉(排序位置与现签 imageUrl 一并对齐) */
function onSaved(): void {
void loadFirst()
}
/** 硬删除:确认(数据不保留、不可恢复)→ DELETE → 重拉列表 */
async function remove(row: Api.Ads.Item): Promise<void> {
try {
await ElMessageBox.confirm(
t('adsManage.list.deleteConfirm', { name: row.title ?? row.id }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteAd(row.id)
ElMessage.success(t('adsManage.list.deleted'))
loadFirst()
}
onMounted(() => {
void loadFirst()
})
</script>
<style scoped>
.ads-thumb {
width: 96px;
height: 48px;
object-fit: cover;
display: block;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
.ads-link {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
color: var(--el-text-color-regular);
}
</style>
+6 -2
View File
@@ -65,12 +65,16 @@ kotlin {
dependencies {
// P3 Task 7 后台上传桥接:WorkManager(expedited + dataSync 前台服务)
// + OkHttp 直传 presign PUT(独立 4.12,与 Flutter 引擎无耦合)。
// + OkHttp 直传 presign PUT(声明 4.12.0,Gradle 冲突仲裁实际解析
// 5.2.1——fluwx 传递依赖胜出;独立实例、与 Flutter 引擎无耦合不变)。
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.core:core-ktx:1.15.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
// mockwebserver 须对齐实际解析的 okhttp:fluwx 传递 okhttp 5.x
// (冲突调解覆盖本模块 4.12 声明),4.12 的 mockwebserver 在 5.x
// 运行时缺 okhttp3.internal.Util → NoClassDefFoundError。
testImplementation("com.squareup.okhttp3:mockwebserver:5.2.1")
}
flutter {
@@ -9,6 +9,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 应用内相册选择器(wechat_assets_picker/photo_manager):图片读取。
READ_MEDIA_VISUAL_USER_SELECTED:API 34 引入、无上限,勿加
maxSdkVersion——加了会在 Android 15+(targetSdk 36)被裁掉,
部分访问的「选择照片」入口随之消失,picker limited/manage 流程失效。 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<application
android:usesCleartextTraffic="true"
android:label="photo_live"
@@ -20,11 +30,14 @@
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
<!-- 连拍页按竖屏设计(顶部常驻层/底部控制位与预览宽高交换均假设
纵向),横屏下预览比例与控制层错位;锁定竖屏。 -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:screenOrientation="portrait"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -38,11 +38,17 @@ sealed class PutOutcome {
* - 请求头:严格携带 Dart 传入的 credential.headers(Content-Type 经
* RequestBody.contentType 表达,Content-Length 由 okhttp 按声明长度
* 发送,二者不重复注入);
* - 进度:写 body 驱动 [onProgress](已发字节/总字节,IO 线程回调);
* - OkHttp 独立实例(4.12,与 Flutter 引擎无耦合;连接失败内置静默重试)。
* - 进度:写 body 驱动 [onProgress](已发字节/总字节,IO 线程回调;
* 256KB 步长节流,末块必报 sent==total);
* - OkHttp 独立实例(声明 4.12.0,Gradle 冲突仲裁实际解析 5.2.1——
* fluwx 传递依赖胜出;独立实例、与 Flutter 引擎无耦合的性质不变,
* 连接失败内置静默重试)。
*/
object UploadPutExecutor {
/** 进度回调最小步长(字节);末块必报。 */
private const val PROGRESS_STEP_BYTES = 256L * 1024
private val defaultClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(15))
.writeTimeout(Duration.ofMinutes(3))
@@ -75,6 +81,7 @@ object UploadPutExecutor {
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
var sent = 0L
var lastReported = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) {
@@ -82,7 +89,10 @@ object UploadPutExecutor {
}
sink.write(buffer, 0, read)
sent += read
onProgress?.invoke(sent, total)
if (sent - lastReported >= PROGRESS_STEP_BYTES || sent == total) {
lastReported = sent
onProgress?.invoke(sent, total)
}
}
sink.flush()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1A73E8</color>
</resources>
@@ -14,7 +14,7 @@ import org.junit.Test
* UploadPutExecutor 结局分类测试(P3 计划 Task 7;MockWebServer 驱动):
* 200→Success(etag)、500→Retryable(server)、403→RenewRequired、
* 4xx→HttpFail、连接失败→Retryable(network)、缺文件→SourceMissing、
* 请求头透传与进度回调。
* 请求头透传与进度回调(单调性 + 256KB 步长节流/末块必报)。
*/
class UploadPutExecutorTest {
@@ -147,6 +147,38 @@ class UploadPutExecutorTest {
samples.forEach { assertEquals(file.length(), it.second) }
}
@Test
fun `progress callback throttles to 256KB steps and reports final chunk`() {
// 700KB 夹具:64KB 写循环下未节流会有 ~11 次回调;节流后仅在
// 累计 ≥256KB 或末块(sent==total)时上报。
server.enqueue(MockResponse().setResponseCode(200))
val big = Files.createTempFile("upload-put-big", ".bin")
big.toFile().deleteOnExit()
val bigFile = big.toFile()
Files.write(big, ByteArray(700_000) { (it % 251).toByte() })
val samples = mutableListOf<Pair<Long, Long>>()
UploadPutExecutor.execute(
url = server.url("/staging/x.jpg").toString(),
method = "PUT",
headers = mapOf("Content-Type" to "image/jpeg"),
filePath = bigFile.absolutePath,
client = client,
) { sent, total -> samples.add(sent to total) }
assertTrue(samples.isNotEmpty())
// 至少一次 ≥256KB 步长回调 + 末块(防过度节流只报终值的盲区)。
assertTrue(samples.size >= 2)
// 末块必报:sent == total。
assertEquals(bigFile.length(), samples.last().first)
assertEquals(bigFile.length(), samples.last().second)
// 步长节流:相邻回调字节差 ≥256KB(末次为余量,可小于步长)。
val step = 256L * 1024
samples.zipWithNext().dropLast(1).forEach { (prev, next) ->
assertTrue(next.first - prev.first >= step)
}
// 节流生效:回调次数少于 64KB 写循环次数。
assertTrue(samples.size < (bigFile.length() / (64 * 1024)).toInt())
}
@Test
fun `blank method defaults to PUT and uppercases`() {
assertEquals("PUT", UploadPutExecutor.normalizeMethod(""))
Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

@@ -93,7 +93,10 @@ class ItAppHarness {
ref.onDispose(() => client.close());
return client;
}),
multiImagePickerProvider.overrideWithValue(() async => picker.consume()),
multiImagePickerProvider.overrideWithValue(
(context, {required accountId, required albumId}) async =>
picker.consume(),
),
singleImagePickerProvider.overrideWithValue(() async {
final paths = picker.consume();
return paths.isEmpty ? null : paths.first;
+70
View File
@@ -25,6 +25,17 @@ import 'error_codes.dart';
import 'models/cursor_page.dart';
import 'session_store.dart';
/// 查重预检请求条目(契约 UploadDuplicateCheckItem;filename 仅元数据——
/// 服务端查重谓词只看 bytes,与 initUpload ③.5 逐字一致)。
typedef UploadDuplicateCheckItem = ({String filename, int bytes});
/// 查重预检命中(契约 UploadDuplicateCheckDuplicate;index 已从分块局部
/// 下标换算为对入参列表的全局下标)。
typedef UploadDuplicateHit = ({int index, int bytes, DateTime uploadedAt});
/// 单批查重条目上限(契约 items maxItems=500;客户端按此分块顺序调用)。
const int kUploadDuplicateCheckChunkSize = 500;
class ApiClient {
/// [dio] 可注入测试替身(自定义 HttpClientAdapter);[baseUrl] 缺省
/// 取 [ApiConfig.baseUrl](--dart-define API_BASE 覆盖)。
@@ -137,6 +148,18 @@ class ApiClient {
final Object? message = data['message'];
final Object? requestId = data['requestId'];
if (code is String && message is String) {
// P0-1 服务端查重拒绝(Task 8):409 的 wire code 复用
// VALIDATION_FAILED,按 message=PHOTO_DUPLICATE 识别归一——所有
// 调用方(上传引擎分类/l10n 文案)据此走查重分支;契约日后若设
// 独立 code,此处识别与透传均兼容。
if (status == 409 && message.contains(ErrorCodes.photoDuplicate)) {
return AppException(
code: ErrorCodes.photoDuplicate,
message: message,
requestId: requestId is String ? requestId : null,
statusCode: status,
);
}
return AppException(
code: code,
message: message,
@@ -556,6 +579,53 @@ class ApiClient {
limit: limit,
);
/// operationId `checkUploadDuplicates`
/// (POST /uploads/duplicates/check,批量只读预检)。
///
/// 按 [kUploadDuplicateCheckChunkSize] 顺序分块调用(契约单批上限 500;
/// 块间串行防瞬时并发放大);各块命中的分块局部下标换算为对 [items] 的
/// 全局下标后合并返回。空列表直接返回空(契约 items minItems=1,不发
/// 请求)。错误经 [_send] 归一为 [AppException](404/403/400/401 见契约)。
Future<List<UploadDuplicateHit>> checkUploadDuplicates(
String albumId,
List<UploadDuplicateCheckItem> items,
) async {
if (items.isEmpty) return const <UploadDuplicateHit>[];
final hits = <UploadDuplicateHit>[];
for (
var start = 0;
start < items.length;
start += kUploadDuplicateCheckChunkSize
) {
final chunk = items
.skip(start)
.take(kUploadDuplicateCheckChunkSize)
.toList();
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/uploads/duplicates/check',
data: {
'albumId': albumId,
'items': [
for (final item in chunk)
{'filename': item.filename, 'bytes': item.bytes},
],
},
),
);
final duplicates = (resp.data!['duplicates'] as List<dynamic>)
.cast<Map<String, dynamic>>();
for (final d in duplicates) {
hits.add((
index: start + (d['index'] as int),
bytes: d['bytes'] as int,
uploadedAt: DateTime.parse(d['uploadedAt'] as String),
));
}
}
return hits;
}
// ── photos(契约 tag: photos)──
/// operationId `listAlbumPhotos`
+6
View File
@@ -68,6 +68,12 @@ abstract final class ErrorCodes {
// ── 以下为客户端本地伪码(契约外,见类注释) ──
/// 照片疑似重复(409,服务端查重拒绝)。P0-1 契约现状:wire 体 code
/// 复用 VALIDATION_FAILED、靠 message=PHOTO_DUPLICATE 识别——ApiClient
/// 归一为本码(若契约日后设独立 code,归一自动兼容)。skip 服务端查重
/// 的 force 请求仅 owner/admin 生效(服务端裁决)。
static const String photoDuplicate = 'PHOTO_DUPLICATE';
/// 网络连接失败(DNS/握手/断网等连接层错误)。
static const String networkError = 'NETWORK_ERROR';
+2
View File
@@ -26,6 +26,8 @@ extension AppLocalizationsErrorX on AppLocalizations {
return errorAuthTokenExpired;
case ErrorCodes.validationFailed:
return errorValidation;
case ErrorCodes.photoDuplicate: // P0-1 服务端查重拒绝(409 归一码)。
return errorPhotoDuplicate;
case ErrorCodes.networkError:
return errorNetwork;
case ErrorCodes.networkTimeout:
+43
View File
@@ -0,0 +1,43 @@
/// 网络连接状态 Provider(P0-4:离线感知)。
///
/// connectivity_plus 监听网络变化 → 全局 Stream → UI/引擎消费:
/// - **UI**:顶部黄条「当前无网络连接」(HomePage 或全局 Scaffold 叠加)
/// - **上传引擎**:离线 → 暂停再出队;恢复 → 自动重试
///
/// 三档:online / offline / unknown(初始/权限不足)。
/// offline 只暂停再出队,**不中断在途传输**——避免假离线(VPN 等)打断上传。
library;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 网络可用性(三档)。
enum NetworkAvailability { online, offline, unknown }
/// 网络状态 Provider(StreamProvider——connectivity_plus Stream 直通)。
final networkStatusProvider = StreamProvider<NetworkAvailability>((ref) async* {
final connectivity = Connectivity();
try {
// 初始值(checkConnectivity 是 Future<List<ConnectivityResult>>)。
final initial = await connectivity.checkConnectivity();
yield _map(initial);
// 后续变化(onConnectivityChanged 是 Stream<List<ConnectivityResult>>)。
yield* connectivity.onConnectivityChanged.map(_map);
} on Object {
// 权限不足/平台不支持——报 unknown(不阻塞 App)。
yield NetworkAvailability.unknown;
}
});
NetworkAvailability _map(List<ConnectivityResult> results) {
if (results.contains(ConnectivityResult.none)) {
return NetworkAvailability.offline;
}
if (results.isEmpty) return NetworkAvailability.unknown;
return NetworkAvailability.online;
}
/// 便捷读取:当前是否有网(unknown 视为有网——假阴性代价低于假阳性)。
bool isNetworkOnline(NetworkAvailability? status) {
return status != NetworkAvailability.offline;
}
@@ -57,7 +57,8 @@ String formatAlbumDateTime(
String locale,
) {
final zoned = tryToAlbumZone(utcInstant, timeZone) ?? utcInstant.toUtc();
return DateFormat('y/M/d HH:mm', locale).format(zoned);
// UI06(用户需求 2026-09-24):相册日期一律纯日期显示(不带 HH:mm)。
return DateFormat('y/M/d', locale).format(zoned);
}
/// 相册墙钟(表单里选择的日期时间,设备本地时区标记的 naive 值)→
@@ -15,6 +15,7 @@ library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api_client.dart';
import '../../core/network_status.dart';
import 'providers.dart';
import 'upload_engine.dart';
@@ -49,14 +50,29 @@ final sessionAccountProvider =
/// 引擎生命周期绑定:账号在场 → start;离场 → stop(任务保留库内)。
///
/// P0-4 离线感知:**离线 → stop(暂停再出队,在途不受影响)**;
/// **恢复 → start + onNetworkRestored(立即重试 waitingNetwork)**。
/// 引擎与账号/网络三重绑定:有账号且有网才出队,任一失去即停。
///
/// 返回当前 scope 账号(null=引擎停止);仅被 App 壳 watch 时生效
/// (Provider 惰性求值)。
final uploadEngineScopeProvider = Provider<String?>((ref) {
final account = ref.watch(sessionAccountProvider).value;
final engine = ref.watch(uploadEngineProvider);
if (account != null) {
// P0-4:网络状态(offline 时不出队;unknown 视为有网)。
final network = ref.watch(networkStatusProvider).value;
final online = isNetworkOnline(network);
if (account != null && online) {
// 恢复在线且已有账号 → start(幂等:已 start 则只刷新 timer)。
engine.start(account);
// 网络从 offline → online 的跃变触发即时重试。
if (network == NetworkAvailability.online) {
engine.onNetworkRestored();
}
} else {
// 离线(或无账号)→ stop(不中断在途;恢复时上面的 start 恢复出队)。
engine.stop();
}
ref.onDispose(engine.stop);
+428 -24
View File
@@ -1,24 +1,68 @@
/// 选图注入点 + 批量入队动作(P3 计划 Task 9)。
///
/// image_picker 的平台调用经 Provider 注入([singleImagePickerProvider]/
/// [multiImagePickerProvider]),widget 测试 override 为替身——不经平台
/// 通道即可驱动入队流;[enqueueForUpload] 为连拍页与相册详情 FAB 的共用
/// 入队路径:首次动作触发 POST_NOTIFICATIONS 请求(静默降级)+ 逐张经
/// 准备器入队(准备器内部串行复制,防连拍 IO 风暴)。
/// 选图的平台调用经 Provider 注入([singleImagePickerProvider]=image_picker
/// 单图;[multiImagePickerProvider]=wechat_assets_picker 应用内多选),
/// widget 测试 override 为替身——不经平台通道即可驱动入队流;
/// [enqueueForUpload] 为连拍页与相册详情 FAB 的共用入队路径:首次动作
/// 触发 POST_NOTIFICATIONS 请求(静默降级)+ 批量经准备器入队(准备器
/// 内部有界并行复制,默认并发 3,防连拍 IO 风暴)。
///
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateMatch],由
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
/// 本地匹配经统一入口 [checkDuplicatesUnified] 驱动(原独立入口
/// checkDuplicates 已折叠为私有半场,仓库内无其他调用方)。
///
/// Task 2(查重前置到入队前·统一入口):入队前查重升级为
/// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史
/// (duplicates/check 只读预检,bytes 命中);服务器失败静默降级
/// 本机-only,不阻塞入队。
///
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
///
/// 方案 A v2(选图点选拦截·硬封锁):[multiImagePickerProvider] 经
/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选
/// 「已上传到目标相册」的照片当场拦下且**再次点击也不放行**(SnackBar
/// 提示)。判定**仅查本机 TaskStore 历史**(毫秒级——v1 的服务器单项
/// 预检在弱网下把每次点选的勾选卡到最多 2s,是「选图不灵敏」的根因);
/// 服务器查重退至入队时刻统一弹窗(checkDuplicatesUnified)+ init 409
/// 兜底,跨设备/重装场景的重复仍在点上传时拦。已接受限制(用户拍板):
/// 后台已删除的照片重传同一文件会被本机历史永远拦下(清数据/换设备)。
library;
import 'dart:io';
import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar;
import 'package:flutter/widgets.dart'
show BuildContext, Localizations, Text;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import '../../core/api_client.dart'
show UploadDuplicateHit, apiClientProvider;
import '../../core/notification_permission.dart';
import '../../l10n/app_localizations.dart';
import 'providers.dart';
import 'task_state.dart';
import 'task_store.dart';
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
typedef SingleImagePicker = Future<String?> Function();
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
typedef MultiImagePicker = Future<List<String>> Function();
///
/// [accountId]/[albumId] 为点选拦截的判定范围(方案 A:当前目标相册;
/// 与入队目标同源)——两处调用点(相册详情/连拍页)均持有,必传。
typedef MultiImagePicker = Future<List<String>> Function(
BuildContext context, {
required String accountId,
required String albumId,
});
/// 单图选择 Provider(生产=image_picker;测试 override)。
final singleImagePickerProvider = Provider<SingleImagePicker>(
@@ -28,37 +72,397 @@ final singleImagePickerProvider = Provider<SingleImagePicker>(
},
);
/// 多图选择 Provider(生产=image_picker;测试 override)。
/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
///
/// - 应用内选择器:长按进入多选 + 拖动连选 + 全选;无系统 Photo Picker
/// 的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页
/// 加载,实际不受限);
/// - originFile 取原始文件路径:API 30+ 直接返回原路径;Android 10 非
/// legacy 经 ScopedCache 复制到应用缓存(较慢、占缓存空间)。路径
/// 解析并行发起(批量数百张时避免串行平台通道往返阻塞入队启动;
/// 平台通道调用在平台线程自然串行化,无 IO 风暴);
/// - 单个资产解析失败(选择后被删/云下架等 PlatformException)仅跳过
/// 该张(null 过滤),不丢弃整批;入队数由调用方 SnackBar 反映实际
/// 张数;
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
/// (非「返回空」);返回 null 仅发生在用户取消;
/// - 方案 A v2(点选拦截·硬封锁):selectPredicate 挂 [PickerDuplicateGate]
/// ——已上传到目标相册的照片点选当场拦下(再次点击不放行;仅本机判定)。
final multiImagePickerProvider = Provider<MultiImagePicker>(
(ref) => () async {
final files = await ImagePicker().pickMultiImage();
return files.map((file) => file.path).toList();
(ref) => (context, {required accountId, required albumId}) async {
// 拦截器按次构造:每次选图会话独立的判定缓存(不跨会话残留)。
final gate = PickerDuplicateGate(
store: ref.read(taskStoreProvider),
accountId: accountId,
albumId: albumId,
);
final locale = Localizations.maybeLocaleOf(context);
final assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
requestType: RequestType.image,
maxAssets: kPickerSoftMaxAssets,
textDelegate: assetPickerTextDelegateFromLocale(locale),
// 点选拦截(selectPredicate 签名 FutureOr<bool> Function(ctx,
// asset, selected)——false 静默不选);AssetEntity →
// [PickerAssetFact] 适配(fileSize 惰性求值)在此完成。
selectPredicate: (ctx, asset, selected) => gate.decideFact(
ctx,
(id: asset.id, title: asset.title, fileSize: () => asset.fileSize),
selected,
),
),
);
if (assets == null) return const <String>[];
final files = await Future.wait(
assets.map((a) => a.originFile.then((f) => f, onError: (_) => null)),
);
return [
for (final file in files)
if (file != null) file.path,
];
},
);
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999;
/// 拦截器所需的最小资产事实([PickerDuplicateGate] 的测试接缝)。
///
/// AssetEntity → 此记录的适配在 [multiImagePickerProvider] 内完成:
/// - `fileSize`:photo_manager 3.12 为 `Future<int> get fileSize`
/// (entity.dart:778——平台通道异步,无同步 length 字段);以惰性闭包
/// 传入,缓存命中/取消选择/二次点击路径均不发起查询;
/// - `title`:`String?`——iOS 需 FilterOption.needTitle 才非空
/// (wechat_assets_picker 默认 filterOptions 仅对 audio 开),Android
/// 侧无条件携带(ConvertUtils.kt 无条件 `title to entity.displayName`);
/// null → 本机半场跳过(服务器按 bytes 仍可判)。
typedef PickerAssetFact = ({
String id,
String? title,
Future<int> Function() fileSize,
});
/// 选图点选拦截器(方案 A v2·硬封锁):挂 wechat_assets_picker 的
/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码
/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回
/// false 静默不选;谓词返回前勾选不出现,故判定必须快)。
///
/// v2 要点(2026-09-29 用户反馈驱动):
/// - **仅本机判定**(同账号+相册+completed,filename+bytes)——毫秒级,
/// 不走网络。v1 的服务器单项预检(2s 时间帽)在弱网下让每次点选卡
/// 0.5~2s,配合「二次点击强制」活口,重复照片被连点强制选中,拦截
/// 形同虚设。服务器查重移回入队时刻统一弹窗 + init 409 兜底。
/// - **硬封锁**:本机命中的重复每次点击都拒选(无强制活口);提示文案
/// 不再引导再点。
/// - 判定缓存共享 in-flight Future(putIfAbsent)——同一照片连点只判定
/// 一次;[_judge] 全捕获不抛错,缓存 Future 无失败态。
/// - 查询异常(fileSize 失败/本机查询失败)一律放行——预检是体验优化
/// 而非闸门;title 为空(iOS 常见)本机无从匹配 → 放行(跨设备判定
/// 在入队弹窗)。
///
/// 拦下时 SnackBar 提示(l10n `pickerDuplicateBlocked`,经谓词 ctx 向上
/// 解析到根 ScaffoldMessenger——picker 页自带的 Scaffold 之上无
/// messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条)。取消选择
/// (selected=true)恒放行。
///
/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。
class PickerDuplicateGate {
PickerDuplicateGate({
required this.store,
required this.accountId,
required this.albumId,
});
/// 本机历史半场数据源(生产=taskStoreProvider)。
final TaskStore store;
/// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。
final String accountId;
/// 判定范围:目标相册(与入队目标一致)。
final String albumId;
/// per-asset 判定缓存(assetId → 判定 Future):in-flight 共享——
/// 同一照片连点(判定未完成时再点)复用同一次判定;完成后秒回。
final Map<String, Future<bool>> _verdicts = <String, Future<bool>>{};
/// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。
///
/// [selected]=true 表示当前已选中、本次点击为**取消选择** → 恒放行。
Future<bool> decideFact(
BuildContext ctx,
PickerAssetFact asset,
bool selected,
) async {
if (selected) return true;
final isDuplicate = await _verdicts.putIfAbsent(
asset.id,
() => _judge(asset),
);
if (!isDuplicate) return true;
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false)
// ——跳过提示,不跨 async 间隙用 ctx。
if (ctx.mounted) _notifyBlocked(ctx);
return false;
}
/// 判定一张是否重复(true=重复):仅本机历史,异常降级放行。
Future<bool> _judge(PickerAssetFact asset) async {
try {
final bytes = await asset.fileSize();
final title = asset.title;
if (title == null) return false; // iOS 常见:本机无从匹配 → 放行。
try {
final completed = await store.listByAccount(
accountId,
albumId: albumId,
states: const <UploadTaskState>{UploadTaskState.completed},
);
return completed.any(
(t) => t.filename == title && t.bytes == bytes,
);
} on Object {
// 本机查询失败:放行(入队弹窗 + 409 兜底)。
return false;
}
} on Object {
// fileSize 失败等任意异常:放行。
return false;
}
}
/// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。呈现前清队列:快速
/// 点多个重复资产时只留当前一条,不排队 N 条 4s 提示。
void _notifyBlocked(BuildContext ctx) {
ScaffoldMessenger.of(ctx)
..clearSnackBars()
..showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked),
),
);
}
}
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
///
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
/// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。
typedef DuplicateDialogChoice
= ({Set<String> skipPaths, Set<String> forcePaths});
/// 查重结果(单张):匹配到的已上传任务摘要。
class DuplicateMatch {
const DuplicateMatch({
required this.sourcePath,
required this.filename,
required this.bytes,
required this.uploadedAt,
});
/// 本批照片的完整源路径:弹窗/入队按路径精确派生重复项——同 basename
/// 不同目录/扩展名的兄弟文件不误伤(Task 2 评审 #2)。
final String sourcePath;
final String filename;
final int bytes;
final DateTime uploadedAt;
}
/// 入队结果(P0-1:含查重信息)。
class EnqueueResult {
const EnqueueResult({
required this.enqueued,
required this.duplicates,
this.skipped = 0,
});
/// 成功入队张数(state=queued)。
final int enqueued;
/// 与历史已上传任务匹配的项(需用户确认)。
final List<DuplicateMatch> duplicates;
/// 用户选择跳过的重复项张数。
final int skipped;
}
/// 本批照片的 stat 结果(路径 + 派生 filename + 字节数;层 1/层 2 查重
/// 共用——文件只 stat 一次,Task 2 评审 #3)。
typedef _PickStat = ({String path, String filename, int bytes});
/// stat 本批文件([checkDuplicatesUnified] 专用;读不到的文件跳过,
/// 入队时准备器会报错)。
Future<List<_PickStat>> _statPicks(List<String> sourcePaths) async {
final picks = <_PickStat>[];
for (final path in sourcePaths) {
try {
final file = File(path);
picks.add((
path: path,
filename: p.basename(path),
bytes: await file.length(),
));
} on Object {
// 读不到的文件不参与查重(入队时准备器会报错)。
}
}
return picks;
}
/// 层 1 本机匹配:TaskStore 同账号 + 同相册 + state=completed 的任务与
/// 本批 stat 结果做 filename + bytes 匹配(原公共 checkDuplicates 的
/// 内核,折叠为统一入口的私有半场——仓库内已无其他调用方)。
Future<List<DuplicateMatch>> _matchLocalHistory(
WidgetRef ref, {
required String accountId,
required String albumId,
required List<_PickStat> picks,
}) async {
// 查同相册已完成的任务。
final store = ref.read(taskStoreProvider);
final completed = await store.listByAccount(
accountId,
albumId: albumId,
states: {UploadTaskState.completed},
);
// 匹配 filename + bytes。
final matches = <DuplicateMatch>[];
for (final task in completed) {
for (final pick in picks) {
if (task.filename == pick.filename && task.bytes == pick.bytes) {
matches.add(
DuplicateMatch(
sourcePath: pick.path,
filename: pick.filename,
bytes: pick.bytes,
uploadedAt: task.createdAt,
),
);
break; // 一对一即可(同一历史任务不重复匹配多张同内容照片)。
}
}
}
return matches;
}
/// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪
/// 服务器历史(POST /uploads/duplicates/check;服务端谓词仅 bytes——
/// filename 刻意不参与,与 initUpload 查重一致)。
///
/// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞
/// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。
///
/// 本批文件只 stat 一次([_statPicks],两层共用);服务器命中按全局
/// 下标映射回 stat 结果(filename/sourcePath 由此生效);合并按
/// sourcePath 去重——同一文件双命中只呈现一条,本机命中在前(本地
/// uploadedAt 优先,Task 2 评审 #1)。
Future<List<DuplicateMatch>?> checkDuplicatesUnified(
WidgetRef ref, {
required String accountId,
required String albumId,
required List<String> sourcePaths,
}) async {
if (sourcePaths.isEmpty) return null;
// stat 一次,层 1/层 2 共用。
final picks = await _statPicks(sourcePaths);
if (picks.isEmpty) return null;
// 层 1:本机 TaskStore 历史(filename+bytes 匹配)。
final local = await _matchLocalHistory(
ref,
accountId: accountId,
albumId: albumId,
picks: picks,
);
// 层 2:服务器历史(bytes 命中,逐请求条目一条;失败静默降级)。
final serverHits = <UploadDuplicateHit>[];
try {
serverHits.addAll(
await ref
.read(apiClientProvider)
.checkUploadDuplicates(
albumId,
[
for (final pick in picks)
(filename: pick.filename, bytes: pick.bytes),
],
),
);
} on Object {
// 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队
//(服务端 init 409 仍是兜底)。
}
// 合并去重(sourcePath 唯一标识一张本批照片;putIfAbsent 保留先见
// 者——本机在前,故同一重复的 uploadedAt 取本地历史)。
final byPath = <String, DuplicateMatch>{};
for (final match in [
...local,
for (final hit in serverHits)
if (hit.index >= 0 && hit.index < picks.length)
DuplicateMatch(
sourcePath: picks[hit.index].path,
filename: picks[hit.index].filename,
bytes: hit.bytes,
uploadedAt: hit.uploadedAt,
),
]) {
byPath.putIfAbsent(match.sourcePath, () => match);
}
return byPath.isEmpty ? null : byPath.values.toList();
}
/// 批量入队:通知权限单次请求 + 逐张准备入队。
///
/// 返回成功入队(state=queued)的张数;准备失败的任务已落库为
/// preparationFailed(连拍页失败徽章/队列页可见,不在此抛出)。
Future<int> enqueueForUpload(
/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」
/// 确认强传的重复项(Task 8——落到任务行 force 标记,引擎 initUpload
/// 携带;仅 owner/admin 生效,服务端裁决)。两者互斥。
///
/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。
/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见,
/// 不在此抛出)。
Future<EnqueueResult> enqueueForUpload(
WidgetRef ref, {
required String accountId,
required String albumId,
String? categoryId,
required List<String> sourcePaths,
List<String>? skipPaths,
List<String>? forcePaths,
}) async {
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
await ref.read(notificationPermissionProvider).ensureRequested();
final preparer = ref.read(uploadPreparerProvider);
var enqueued = 0;
for (final path in sourcePaths) {
final task = await preparer.prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: path,
);
if (task.state == UploadTaskState.queued) enqueued++;
}
return enqueued;
// P0-1:跳过用户选择取消的重复项。
final skipSet = skipPaths?.toSet() ?? <String>{};
final effective = sourcePaths
.where((path) => !skipSet.contains(path))
.toList();
// Task 8:「全部上传」确认的强传项(重复项 force=true 落任务行)。
final forceSet = forcePaths?.toSet() ?? <String>{};
final results = await Future.wait(
effective.map((path) => preparer.prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: path,
force: forceSet.contains(path),
)),
);
final enqueued = results
.where((task) => task.state == UploadTaskState.queued)
.length;
return EnqueueResult(
enqueued: enqueued,
duplicates: [],
skipped: sourcePaths.length - effective.length,
);
}
+16 -1
View File
@@ -93,6 +93,7 @@ class UploadInitRequest {
required this.contentType,
this.categoryId,
this.sourceDeviceId,
this.force = false,
});
/// 目标相册 ID。
@@ -116,6 +117,9 @@ class UploadInitRequest {
/// 发起设备标识(可选,minLength 8 / maxLength 64;A19)。
final String? sourceDeviceId;
/// P0-1 层 2:跳过服务端查重(仅 owner/admin 生效)。缺省 false。
final bool force;
Map<String, dynamic> toJson() => {
'albumId': albumId,
if (categoryId != null) 'categoryId': categoryId,
@@ -124,6 +128,7 @@ class UploadInitRequest {
'bytes': bytes,
'contentType': contentType,
if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId,
if (force) 'force': true,
};
}
@@ -202,12 +207,15 @@ class UploadCompleteResponse {
/// 会话关联照片的当前状态(会话记录内嵌,客户端据此展示服务端进度)。
///
/// 契约 `components.schemas.UploadSessionPhoto`。
/// 契约 `components.schemas.UploadSessionPhoto`。[preview] 为可选缩略图
///(2026-09-29 增补):服务端在变体齐备且未隐藏/删除时现签(uploader
/// 本人视角);缺省(未处理/隐藏/删除)时 UI 回退中性占位。
class UploadSessionPhoto {
const UploadSessionPhoto({
required this.photoId,
required this.processingState,
required this.visibility,
this.preview,
});
factory UploadSessionPhoto.fromJson(Map<String, dynamic> json) =>
@@ -216,6 +224,9 @@ class UploadSessionPhoto {
processingState:
PhotoProcessingState.fromJson(json['processingState'] as String),
visibility: PhotoVisibility.fromJson(json['visibility'] as String),
preview: json['preview'] == null
? null
: PhotoPreview.fromJson(json['preview'] as Map<String, dynamic>),
);
/// 照片 ID。
@@ -227,10 +238,14 @@ class UploadSessionPhoto {
/// 对外可见性。
final PhotoVisibility visibility;
/// 短期签名预览 URL;null = 无(回退占位图标)。
final PhotoPreview? preview;
Map<String, dynamic> toJson() => {
'photoId': photoId,
'processingState': processingState.wire,
'visibility': visibility.wire,
if (preview != null) 'preview': preview!.toJson(),
};
}
@@ -139,9 +139,15 @@ class DartHttpTransport implements Transport {
final file = File(filePath);
final length = await file.length();
var sent = 0;
var lastReported = 0;
// 256KB 步长节流(与原生 UploadPutExecutor 一致);末块必报 sent==length。
const progressStep = 256 * 1024;
final progressStream = file.openRead().map((List<int> chunk) {
sent += chunk.length;
onProgress?.call(sent, length);
if (sent - lastReported >= progressStep || sent == length) {
lastReported = sent;
onProgress?.call(sent, length);
}
return chunk;
});
try {
+73 -24
View File
@@ -7,8 +7,8 @@
/// [UploadPreparer.retry]),lastError 携带客户端伪码([PrepErrorCodes])。
///
/// 设计取舍(对齐计划 Task 6 评审口径):
/// - **串行执行**:文件复制是最容易造成 IO 风暴的环节(连拍场景一次
/// 落盘多张),队列化逐张处理;传输并发由 UploadEngine 另行控制;
/// - **有界并发执行(默认 3)**:文件复制仍是 IO 最重环节,上限钳位
/// 防连拍(一次落盘多张)IO 风暴;传输并发由 UploadEngine 另行控制;
/// - **磁盘空间不显式预检**:引入 disk_space 插件成本(新依赖 + 平台
/// 误差)高于收益——复制失败本身即失败信号(ENOSPC 走 copy 失败分支
/// → preparationFailed,用户可见可重试)。此为有意裁剪,登记为计划
@@ -19,6 +19,7 @@
library;
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:math';
@@ -45,18 +46,24 @@ abstract final class PrepErrorCodes {
static const String copyFailed = 'PREP_COPY_FAILED';
}
/// 准备阶段有界并发上限(dart-define PREPARE_CONCURRENCY 覆盖;1=串行)。
const int kMaxConcurrentPrepares =
int.fromEnvironment('PREPARE_CONCURRENCY', defaultValue: 3);
/// 上传准备器:入队唯一入口(队列 UI/连拍页调用;引擎消费产出)。
///
/// 所有操作串行(连拍入队逐张复制,避免 IO 风暴,见库级注释)。
/// 所有操作有界并发执行(默认 3):文件复制仍是 IO 最重环节,上限钳位
/// 防连拍风暴;传输并发由 UploadEngine 另行控制(见库级注释)。
class UploadPreparer {
UploadPreparer(
this._store, {
Future<Directory> Function()? baseDirProvider,
Random? random,
this.maxBytes = kMaxUploadBytes,
this.maxConcurrentPrepares = kMaxConcurrentPrepares,
this.onEnqueued,
}) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory,
_random = random ?? Random.secure();
}) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory,
_random = random ?? Random.secure();
final TaskStore _store;
final Future<Directory> Function() _baseDirProvider;
@@ -71,13 +78,18 @@ class UploadPreparer {
/// 入队成功回调(唤醒传输引擎立即出队)。
final void Function()? onEnqueued;
/// 串行队列尾部。
Future<void> _tail = Future<void>.value();
/// 准备并发上限(运行时 <1 钳位为 1)。
final int maxConcurrentPrepares;
int get _effectivePrepareConcurrency =>
maxConcurrentPrepares < 1 ? 1 : maxConcurrentPrepares;
/// 准备一张图片并入队。
///
/// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内
/// 可直接 File 读取);[filename] 缺省取源文件 basename。
/// 可直接 File 读取);[filename] 缺省取源文件 basename;[force] 为
/// 查重弹窗「全部上传」的用户确认(Task 8)——落到任务行,由引擎在
/// initUpload 携带(仅 owner/admin 生效,服务端裁决)。
///
/// 返回入队后的任务(state=queued);失败返回 state=preparationFailed
/// 的任务(不抛异常——准备失败是业务状态而非调用错误)。
@@ -87,14 +99,18 @@ class UploadPreparer {
String? categoryId,
required String sourcePath,
String? filename,
bool force = false,
}) {
return _serialize(() => _prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: sourcePath,
filename: filename,
));
return _runBounded(
() => _prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: sourcePath,
filename: filename,
force: force,
),
);
}
/// 重试准备失败的任务(preparationFailed → preparing → 复制 → queued)。
@@ -106,26 +122,53 @@ class UploadPreparer {
required String taskId,
required String sourcePath,
}) {
return _serialize(() => _retry(
accountId: accountId,
taskId: taskId,
sourcePath: sourcePath,
));
return _runBounded(
() =>
_retry(accountId: accountId, taskId: taskId, sourcePath: sourcePath),
);
}
/// 串行化:所有准备操作首尾相接(错误吞掉以不断链,错误经返回值上抛)。
Future<T> _serialize<T>(Future<T> Function() op) {
final result = _tail.then((_) => op());
_tail = result.then<void>((_) {}, onError: (Object _) {});
/// 在途准备计数 + FIFO 等待队列(有界并发信号量)。
int _activePrepares = 0;
final Queue<Completer<void>> _prepareWaiters = Queue<Completer<void>>();
/// 有界并发执行:超额排队(FIFO);错误由调用方经返回值处理,不设全局断链。
Future<T> _runBounded<T>(Future<T> Function() op) {
final ticket = _acquirePrepareSlot();
final result = ticket.then((_) => op());
result.whenComplete(_releasePrepareSlot).ignore();
return result;
}
Future<void> _acquirePrepareSlot() {
if (_activePrepares < _effectivePrepareConcurrency) {
_activePrepares++;
return Future<void>.value();
}
final waiter = Completer<void>();
_prepareWaiters.addLast(waiter);
return waiter.future;
}
void _releasePrepareSlot() {
final waiter = _prepareWaiters.isEmpty
? null
: _prepareWaiters.removeFirst();
if (waiter == null) {
_activePrepares--;
return;
}
// 名额直接移交队首等待者(计数不变)。
waiter.complete();
}
Future<UploadTask> _prepare({
required String accountId,
required String albumId,
required String? categoryId,
required String sourcePath,
required String? filename,
required bool force,
}) async {
final id = _newId();
final clientTaskId = _newId();
@@ -150,6 +193,7 @@ class UploadPreparer {
// 预检失败时占位(该行永不进入传输;成功路径 bytes 来自源 stat)。
bytes: bytes > 0 ? bytes : 1,
contentType: contentType ?? 'application/octet-stream',
force: force,
),
);
@@ -196,6 +240,11 @@ class UploadPreparer {
if (expected <= 0) {
throw StateError('empty source');
}
// P1-8:磁盘空间预检——超 100MB 直接拒绝(copy 阶段的磁盘满错误
// 已被下方 on Object 兜底为 copyFailed;此上限防止单文件打满存储)。
if (expected > 100 * 1024 * 1024) {
throw StateError('source too large');
}
await target.parent.create(recursive: true);
await source.copy(task.sourceFilePath);
final copied = await target.length();
@@ -89,6 +89,11 @@ class UploadTasks extends Table {
TextColumn get state =>
text().map(const _UploadTaskStateConverter())();
/// 用户已确认强传(P0-1 Task 8):查重弹窗「全部上传」入队的任务置
/// true,initUpload 携带 force 跳过服务端查重(仅 owner/admin 生效,
/// 服务端裁决)。缺省 false(正常查重)。
BoolColumn get force => boolean().withDefault(const Constant(false))();
/// 已尝试传输次数(退避与上限判断依据)。
IntColumn get attempt => integer().withDefault(const Constant(0))();
@@ -128,8 +133,31 @@ class UploadTaskDb extends _$UploadTaskDb {
/// 测试用内存库(drift NativeDatabase.memory,随 close 丢弃)。
UploadTaskDb.memory() : super(NativeDatabase.memory());
/// v1:初始表;v2(Task 8):+force 列(查重弹窗「全部上传」强传标记)。
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from == 1) {
// 降级-重升级防护:旧 APK 覆盖安装打开 v2 库时,drift 降级
// 路径不改表、只把 user_version 写回 1(force 列留存);随后
// 新 APK 重升级再次走到这里——无条件 addColumn 会撞
// 「duplicate column name: force」致库打开失败。先探测列
// 存在性,缺列才补(正常 v1 → v2 路径列必缺省,行为不变)。
final columns = await m.database
.customSelect('PRAGMA table_info(upload_tasks)')
.get();
final forceExists = columns.any(
(row) => row.read<String>('name') == 'force',
);
if (!forceExists) {
await m.addColumn(uploadTasks, uploadTasks.force);
}
}
},
);
}
/// 打开生产库:app documents 目录下 upload_tasks.sqlite,后台 isolate 执行
@@ -154,6 +182,7 @@ class UploadTaskDraft {
required this.filename,
required this.bytes,
required this.contentType,
this.force = false,
});
/// 任务 ID(uuid,调用方生成)。
@@ -182,6 +211,9 @@ class UploadTaskDraft {
/// MIME 类型。
final String contentType;
/// 用户已确认强传(查重弹窗「全部上传」);initUpload 携带 force。
final bool force;
}
/// 状态迁移时一并写入的旁路字段(未提供的列保持不变;`null` 与"清除"通过
@@ -279,6 +311,7 @@ class TaskStore {
filename: draft.filename,
bytes: draft.bytes,
contentType: draft.contentType,
force: Value(draft.force),
state: UploadTaskState.preparing,
createdAt: timestamp,
updatedAt: timestamp,
@@ -113,6 +113,19 @@ class $UploadTasksTable extends UploadTasks
type: DriftSqlType.string,
requiredDuringInsert: true,
).withConverter<UploadTaskState>($UploadTasksTable.$converterstate);
static const VerificationMeta _forceMeta = const VerificationMeta('force');
@override
late final GeneratedColumn<bool> force = GeneratedColumn<bool>(
'force',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("force" IN (0, 1))',
),
defaultValue: const Constant(false),
);
static const VerificationMeta _attemptMeta = const VerificationMeta(
'attempt',
);
@@ -214,6 +227,7 @@ class $UploadTasksTable extends UploadTasks
bytes,
contentType,
state,
force,
attempt,
nextRetryAt,
serverUploadId,
@@ -311,6 +325,12 @@ class $UploadTasksTable extends UploadTasks
} else if (isInserting) {
context.missing(_contentTypeMeta);
}
if (data.containsKey('force')) {
context.handle(
_forceMeta,
force.isAcceptableOrUnknown(data['force']!, _forceMeta),
);
}
if (data.containsKey('attempt')) {
context.handle(
_attemptMeta,
@@ -423,6 +443,10 @@ class $UploadTasksTable extends UploadTasks
data['${effectivePrefix}state'],
)!,
),
force: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}force'],
)!,
attempt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}attempt'],
@@ -498,6 +522,11 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
/// 任务状态(状态机见 task_state.dart)。
final UploadTaskState state;
/// 用户已确认强传(P0-1 Task 8):查重弹窗「全部上传」入队的任务置
/// true,initUpload 携带 force 跳过服务端查重(仅 owner/admin 生效,
/// 服务端裁决)。缺省 false(正常查重)。
final bool force;
/// 已尝试传输次数(退避与上限判断依据)。
final int attempt;
@@ -535,6 +564,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
required this.bytes,
required this.contentType,
required this.state,
required this.force,
required this.attempt,
this.nextRetryAt,
this.serverUploadId,
@@ -563,6 +593,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
$UploadTasksTable.$converterstate.toSql(state),
);
}
map['force'] = Variable<bool>(force);
map['attempt'] = Variable<int>(attempt);
if (!nullToAbsent || nextRetryAt != null) {
map['next_retry_at'] = Variable<DateTime>(nextRetryAt);
@@ -598,6 +629,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes: Value(bytes),
contentType: Value(contentType),
state: Value(state),
force: Value(force),
attempt: Value(attempt),
nextRetryAt: nextRetryAt == null && nullToAbsent
? const Value.absent()
@@ -635,6 +667,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes: serializer.fromJson<int>(json['bytes']),
contentType: serializer.fromJson<String>(json['contentType']),
state: serializer.fromJson<UploadTaskState>(json['state']),
force: serializer.fromJson<bool>(json['force']),
attempt: serializer.fromJson<int>(json['attempt']),
nextRetryAt: serializer.fromJson<DateTime?>(json['nextRetryAt']),
serverUploadId: serializer.fromJson<String?>(json['serverUploadId']),
@@ -659,6 +692,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
'bytes': serializer.toJson<int>(bytes),
'contentType': serializer.toJson<String>(contentType),
'state': serializer.toJson<UploadTaskState>(state),
'force': serializer.toJson<bool>(force),
'attempt': serializer.toJson<int>(attempt),
'nextRetryAt': serializer.toJson<DateTime?>(nextRetryAt),
'serverUploadId': serializer.toJson<String?>(serverUploadId),
@@ -681,6 +715,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
int? bytes,
String? contentType,
UploadTaskState? state,
bool? force,
int? attempt,
Value<DateTime?> nextRetryAt = const Value.absent(),
Value<String?> serverUploadId = const Value.absent(),
@@ -700,6 +735,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes: bytes ?? this.bytes,
contentType: contentType ?? this.contentType,
state: state ?? this.state,
force: force ?? this.force,
attempt: attempt ?? this.attempt,
nextRetryAt: nextRetryAt.present ? nextRetryAt.value : this.nextRetryAt,
serverUploadId: serverUploadId.present
@@ -733,6 +769,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
? data.contentType.value
: this.contentType,
state: data.state.present ? data.state.value : this.state,
force: data.force.present ? data.force.value : this.force,
attempt: data.attempt.present ? data.attempt.value : this.attempt,
nextRetryAt: data.nextRetryAt.present
? data.nextRetryAt.value
@@ -763,6 +800,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
..write('bytes: $bytes, ')
..write('contentType: $contentType, ')
..write('state: $state, ')
..write('force: $force, ')
..write('attempt: $attempt, ')
..write('nextRetryAt: $nextRetryAt, ')
..write('serverUploadId: $serverUploadId, ')
@@ -787,6 +825,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes,
contentType,
state,
force,
attempt,
nextRetryAt,
serverUploadId,
@@ -810,6 +849,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
other.bytes == this.bytes &&
other.contentType == this.contentType &&
other.state == this.state &&
other.force == this.force &&
other.attempt == this.attempt &&
other.nextRetryAt == this.nextRetryAt &&
other.serverUploadId == this.serverUploadId &&
@@ -831,6 +871,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
final Value<int> bytes;
final Value<String> contentType;
final Value<UploadTaskState> state;
final Value<bool> force;
final Value<int> attempt;
final Value<DateTime?> nextRetryAt;
final Value<String?> serverUploadId;
@@ -851,6 +892,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
this.bytes = const Value.absent(),
this.contentType = const Value.absent(),
this.state = const Value.absent(),
this.force = const Value.absent(),
this.attempt = const Value.absent(),
this.nextRetryAt = const Value.absent(),
this.serverUploadId = const Value.absent(),
@@ -872,6 +914,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
required int bytes,
required String contentType,
required UploadTaskState state,
this.force = const Value.absent(),
this.attempt = const Value.absent(),
this.nextRetryAt = const Value.absent(),
this.serverUploadId = const Value.absent(),
@@ -903,6 +946,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
Expression<int>? bytes,
Expression<String>? contentType,
Expression<String>? state,
Expression<bool>? force,
Expression<int>? attempt,
Expression<DateTime>? nextRetryAt,
Expression<String>? serverUploadId,
@@ -924,6 +968,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
if (bytes != null) 'bytes': bytes,
if (contentType != null) 'content_type': contentType,
if (state != null) 'state': state,
if (force != null) 'force': force,
if (attempt != null) 'attempt': attempt,
if (nextRetryAt != null) 'next_retry_at': nextRetryAt,
if (serverUploadId != null) 'server_upload_id': serverUploadId,
@@ -947,6 +992,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
Value<int>? bytes,
Value<String>? contentType,
Value<UploadTaskState>? state,
Value<bool>? force,
Value<int>? attempt,
Value<DateTime?>? nextRetryAt,
Value<String?>? serverUploadId,
@@ -968,6 +1014,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
bytes: bytes ?? this.bytes,
contentType: contentType ?? this.contentType,
state: state ?? this.state,
force: force ?? this.force,
attempt: attempt ?? this.attempt,
nextRetryAt: nextRetryAt ?? this.nextRetryAt,
serverUploadId: serverUploadId ?? this.serverUploadId,
@@ -1015,6 +1062,9 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
$UploadTasksTable.$converterstate.toSql(state.value),
);
}
if (force.present) {
map['force'] = Variable<bool>(force.value);
}
if (attempt.present) {
map['attempt'] = Variable<int>(attempt.value);
}
@@ -1058,6 +1108,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
..write('bytes: $bytes, ')
..write('contentType: $contentType, ')
..write('state: $state, ')
..write('force: $force, ')
..write('attempt: $attempt, ')
..write('nextRetryAt: $nextRetryAt, ')
..write('serverUploadId: $serverUploadId, ')
@@ -1115,6 +1166,7 @@ typedef $$UploadTasksTableCreateCompanionBuilder =
required int bytes,
required String contentType,
required UploadTaskState state,
Value<bool> force,
Value<int> attempt,
Value<DateTime?> nextRetryAt,
Value<String?> serverUploadId,
@@ -1137,6 +1189,7 @@ typedef $$UploadTasksTableUpdateCompanionBuilder =
Value<int> bytes,
Value<String> contentType,
Value<UploadTaskState> state,
Value<bool> force,
Value<int> attempt,
Value<DateTime?> nextRetryAt,
Value<String?> serverUploadId,
@@ -1208,6 +1261,11 @@ class $$UploadTasksTableFilterComposer
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnFilters<bool> get force => $composableBuilder(
column: $table.force,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get attempt => $composableBuilder(
column: $table.attempt,
builder: (column) => ColumnFilters(column),
@@ -1308,6 +1366,11 @@ class $$UploadTasksTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get force => $composableBuilder(
column: $table.force,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get attempt => $composableBuilder(
column: $table.attempt,
builder: (column) => ColumnOrderings(column),
@@ -1396,6 +1459,9 @@ class $$UploadTasksTableAnnotationComposer
GeneratedColumnWithTypeConverter<UploadTaskState, String> get state =>
$composableBuilder(column: $table.state, builder: (column) => column);
GeneratedColumn<bool> get force =>
$composableBuilder(column: $table.force, builder: (column) => column);
GeneratedColumn<int> get attempt =>
$composableBuilder(column: $table.attempt, builder: (column) => column);
@@ -1468,6 +1534,7 @@ class $$UploadTasksTableTableManager
Value<int> bytes = const Value.absent(),
Value<String> contentType = const Value.absent(),
Value<UploadTaskState> state = const Value.absent(),
Value<bool> force = const Value.absent(),
Value<int> attempt = const Value.absent(),
Value<DateTime?> nextRetryAt = const Value.absent(),
Value<String?> serverUploadId = const Value.absent(),
@@ -1488,6 +1555,7 @@ class $$UploadTasksTableTableManager
bytes: bytes,
contentType: contentType,
state: state,
force: force,
attempt: attempt,
nextRetryAt: nextRetryAt,
serverUploadId: serverUploadId,
@@ -1510,6 +1578,7 @@ class $$UploadTasksTableTableManager
required int bytes,
required String contentType,
required UploadTaskState state,
Value<bool> force = const Value.absent(),
Value<int> attempt = const Value.absent(),
Value<DateTime?> nextRetryAt = const Value.absent(),
Value<String?> serverUploadId = const Value.absent(),
@@ -1530,6 +1599,7 @@ class $$UploadTasksTableTableManager
bytes: bytes,
contentType: contentType,
state: state,
force: force,
attempt: attempt,
nextRetryAt: nextRetryAt,
serverUploadId: serverUploadId,
@@ -19,7 +19,7 @@
/// | 网络不可达/超时/429 限速 | waitingNetwork | 退避 5s·2^n 封顶 10min,无次数上限 |
/// | 401 会话过期 | waitingNetwork | 30s 短退避;全局驱逐由 ApiClient 处理,不烧次数 |
/// | 400 UPLOAD_VERIFY_FAILED / 5xx / 未知 | retryableFailed | 退避重试,3 次后停止自动重试 |
/// | 409 ALBUM_UPLOAD_DISABLED / UPLOAD_SESSION_CONFLICT / 源文件缺失 | retryableFailed | 不自动重试(人工修正后 retryNow) |
/// | 409 ALBUM_UPLOAD_DISABLED / UPLOAD_SESSION_CONFLICT / PHOTO_DUPLICATE / 源文件缺失 | retryableFailed | 不自动重试(人工修正后 retryNow) |
/// | presign 403 凭证过期 | (renew 后原状重传) | renewUpload 至多 2 次 |
/// - **取消**([cancel]):未开始 → cancelled 后删行删文件(确定语义);
/// 进行中 → cancelToken 尽力取消 + 置 cancelled(文件保留,由清理
@@ -51,9 +51,11 @@ import 'task_state.dart';
import 'task_store.dart';
/// 并行传输上限(计划:批量并发为可配置小值;dart-define
/// MAX_PARALLEL_UPLOADS 覆盖)。
/// MAX_PARALLEL_UPLOADS 覆盖)。2026-09-28 上传提速:2 → 4
/// (4G/Wi-Fi 上行可支撑;Android 侧每任务独立 WorkManager 通知,
/// 并发=通知数,观察后再定是否合并)。
const int kMaxParallelUploads =
int.fromEnvironment('MAX_PARALLEL_UPLOADS', defaultValue: 2);
int.fromEnvironment('MAX_PARALLEL_UPLOADS', defaultValue: 4);
/// completed/cancelled 任务源文件保留天数(dart-define
/// KEEP_COMPLETED_DAYS 覆盖)。
@@ -333,6 +335,9 @@ class UploadEngine {
Future<void> _transferSteps(UploadTask task, _TransferHandle handle) async {
// a. initUpload(幂等:重试复用 clientTaskId,服务端返回原会话)。
// force(Task 8):查重弹窗「全部上传」入队时落库的用户确认——重试
// 与 retryNow 均沿用任务行标记(用户已确认过的强传不因重试退化为
// 再被 409 拒绝)。
final init = await _apiClient.initUpload(
UploadInitRequest(
albumId: task.albumId,
@@ -342,6 +347,7 @@ class UploadEngine {
bytes: task.bytes,
contentType: task.contentType,
sourceDeviceId: await _deviceId.value(),
force: task.force,
),
);
// 回填会话/照片 ID(状态仍 uploading,旁路写入,见 TaskStore.updateMeta)。
@@ -484,10 +490,14 @@ class UploadEngine {
return;
}
// 3) 客户端重试无意义(服务端开关/幂等冲突/源文件缺失):不自动
// 重试,用户可见 lastError,修正后 retryNow 手动重试。
// 3) 客户端重试无意义(服务端开关/幂等冲突/查重拒绝/源文件缺失):
// 不自动重试,用户可见 lastError,修正后 retryNow 手动重试。
// PHOTO_DUPLICATE(P0-1 Task 8):重发同体 init 必再 409——用户想
// 强传需重走选图弹窗「全部上传」(force 仅 retryNow 外的入队路径
// 携带;retryNow 保持任务原 force 标记)。
if (code == ErrorCodes.albumUploadDisabled ||
code == ErrorCodes.uploadSessionConflict ||
code == ErrorCodes.photoDuplicate ||
code == UploadEngine.sourceMissingCode) {
await _fail(
task,
+12 -1
View File
@@ -230,6 +230,8 @@
"queueTimeLast7Days": "Last 7 days",
"queueTimeLast30Days": "Last 30 days",
"queueEmpty": "No upload tasks",
"queueEmptyDeviceHint": "Upload records live on this device only and are cleared on reinstall",
"queueViewServerHistory": "View server history",
"queueRetryAllFailed": "Retry all failed",
"queueAttemptCount": "{count} attempt(s)",
"@queueAttemptCount": {
@@ -293,6 +295,7 @@
"errorAlbumUploadDisabled": "Album uploads are disabled",
"errorUploadSessionConflict": "Upload session conflict. Please retry",
"errorUploadVerifyFailed": "Upload verification failed. Will retry",
"errorPhotoDuplicate": "Possible duplicate: an identical photo already exists in this album",
"captureTitle": "Continuous capture",
"captureCategoryDefault": "Default category",
"captureSwitchHint": "Switching only affects photos captured afterwards",
@@ -348,6 +351,7 @@
}
},
"uploadPickNone": "No photos selected",
"pickerDuplicateBlocked": "This photo has already been uploaded to this album",
"albumManagePhotos": "Manage photos",
"albumTakeFirstPhoto": "Take the first photo",
"albumUploadDisabledHint": "Uploads are off for this album. New photos cannot be added right now",
@@ -427,5 +431,12 @@
"posterDownload": "Download poster image",
"posterSaved": "Poster saved to gallery",
"posterSaveFailed": "Failed to save poster",
"close": "Close"
"close": "Close",
"albumUploadDisabled": "Upload is disabled for this album",
"networkOfflineBanner": "No network. Photos will upload automatically when reconnected.",
"cropTitle": "Crop image",
"cropConfirm": "Done",
"cropCancel": "Cancel",
"cropProcessing": "Processing…",
"cropUnsupportedFormat": "This image format isn't supported yet — please pick JPG/PNG"
}
@@ -1268,6 +1268,18 @@ abstract class AppLocalizations {
/// **'暂无上传任务'**
String get queueEmpty;
/// No description provided for @queueEmptyDeviceHint.
///
/// In zh, this message translates to:
/// **'上传记录仅保存在本机,卸载重装后不会保留'**
String get queueEmptyDeviceHint;
/// No description provided for @queueViewServerHistory.
///
/// In zh, this message translates to:
/// **'查看服务端历史'**
String get queueViewServerHistory;
/// No description provided for @queueRetryAllFailed.
///
/// In zh, this message translates to:
@@ -1562,6 +1574,12 @@ abstract class AppLocalizations {
/// **'上传核验失败,将自动重试'**
String get errorUploadVerifyFailed;
/// No description provided for @errorPhotoDuplicate.
///
/// In zh, this message translates to:
/// **'照片可能重复:同相册已存在相同内容的照片'**
String get errorPhotoDuplicate;
/// No description provided for @captureTitle.
///
/// In zh, this message translates to:
@@ -1682,6 +1700,12 @@ abstract class AppLocalizations {
/// **'未选择照片'**
String get uploadPickNone;
/// No description provided for @pickerDuplicateBlocked.
///
/// In zh, this message translates to:
/// **'这张已上传到该相册,无法重复选择'**
String get pickerDuplicateBlocked;
/// No description provided for @albumManagePhotos.
///
/// In zh, this message translates to:
@@ -2017,6 +2041,48 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'关闭'**
String get close;
/// No description provided for @albumUploadDisabled.
///
/// In zh, this message translates to:
/// **'该相册已关闭上传,无法添加照片'**
String get albumUploadDisabled;
/// No description provided for @networkOfflineBanner.
///
/// In zh, this message translates to:
/// **'当前无网络连接,照片将在恢复后自动上传'**
String get networkOfflineBanner;
/// No description provided for @cropTitle.
///
/// In zh, this message translates to:
/// **'裁剪图片'**
String get cropTitle;
/// No description provided for @cropConfirm.
///
/// In zh, this message translates to:
/// **'完成'**
String get cropConfirm;
/// No description provided for @cropCancel.
///
/// In zh, this message translates to:
/// **'取消'**
String get cropCancel;
/// No description provided for @cropProcessing.
///
/// In zh, this message translates to:
/// **'处理中…'**
String get cropProcessing;
/// No description provided for @cropUnsupportedFormat.
///
/// In zh, this message translates to:
/// **'暂不支持该图片格式,请选择 JPG/PNG'**
String get cropUnsupportedFormat;
}
class _AppLocalizationsDelegate
@@ -622,6 +622,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get queueEmpty => 'No upload tasks';
@override
String get queueEmptyDeviceHint =>
'Upload records live on this device only and are cleared on reinstall';
@override
String get queueViewServerHistory => 'View server history';
@override
String get queueRetryAllFailed => 'Retry all failed';
@@ -780,6 +787,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get errorUploadVerifyFailed =>
'Upload verification failed. Will retry';
@override
String get errorPhotoDuplicate =>
'Possible duplicate: an identical photo already exists in this album';
@override
String get captureTitle => 'Continuous capture';
@@ -852,6 +863,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get uploadPickNone => 'No photos selected';
@override
String get pickerDuplicateBlocked =>
'This photo has already been uploaded to this album';
@override
String get albumManagePhotos => 'Manage photos';
@@ -1031,4 +1046,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get close => 'Close';
@override
String get albumUploadDisabled => 'Upload is disabled for this album';
@override
String get networkOfflineBanner =>
'No network. Photos will upload automatically when reconnected.';
@override
String get cropTitle => 'Crop image';
@override
String get cropConfirm => 'Done';
@override
String get cropCancel => 'Cancel';
@override
String get cropProcessing => 'Processing…';
@override
String get cropUnsupportedFormat =>
'This image format isn\'t supported yet — please pick JPG/PNG';
}
@@ -604,6 +604,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get queueEmpty => '暂无上传任务';
@override
String get queueEmptyDeviceHint => '上传记录仅保存在本机,卸载重装后不会保留';
@override
String get queueViewServerHistory => '查看服务端历史';
@override
String get queueRetryAllFailed => '一键重试全部失败';
@@ -755,6 +761,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get errorUploadVerifyFailed => '上传核验失败,将自动重试';
@override
String get errorPhotoDuplicate => '照片可能重复:同相册已存在相同内容的照片';
@override
String get captureTitle => '连拍上传';
@@ -825,6 +834,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get uploadPickNone => '未选择照片';
@override
String get pickerDuplicateBlocked => '这张已上传到该相册,无法重复选择';
@override
String get albumManagePhotos => '管理照片';
@@ -998,4 +1010,25 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get close => '关闭';
@override
String get albumUploadDisabled => '该相册已关闭上传,无法添加照片';
@override
String get networkOfflineBanner => '当前无网络连接,照片将在恢复后自动上传';
@override
String get cropTitle => '裁剪图片';
@override
String get cropConfirm => '完成';
@override
String get cropCancel => '取消';
@override
String get cropProcessing => '处理中…';
@override
String get cropUnsupportedFormat => '暂不支持该图片格式,请选择 JPG/PNG';
}
+12 -1
View File
@@ -230,6 +230,8 @@
"queueTimeLast7Days": "近7天",
"queueTimeLast30Days": "近30天",
"queueEmpty": "暂无上传任务",
"queueEmptyDeviceHint": "上传记录仅保存在本机,卸载重装后不会保留",
"queueViewServerHistory": "查看服务端历史",
"queueRetryAllFailed": "一键重试全部失败",
"queueAttemptCount": "已尝试 {count} 次",
"@queueAttemptCount": {
@@ -293,6 +295,7 @@
"errorAlbumUploadDisabled": "相册已关闭上传",
"errorUploadSessionConflict": "上传会话冲突,请重试",
"errorUploadVerifyFailed": "上传核验失败,将自动重试",
"errorPhotoDuplicate": "照片可能重复:同相册已存在相同内容的照片",
"captureTitle": "连拍上传",
"captureCategoryDefault": "默认分类",
"captureSwitchHint": "切换分类仅影响之后拍摄的照片",
@@ -348,6 +351,7 @@
}
},
"uploadPickNone": "未选择照片",
"pickerDuplicateBlocked": "这张已上传到该相册,无法重复选择",
"albumManagePhotos": "管理照片",
"albumTakeFirstPhoto": "拍摄第一张照片",
"albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片",
@@ -427,5 +431,12 @@
"posterDownload": "下载海报图",
"posterSaved": "海报已保存到相册",
"posterSaveFailed": "海报保存失败",
"close": "关闭"
"close": "关闭",
"albumUploadDisabled": "该相册已关闭上传,无法添加照片",
"networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传",
"cropTitle": "裁剪图片",
"cropConfirm": "完成",
"cropCancel": "取消",
"cropProcessing": "处理中…",
"cropUnsupportedFormat": "暂不支持该图片格式,请选择 JPG/PNG"
}
+101 -5
View File
@@ -325,24 +325,115 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
Future<void> _pickFromGallery() async {
final album = _album;
if (album == null) return;
// P0-8:相册上传开关预检——关闭时不入队(避免白做功后 403)。
if (!album.uploadEnabled) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).albumUploadDisabled),
),
);
return;
}
final l10n = AppLocalizations.of(context);
final account = await _currentAccountId();
if (account == null) return;
final paths = await ref.read(multiImagePickerProvider)();
if (account == null || !mounted) return;
// 方案 A:accountId/albumId 传入选图器(点选拦截的判定范围)。
final paths = await ref.read(multiImagePickerProvider)(
context,
accountId: account,
albumId: album.id,
);
if (paths.isEmpty || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final count = await enqueueForUpload(
// P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史
// ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。
final duplicates = await checkDuplicatesUnified(
ref,
accountId: account,
albumId: album.id,
sourcePaths: paths,
);
Set<String> skipPaths = <String>{};
Set<String> forcePaths = <String>{};
if (duplicates != null && duplicates.isNotEmpty && mounted) {
final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (choice == null) return; // 取消。
skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
}
final result = await enqueueForUpload(
ref,
accountId: account,
albumId: album.id,
sourcePaths: paths,
skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
SnackBar(
content: Text(
result.skipped > 0
? '${result.enqueued} 张已入队,${result.skipped} 张重复已跳过'
: l10n.uploadEnqueuedCount(result.enqueued),
),
),
);
}
/// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
/// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n,
List<DuplicateMatch> duplicates,
List<String> allPaths,
) async {
// 重复项按完整源路径精确派生(同 basename 不同目录/扩展名的兄弟
// 文件不误伤,Task 2 评审 #2)。
final dupPaths = duplicates.map((d) => d.sourcePath).toSet();
final nonDupCount = allPaths.length - dupPaths.length;
final choice = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('可能已上传过'),
content: Text(
'${duplicates.length} 张照片可能已上传到该相册'
'${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n'
'${duplicates.take(3).map((d) => d.filename).join('、')}'
'${duplicates.length > 3 ? ' …' : ''}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop('cancel'),
child: const Text('取消'),
),
if (nonDupCount > 0)
TextButton(
onPressed: () => Navigator.of(ctx).pop('skip'),
child: Text('跳过重复,上传 $nonDupCount 张'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop('all'),
child: Text('全部上传 ${allPaths.length} 张'),
),
],
),
);
if (choice == 'cancel' || choice == null) return null;
if (choice == 'skip') {
return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -380,7 +471,11 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 封面:全宽 16:9(无封面同尺寸中性占位)。
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
PhotoThumbnail(
url: album.cover?.url,
aspectRatio: 16 / 9,
maxCacheWidth: 600,
),
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16,
@@ -761,6 +856,7 @@ class _RecentPhotosSection extends StatelessWidget {
itemBuilder: (context, index) => PhotoThumbnail(
url: photos[index].preview?.thumb.url,
aspectRatio: 1,
maxCacheWidth: 400,
),
),
] else
+37 -7
View File
@@ -31,6 +31,7 @@ import '../features/uploads/enqueue.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/section_header.dart';
import '../ui/tokens.dart';
import 'image_crop_page.dart';
/// 创建([existing] 为 null)/ 编辑相册表单。
class AlbumFormPage extends ConsumerStatefulWidget {
@@ -497,21 +498,48 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
],
);
/// 选图后必经裁剪:封面 16:9(表单预览区比例),输出宽 ≤1920。
Future<void> _pickCoverImage() async {
final path = await ref.read(singleImagePickerProvider)();
if (path != null) {
setState(() => _coverFilePath = path);
if (path == null || !mounted) {
return;
}
final cropped = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => ImageCropPage(
sourcePath: path,
aspectRatio: 16 / 9,
maxOutputWidth: 1920,
),
),
);
if (cropped != null && mounted) {
setState(() => _coverFilePath = cropped);
}
}
/// 选图后必经裁剪:宣传图 16:9(观众页横幅,2026-09-29 由 3:1 调整),
/// 输出宽 ≤1920(1920×1080,与封面一致)。
Future<void> _pickBannerImage() async {
final path = await ref.read(singleImagePickerProvider)();
if (path != null) {
setState(() => _bannerFilePath = path);
if (path == null || !mounted) {
return;
}
final cropped = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => ImageCropPage(
sourcePath: path,
aspectRatio: 16 / 9,
maxOutputWidth: 1920,
),
),
);
if (cropped != null && mounted) {
setState(() => _bannerFilePath = cropped);
}
}
/// 宣传图(banner)选择器:3:1 横幅可点击区域;编辑模式显示现有 banner。
/// 宣传图(banner)选择器:16:9 可点击区域(与封面同视觉高度);编辑模式显示现有 banner。
Widget _buildBannerPicker(AppLocalizations l10n) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -522,7 +550,9 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
onTap: _submitting ? null : _pickBannerImage,
borderRadius: BorderRadius.circular(kRadiusInput),
child: Container(
height: 120,
// 与封面预览区同高(16:9 语义下的近似占位;实际裁剪比在
// _pickBannerImage 的 ImageCropPage 参数)。
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
@@ -592,7 +622,7 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
_buildCoverPicker(l10n),
const SizedBox(height: kSpace16),
// ── 宣传图选择(3:1 横幅;编辑模式显示现有 banner)──
// ── 宣传图选择(16:9;编辑模式显示现有 banner)──
_buildBannerPicker(l10n),
const SizedBox(height: kSpace24),
@@ -410,7 +410,9 @@ class _AlbumInviteSheetState extends ConsumerState<AlbumInviteSheet> {
void _onSearchChanged(String text) {
_debounce?.cancel();
final query = text.trim();
if (query.length < 2) {
// 单字符即搜(2026-09-29 与服务端 1..64 bounds 对齐——中文姓氏单字);
// 空串清空结果。
if (query.isEmpty) {
setState(() {
_searching = false;
_results = null;
@@ -568,43 +568,65 @@ class _ViewerImage extends StatefulWidget {
class _ViewerImageState extends State<_ViewerImage> {
bool _autoRefreshScheduled = false;
/// 当前缩放比例(1.0 = 原始;> 1.0 = 放大中,PageView 滑动被禁用)。
/// 通过 [InheritedWidget] 通知父级 PageView 切换 physics。
final ValueNotifier<double> _scaleNotifier = ValueNotifier(1.0);
@override
void dispose() {
_scaleNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Image.network(
widget.imageUrl,
fit: BoxFit.contain,
gaplessPlayback: true,
loadingBuilder: (context, child, progress) => progress == null
? child
: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
errorBuilder: (_, _, _) {
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
if (!_autoRefreshScheduled &&
widget.controller.canRefresh(widget.photoId)) {
_autoRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.controller.refreshPreview(widget.photoId);
});
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: kSpace8),
Text(
l10n.photoPreviewUnavailable,
style: const TextStyle(color: Colors.white54),
),
],
),
);
// P0-2(UI06 分析 §二):双指缩放 + 放大后平移;1x 时手势穿透到
// PageView 左右滑动切页(InteractiveViewer 在内容不溢出时不拦截)。
return InteractiveViewer(
maxScale: 5.0,
minScale: 1.0,
clipBehavior: Clip.none,
onInteractionUpdate: (details) => _scaleNotifier.value = details.scale,
onInteractionEnd: (_) {
// 回弹到 1x 时恢复 PageView 滑动。
if (_scaleNotifier.value <= 1.01) _scaleNotifier.value = 1.0;
},
child: Image.network(
widget.imageUrl,
fit: BoxFit.contain,
gaplessPlayback: true,
loadingBuilder: (context, child, progress) => progress == null
? child
: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
errorBuilder: (_, _, _) {
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
if (!_autoRefreshScheduled &&
widget.controller.canRefresh(widget.photoId)) {
_autoRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.controller.refreshPreview(widget.photoId);
});
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: kSpace8),
Text(
l10n.photoPreviewUnavailable,
style: const TextStyle(color: Colors.white54),
),
],
),
);
},
),
);
}
}
+50 -41
View File
@@ -50,8 +50,8 @@ enum PhotoGridFilter {
final String wire;
/// 本地过滤(服务端无 processingState 查询参数)。
bool get isLocal => this == PhotoGridFilter.processing ||
this == PhotoGridFilter.failed;
bool get isLocal =>
this == PhotoGridFilter.processing || this == PhotoGridFilter.failed;
}
/// 一页数据的请求规格(刷新签名 URL 时按原参数重取该页)。
@@ -83,12 +83,12 @@ class PhotoGridController extends ChangeNotifier {
required Future<Session?> Function() readSession,
required this.albumId,
this.pageSize = 20,
}) // 私有字段不能作 initializing formal(外部调用方无法传私有名),
// 参数名保持公开语义,忽略该风格提示。
// ignore: prefer_initializing_formals
: _api = api,
// ignore: prefer_initializing_formals
_readSession = readSession;
}) // 私有字段不能作 initializing formal(外部调用方无法传私有名),
// 参数名保持公开语义,忽略该风格提示。
// ignore: prefer_initializing_formals
: _api = api,
// ignore: prefer_initializing_formals
_readSession = readSession;
final ApiClient _api;
final Future<Session?> Function() _readSession;
@@ -115,12 +115,16 @@ class PhotoGridController extends ChangeNotifier {
/// 当前筛选下应显示的条目。
List<AdminPhoto> get visibleItems => switch (_filter) {
PhotoGridFilter.processing => _items
.where((p) => p.processingState != PhotoProcessingState.ready)
.toList(growable: false),
PhotoGridFilter.failed => _items
.where((p) => p.processingState == PhotoProcessingState.processingFailed)
.toList(growable: false),
PhotoGridFilter.processing =>
_items
.where((p) => p.processingState != PhotoProcessingState.ready)
.toList(growable: false),
PhotoGridFilter.failed =>
_items
.where(
(p) => p.processingState == PhotoProcessingState.processingFailed,
)
.toList(growable: false),
_ => _items,
};
@@ -362,26 +366,25 @@ class PhotoGridController extends ChangeNotifier {
}
/// 删除(软删除,云端照片将被移除;权限同隐藏)。
Future<void> deletePhoto(AdminPhoto photo) =>
_api.deletePhoto(photo.photoId);
Future<void> deletePhoto(AdminPhoto photo) => _api.deletePhoto(photo.photoId);
AdminPhoto _withRecord(AdminPhoto src, PhotoRecord rec) => AdminPhoto(
uploader: src.uploader,
preview: src.preview,
photoId: rec.photoId,
albumId: rec.albumId,
categoryId: rec.categoryId,
uploaderId: rec.uploaderId,
visibility: rec.visibility,
processingState: rec.processingState,
bytes: rec.bytes,
width: rec.width,
height: rec.height,
shotAt: rec.shotAt,
uploadedAt: rec.uploadedAt,
publishedAt: rec.publishedAt,
likeCount: rec.likeCount,
);
uploader: src.uploader,
preview: src.preview,
photoId: rec.photoId,
albumId: rec.albumId,
categoryId: rec.categoryId,
uploaderId: rec.uploaderId,
visibility: rec.visibility,
processingState: rec.processingState,
bytes: rec.bytes,
width: rec.width,
height: rec.height,
shotAt: rec.shotAt,
uploadedAt: rec.uploadedAt,
publishedAt: rec.publishedAt,
likeCount: rec.likeCount,
);
/// 覆盖 [index] 的页规格(删除导致边界漂移时按近似页处理)。
_PageSpec? _pageForIndex(int index) {
@@ -406,12 +409,12 @@ class PhotoGridController extends ChangeNotifier {
PhotoVisibility? visibility,
int? limit,
}) => _api.listAlbumPhotos(
albumId,
scope: scope,
visibility: visibility,
cursor: cursor,
limit: limit,
);
albumId,
scope: scope,
visibility: visibility,
cursor: cursor,
limit: limit,
);
}
/// 照片管理网格页(从详情页携带 [album] 进入,时间按相册时区展示)。
@@ -654,7 +657,8 @@ class _AlbumPhotosBody extends StatelessWidget {
/// 请求)。
void _maybeLoadMore(int index, int visibleCount) {
final nearEnd = index >= visibleCount - 6;
final localStarved = controller.filter.isLocal &&
final localStarved =
controller.filter.isLocal &&
visibleCount < 9 &&
controller.items.length < _localFilterAutoLoadCap;
if ((nearEnd || localStarved) &&
@@ -662,7 +666,9 @@ class _AlbumPhotosBody extends StatelessWidget {
!controller.loadingMore &&
!controller.loading &&
!controller.loadMoreFailed) {
WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore());
WidgetsBinding.instance.addPostFrameCallback(
(_) => controller.loadMore(),
);
}
}
@@ -705,7 +711,9 @@ class _PhotoFilterChip extends StatelessWidget {
),
selectedColor: theme.colorScheme.primary,
labelStyle: theme.textTheme.labelLarge?.copyWith(
color: selected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
color: selected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(horizontal: kSpace8),
@@ -756,6 +764,7 @@ class _PhotoGridTile extends StatelessWidget {
PhotoThumbnail(
url: photo.preview?.thumb.url,
aspectRatio: 1,
maxCacheWidth: 400,
),
if (badge != null)
Positioned(
+1
View File
@@ -244,6 +244,7 @@ class AlbumCard extends StatelessWidget {
child: PhotoThumbnail(
url: album.cover?.url,
aspectRatio: 16 / 9,
maxCacheWidth: 600,
),
),
const Positioned.fill(
+354 -58
View File
@@ -5,7 +5,9 @@
/// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips
/// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张
/// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄
/// 切换 + 闪光灯(源支持才出现)。入队成功以触觉 + 200ms 浮层轻反馈,
/// 切换 + 闪光灯(源支持才出现)。预览按传感器比例居中(所见即所得,
/// 不裁切不变形);源支持变焦(maxZoom > 1)时捏合缩放 + 双击复位 1×。
/// 入队成功以触觉 + 200ms 浮层轻反馈,
/// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android
/// 原生 WorkManager 承担,见 native_transport.dart)。
///
@@ -37,7 +39,7 @@ abstract class CaptureSource {
/// 拍摄一张(返回源文件)。
Future<XFile> capture();
/// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。
/// 取景预览(按传感器比例居中——所见即所得,不裁切不变形)。
Widget buildPreview();
/// 释放底层资源。
@@ -49,6 +51,15 @@ abstract class CaptureSource {
/// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。
Future<void> setFlashEnabled(bool enabled) async {}
/// 变焦能力下限(默认 1=不支持变焦;子类按硬件覆写)。
double get minZoom => 1;
/// 变焦能力上限(默认 1=不支持变焦;不支持则 UI 不启用手势)。
double get maxZoom => 1;
/// 设置变焦倍数(默认 no-op——能力为 1 时 UI 不启用手势)。
Future<void> setZoom(double level) async {}
}
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
@@ -62,20 +73,33 @@ class CameraPluginSource extends CaptureSource {
throw CameraException('noCamera', 'no camera available');
}
CameraDescription pick() => cameras.firstWhere(
(c) => c.lensDirection ==
(preferFront
? CameraLensDirection.front
: CameraLensDirection.back),
orElse: () => cameras.first,
);
final controller = CameraController(pick(), ResolutionPreset.max,
enableAudio: false);
(c) =>
c.lensDirection ==
(preferFront ? CameraLensDirection.front : CameraLensDirection.back),
orElse: () => cameras.first,
);
final controller = CameraController(
pick(),
ResolutionPreset.max,
enableAudio: false,
);
await controller.initialize();
return CameraPluginSource._(controller);
final source = CameraPluginSource._(controller);
try {
source._minZoom = await controller.getMinZoomLevel();
source._maxZoom = await controller.getMaxZoomLevel();
} on Object {
// 能力探测失败(部分设备/模拟器):保持 1/1,捏合不启用。
}
return source;
}
final CameraController _controller;
double _zoomLevel = 1.0;
double _minZoom = 1;
double _maxZoom = 1;
@override
Future<void> initialize() async {}
@@ -84,27 +108,43 @@ class CameraPluginSource extends CaptureSource {
@override
Widget buildPreview() {
// 纵横比校正(§4 页面 13):预览按传感器尺寸布局,再 cover 铺满——
// CameraPreview 直填父约束会把画面拉扁/拉高。
// 所见即所得(§4 页面 13 修订,决策 ④):按传感器纵向比例居中显示,
// 上下露出深色背景——不再 FittedBox cover 全屏(那会横向裁 ~38% 画面,
// 且预览所见 ≠ takePicture 全幅照片)。
final size = _controller.value.previewSize;
Widget preview = CameraPreview(_controller);
if (size != null && size.width > 0 && size.height > 0) {
preview = FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(width: size.width, height: size.height, child: preview),
preview = Center(
child: AspectRatio(
// 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。
aspectRatio: size.height / size.width,
child: preview,
),
);
}
return preview;
}
@override
double get minZoom => _minZoom;
@override
double get maxZoom => _maxZoom;
@override
Future<void> setZoom(double level) async {
final clamped = level.clamp(minZoom, maxZoom).toDouble();
if (clamped == _zoomLevel) return;
await _controller.setZoomLevel(clamped);
_zoomLevel = clamped;
}
@override
bool get supportsFlash => true;
@override
Future<void> setFlashEnabled(bool enabled) => _controller.setFlashMode(
enabled ? FlashMode.always : FlashMode.off,
);
Future<void> setFlashEnabled(bool enabled) =>
_controller.setFlashMode(enabled ? FlashMode.always : FlashMode.off);
@override
Future<void> dispose() => _controller.dispose();
@@ -113,7 +153,8 @@ class CameraPluginSource extends CaptureSource {
/// 拍摄源工厂 Provider(测试 override 为替身)。
final captureSourceFactoryProvider =
Provider<Future<CaptureSource> Function({bool preferFront})>(
(ref) => CameraPluginSource.create);
(ref) => CameraPluginSource.create,
);
// ── 连拍页 ────────────────────────────────────────────────────────────────
@@ -151,7 +192,8 @@ class CapturePage extends ConsumerStatefulWidget {
enum _CameraPhase { loading, ready, denied, failed }
class _CapturePageState extends ConsumerState<CapturePage> {
class _CapturePageState extends ConsumerState<CapturePage>
with WidgetsBindingObserver {
CaptureSource? _source;
_CameraPhase _phase = _CameraPhase.loading;
@@ -174,19 +216,55 @@ class _CapturePageState extends ConsumerState<CapturePage> {
bool _showEnqueuedFeedback = false;
Timer? _feedbackTimer;
// ── 捏合变焦(Task 7:源 maxZoom > 1 时启用手势) ──────────────────────
/// 捏合起始倍数基线(手势中目标 = 基线 × scale;手势结束置 null)。
double? _pinchBaseZoom;
/// 最近一次已下发到源的变焦值(节流阈值基准;源重开时复位 1)。
double _lastSentZoom = 1;
/// setZoom 在途标记(在途时缓存最新目标,完成后补发——防平台通道乱序
/// 导致徽标与硬件漂移)。
bool _zoomInFlight = false;
double? _zoomQueued;
/// 倍数徽标:显示当前目标倍数(1.0× 格式),停止更新 1s 后隐藏。
double _zoomDisplay = 1;
bool _zoomBadgeVisible = false;
Timer? _zoomBadgeTimer;
@override
void initState() {
super.initState();
// P0-3:监听 App 生命周期——后台释放相机(省电 + 隐私),回前台重开。
WidgetsBinding.instance.addObserver(this);
_initCamera();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_feedbackTimer?.cancel();
_zoomBadgeTimer?.cancel();
_source?.dispose().ignore();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// P0-3:后台 → 释放相机硬件(不释放则摄像头指示灯常亮 + 耗电);
// 回前台 → 重新初始化(沿用当前前后摄/闪光灯偏好)。
if (state == AppLifecycleState.paused && _source != null) {
_source?.dispose().ignore();
_source = null;
setState(() => _phase = _CameraPhase.loading);
} else if (state == AppLifecycleState.resumed && _source == null) {
_initCamera();
}
}
Future<void> _initCamera({bool? preferFront}) async {
final front = preferFront ?? _preferFront;
setState(() => _phase = _CameraPhase.loading);
@@ -200,16 +278,25 @@ class _CapturePageState extends ConsumerState<CapturePage> {
return;
}
_source = source;
_zoomBadgeTimer?.cancel();
setState(() {
_preferFront = front;
_flashOn = false; // 新相机实例回默认关。
// 新源变焦天然回 1(新控制器默认倍数);徽标状态同步清理。
_zoomDisplay = 1;
_zoomBadgeVisible = false;
_phase = _CameraPhase.ready;
});
_pinchBaseZoom = null;
_lastSentZoom = 1;
_zoomQueued = null;
} on Object catch (error) {
if (!mounted) return;
setState(() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed);
setState(
() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed,
);
}
}
@@ -247,23 +334,25 @@ class _CapturePageState extends ConsumerState<CapturePage> {
final source = _source;
if (source == null || _phase != _CameraPhase.ready) return;
final categoryId = _categoryId;
_shotTail = _shotTail.then((_) async {
final photo = await source.capture();
if (!mounted) return;
// 最近一张缩略图(拍摄原文件;缺失走占位)。
setState(() => _lastShotPath = photo.path);
final enqueued = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: categoryId,
sourcePaths: [photo.path],
);
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
if (enqueued > 0 && mounted) _flashEnqueued();
}).catchError((Object _) {
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
});
_shotTail = _shotTail
.then((_) async {
final photo = await source.capture();
if (!mounted) return;
// 最近一张缩略图(拍摄原文件;缺失走占位)。
setState(() => _lastShotPath = photo.path);
final enqueued = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: categoryId,
sourcePaths: [photo.path],
);
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
if (enqueued.enqueued > 0 && mounted) _flashEnqueued();
})
.catchError((Object _) {
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
});
}
/// 入队成功反馈:触觉轻点 + 顶部短暂浮层(200ms 后自动消退)。
@@ -276,25 +365,193 @@ class _CapturePageState extends ConsumerState<CapturePage> {
});
}
// ── 捏合变焦手势 ─────────────────────────────────────────────────────────
void _onPinchStart(ScaleStartDetails details) {
final source = _source;
if (source == null || source.maxZoom <= 1) return;
// 基线取当前已下发倍数(受源能力夹取)——连续捏合从当前倍数续接。
_pinchBaseZoom = _lastSentZoom
.clamp(source.minZoom, source.maxZoom)
.toDouble();
// 单指轻点同样触发 onScaleStart——倍数徽标仅在真捏合(≥2 指)时显示,
// 避免轻点预览也弹 1.0×;基线重定保持无条件(单指转捏合、抬起再捏合
// 均从当前倍数续接)。
if (details.pointerCount >= 2) _showZoomBadge(_pinchBaseZoom!);
}
void _onPinchUpdate(ScaleUpdateDetails details) {
final source = _source;
final base = _pinchBaseZoom;
if (source == null || base == null) return;
final target =
(base * details.scale).clamp(source.minZoom, source.maxZoom).toDouble();
_showZoomBadge(target);
_dispatchZoom(target);
}
void _onPinchEnd(ScaleEndDetails details) {
// 手势结束(含单指抬起触发的重定基)——置空基线,下次手势从当前
// 倍数重新起步(onScaleStart 重新取 _lastSentZoom)。
_pinchBaseZoom = null;
}
/// 双击复位 1.0×。
void _resetZoom() {
final source = _source;
if (source == null || source.maxZoom <= 1) return;
_showZoomBadge(1);
_dispatchZoom(1);
}
/// 变焦下发:节流 + 串行化。onScaleUpdate 每帧触发(60-120/s),不能
/// 每次都打平台通道——与最近已发值差 ≥0.05 才发;在途时缓存最新目标,
/// 完成后补发(防乱序导致徽标与硬件漂移)。
void _dispatchZoom(double target) {
if (_zoomInFlight) {
_zoomQueued = target;
return;
}
if ((target - _lastSentZoom).abs() < 0.05) return;
_sendZoom(target);
}
void _sendZoom(double target) {
final source = _source;
if (source == null) return;
_zoomInFlight = true;
_lastSentZoom = target;
source
.setZoom(target)
.catchError((Object _) {}) // 硬件设置失败:保持当前值(下次手势重试)。
.whenComplete(() {
_zoomInFlight = false;
final queued = _zoomQueued;
_zoomQueued = null;
// 源已更换(翻转/重开相机):丢弃补发。
if (_source != source || queued == null) return;
if ((queued - _lastSentZoom).abs() >= 0.05) _sendZoom(queued);
});
}
/// 倍数徽标:显示当前倍数(四舍五入 1 位小数),停止更新 1s 后隐藏。
void _showZoomBadge([double? level]) {
_zoomBadgeTimer?.cancel();
setState(() {
if (level != null) _zoomDisplay = level;
_zoomBadgeVisible = true;
});
_zoomBadgeTimer = Timer(const Duration(milliseconds: 1000), () {
if (mounted) setState(() => _zoomBadgeVisible = false);
});
}
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
Future<void> _pickFromGallery() async {
final paths = await ref.read(multiImagePickerProvider)();
// 方案 A:目标账号/相册传入选图器(点选拦截的判定范围)。
final paths = await ref.read(multiImagePickerProvider)(
context,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
);
if (paths.isEmpty || !mounted) return;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final count = await enqueueForUpload(
// P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史
// ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。
final duplicates = await checkDuplicatesUnified(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
sourcePaths: paths,
);
Set<String> skipPaths;
Set<String> forcePaths;
if (duplicates != null && duplicates.isNotEmpty && mounted) {
// 批量确认弹窗(单次,非逐张)。
final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (choice == null) return; // 取消——不上传。
skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
} else {
skipPaths = <String>{};
forcePaths = <String>{};
}
final result = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: _categoryId,
sourcePaths: paths,
skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
);
if (!mounted) return;
final uploaded = result.enqueued;
final skipped = result.skipped;
messenger.showSnackBar(
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
SnackBar(
content: Text(
skipped > 0
? '$uploaded 张已入队,$skipped 张重复已跳过'
: l10n.uploadEnqueuedCount(uploaded),
),
),
);
}
/// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
/// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n,
List<DuplicateMatch> duplicates,
List<String> allPaths,
) async {
// 重复项按完整源路径精确派生(同 basename 不同目录/扩展名的兄弟
// 文件不误伤,Task 2 评审 #2)。
final dupPaths = duplicates.map((d) => d.sourcePath).toSet();
final nonDupCount = allPaths.length - dupPaths.length;
final choice = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('可能已上传过'),
content: Text(
'${duplicates.length} 张照片可能已上传到该相册'
'${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n'
'${duplicates.take(3).map((d) => d.filename).join('、')}'
'${duplicates.length > 3 ? ' …' : ''}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop('cancel'),
child: const Text('取消'),
),
if (nonDupCount > 0)
TextButton(
onPressed: () => Navigator.of(ctx).pop('skip'),
child: Text('跳过重复,上传 $nonDupCount 张'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop('all'),
child: Text('全部上传 ${allPaths.length} 张'),
),
],
),
);
if (choice == 'cancel' || choice == null) return null;
if (choice == 'skip') {
return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
}
void _switchCategory(String? categoryId) {
if (categoryId == _categoryId) return;
setState(() => _categoryId = categoryId);
@@ -441,7 +698,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
final source = _source;
switch (_phase) {
case _CameraPhase.ready:
return source!.buildPreview();
return _buildReadyArea(source!);
case _CameraPhase.loading:
return const Center(
child: CircularProgressIndicator(color: Colors.white),
@@ -462,6 +719,46 @@ class _CapturePageState extends ConsumerState<CapturePage> {
);
}
}
/// 就绪态:预览 + 捏合变焦手势(源 maxZoom > 1 时挂)+ 倍数徽标。
Widget _buildReadyArea(CaptureSource source) {
Widget area = source.buildPreview();
if (source.maxZoom > 1) {
area = GestureDetector(
// opaque:预览任意点均可起手势(含按比例居中后的上下留黑区)。
behavior: HitTestBehavior.opaque,
onScaleStart: _onPinchStart,
onScaleUpdate: _onPinchUpdate,
onScaleEnd: _onPinchEnd,
onDoubleTap: _resetZoom,
child: area,
);
}
if (!_zoomBadgeVisible) return area;
return Stack(
fit: StackFit.expand,
children: [area, _buildZoomBadge()],
);
}
/// 变焦倍数徽标(捏合/双击期间显示,不拦截手势)。
Widget _buildZoomBadge() {
return IgnorePointer(
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${_zoomDisplay.toStringAsFixed(1)}×',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
),
);
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────
@@ -488,14 +785,19 @@ class _TopBar extends StatelessWidget {
child: Row(
children: [
IconButton(
tooltip: MaterialLocalizations.of(context)
.backButtonTooltip, // 语义标签;深色界面固定白色前景。
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip, // 语义标签;深色界面固定白色前景。
color: Colors.white,
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
const SizedBox(width: kSpace4),
const Icon(Icons.photo_album_outlined, size: 16, color: Colors.white70),
const Icon(
Icons.photo_album_outlined,
size: 16,
color: Colors.white70,
),
const SizedBox(width: 6),
Expanded(
child: Text(
@@ -609,10 +911,7 @@ class _Badge extends StatelessWidget {
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(color: foreground, fontSize: 12),
),
child: Text(label, style: TextStyle(color: foreground, fontSize: 12)),
);
}
}
@@ -711,11 +1010,8 @@ class _LastShotButton extends StatelessWidget {
content = Image.file(
File(lastShotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.image_outlined,
size: 24,
color: Colors.white,
),
errorBuilder: (_, _, _) =>
const Icon(Icons.image_outlined, size: 24, color: Colors.white),
);
}
return IconButton(
+42 -3
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/locale_controller.dart';
import '../core/api_client.dart';
import '../core/network_status.dart';
import '../features/albums/providers.dart';
import '../features/uploads/engine_scope.dart';
import '../features/uploads/queue_controller.dart';
@@ -38,10 +39,48 @@ class _HomePageState extends ConsumerState<HomePage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final badgeCount = ref.watch(uploadBadgeCountProvider);
// P0-4:离线黄条(网络恢复自动消失)。
final network = ref.watch(networkStatusProvider).value;
final offline = network == NetworkAvailability.offline;
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: const [AlbumsTab(), QueueTab(), SettingsTab()],
body: Column(
children: [
if (offline)
Material(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.wifi_off_rounded,
size: 16,
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.networkOfflineBanner,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
Expanded(
child: IndexedStack(
index: _selectedIndex,
children: const [AlbumsTab(), QueueTab(), SettingsTab()],
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
+307
View File
@@ -0,0 +1,307 @@
/// 封面/宣传图裁剪页:固定纵横比交互裁剪 → 等比缩放上限 → JPEG 落盘。
///
/// 输入 image_picker 选中的本机路径;输出裁剪后的临时文件路径
/// (`{tmp}/album_assets/crop_{ms}.jpg`);取消/失败返回 null。
/// 封面 16:9(输出宽 ≤1920)、宣传图 3:1(输出宽 ≤2400)由调用方给定。
///
/// crop_your_image 2.0.0 为回调式 API:`CropController.crop()` 是 void,
/// 裁剪结果经 `onCropped` 回调(`CropSuccess.croppedImage` /
/// `CropFailure.cause`)。像素级 decode/缩放/编码统一放
/// `Isolate.run`,避免大图卡死主 isolate。
library;
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:crop_your_image/crop_your_image.dart';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../l10n/app_localizations.dart';
import '../ui/tokens.dart';
/// 裁剪页输出目录(应用临时目录下的 album_assets;测试直接可用真实 FS)。
Directory _cropOutputDir() {
final dir = Directory(
'${Directory.systemTemp.path}${Platform.pathSeparator}album_assets',
);
dir.createSync(recursive: true);
return dir;
}
/// 裁剪字节 → 可解码格式预检(魔数嗅探;HEIC/GIF 等走不支持提示)。
///
/// 包内解析失败(`_parseImageWith` 的 compute 抛错)不会回调页面——
/// 页面在交给 [Crop] 前先嗅探,拦下注定解析失败的源。
/// GIF 明确不支持:包内 formatDetector 的兜底分支按 png 解码
/// (`_ => ImageFormat.png`),decodePng(gif) 必然失败 → onStatusChanged
/// 永不 ready → 页面挂死在加载态。
bool _hasSupportedSignature(Uint8List bytes) {
if (bytes.length < 12) {
return false;
}
final jpeg = bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
final png =
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47;
final bmp = bytes[0] == 0x42 && bytes[1] == 0x4D;
final webp =
bytes[0] == 0x52 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46 &&
bytes[3] == 0x46 &&
bytes[8] == 0x57 &&
bytes[9] == 0x45 &&
bytes[10] == 0x42 &&
bytes[11] == 0x50;
return jpeg || png || bmp || webp;
}
/// 裁剪输出编码 + 落盘(只在 isolate 内执行)。
String _encodeCropToJpeg(Uint8List cropped, int? maxOutputWidth) {
final decoded = img.decodeImage(cropped);
if (decoded == null) {
throw img.ImageException('crop output is not decodable');
}
var output = decoded;
if (maxOutputWidth != null && decoded.width > maxOutputWidth) {
output = img.copyResize(decoded, width: maxOutputWidth);
}
final file = File(
'${_cropOutputDir().path}${Platform.pathSeparator}'
'crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
file.writeAsBytesSync(img.encodeJpg(output, quality: 90));
return file.path;
}
/// 封面/宣传图裁剪页。
class ImageCropPage extends StatefulWidget {
const ImageCropPage({
super.key,
required this.sourcePath,
required this.aspectRatio,
this.maxOutputWidth,
});
/// 源图本机路径(image_picker 单选返回)。
final String sourcePath;
/// 目标宽高比(宽/高):封面 16/9、宣传图 3/1。
final double aspectRatio;
/// 输出宽度上限(等比缩放;null=保持裁剪原分辨率)。
final int? maxOutputWidth;
@override
State<ImageCropPage> createState() => _ImageCropPageState();
}
class _ImageCropPageState extends State<ImageCropPage> {
final CropController _controller = CropController();
late final Future<Uint8List> _sourceBytes;
/// 源读取完成且魔数预检通过(可裁剪)。
bool _ready = false;
/// 包内解析完成(CropStatus.ready;完成按钮需等解析完再开放)。
bool _cropReady = false;
/// 源读取失败或预检不通过(HEIC 等不支持的格式)。
bool _unsupported = false;
/// 编码落盘中(遮罩 + 防重复点击)。
bool _processing = false;
@override
void initState() {
super.initState();
_sourceBytes = File(widget.sourcePath).readAsBytes();
_sourceBytes
.then((bytes) {
if (!mounted) {
return;
}
setState(() {
_ready = _hasSupportedSignature(bytes);
_unsupported = !_ready;
});
})
.catchError((Object _) {
if (!mounted) {
return;
}
setState(() => _unsupported = true);
});
}
void _cancel() {
Navigator.of(context).pop(null);
}
void _confirm() {
if (_processing || !_ready || !_cropReady) {
return;
}
setState(() => _processing = true);
// crop() 为 void:结果经 onCropped 回调。
_controller.crop();
}
/// 包内状态回调:解析完成(ready)才允许确认,防 crop() 在解析前触发的断言。
void _onCropStatus(CropStatus status) {
final ready = status == CropStatus.ready;
if (ready == _cropReady) {
return;
}
setState(() => _cropReady = ready);
}
Future<void> _onCropped(CropResult result) async {
if (!mounted) {
return;
}
if (result is! CropSuccess) {
_fail();
return;
}
// 仅捕获可发送值(Uint8List/int),闭包跨 isolate 复制。
final cropped = result.croppedImage;
final maxWidth = widget.maxOutputWidth;
try {
final outputPath = await Isolate.run(
() => _encodeCropToJpeg(cropped, maxWidth),
);
if (!mounted) {
return;
}
Navigator.of(context).pop(outputPath);
} on Object {
if (!mounted) {
return;
}
_fail();
}
}
/// 裁剪/编码失败:解除遮罩 + 提示,不 pop。
void _fail() {
setState(() => _processing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).cropUnsupportedFormat),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// 编码落盘中拦截系统返回/ AppBar 返回(与底部取消禁用一致)。
return PopScope(
canPop: !_processing,
child: Scaffold(
appBar: AppBar(title: Text(l10n.cropTitle)),
body: Stack(
children: [
Positioned.fill(child: _buildBody(l10n)),
if (_processing)
Positioned.fill(
child: AbsorbPointer(
child: ColoredBox(
color: Colors.black54,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: kSpace12),
Text(
l10n.cropProcessing,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
),
),
),
),
],
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace8,
kSpace16,
kSpace12,
),
child: Row(
children: [
Expanded(
child: TextButton(
onPressed: _processing ? null : _cancel,
child: Text(l10n.cropCancel),
),
),
const SizedBox(width: kSpace12),
Expanded(
child: FilledButton(
onPressed: (_ready && _cropReady && !_processing)
? _confirm
: null,
child: Text(l10n.cropConfirm),
),
),
],
),
),
),
),
);
}
Widget _buildBody(AppLocalizations l10n) {
if (_unsupported) {
return Center(
child: Padding(
padding: const EdgeInsets.all(kSpace16),
child: Text(
l10n.cropUnsupportedFormat,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
return FutureBuilder<Uint8List>(
future: _sourceBytes,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done ||
!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
return Crop(
image: snapshot.data!,
controller: _controller,
onCropped: _onCropped,
onStatusChanged: _onCropStatus,
aspectRatio: widget.aspectRatio,
// 交互模式 + 锁定裁剪框:用户平移/缩放图片适配固定比例框。
interactive: true,
fixCropRect: true,
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
progressIndicator: const CircularProgressIndicator(strokeWidth: 2),
);
},
);
}
}
+13 -3
View File
@@ -427,14 +427,24 @@ class _TaskListView extends ConsumerWidget {
),
data: (tasks) {
if (tasks.isEmpty) {
// 页面 15:异常视图空态专用文案(区别于整体队列空态)。
// 页面 15:异常视图空态专用文案(区别于整体队列空态);整体空态
// 附「本机口径说明 + 服务端历史」引导(卸载重装后本机库为空)。
final needsAttentionEmpty =
stateFilter == QueueStateFilter.needsAttention;
return EmptyState(
icon: stateFilter == QueueStateFilter.needsAttention
icon: needsAttentionEmpty
? Icons.task_alt_outlined
: Icons.cloud_upload_outlined,
title: stateFilter == QueueStateFilter.needsAttention
title: needsAttentionEmpty
? l10n.uploadsNoNeedsAttention
: l10n.queueEmpty,
subtitle: needsAttentionEmpty ? null : l10n.queueEmptyDeviceHint,
actionLabel: needsAttentionEmpty
? null
: l10n.queueViewServerHistory,
onAction: needsAttentionEmpty
? null
: () => Navigator.of(context).pushNamed('/server-history'),
);
}
return ListView.builder(
+26 -13
View File
@@ -30,6 +30,7 @@ import '../features/uploads/models.dart';
import '../features/uploads/queue_controller.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/empty_state.dart';
import '../ui/components/photo_thumbnail.dart';
import '../ui/components/error_state.dart';
import '../ui/components/state_badge.dart';
import '../ui/tokens.dart';
@@ -381,20 +382,32 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 契约无图片 URL——中性占位(不伪造缩略图)。
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(kRadiusBadge),
// 行首 48×48:有 preview(变体齐备且未隐藏/删除,服务端现签)→
// 真实缩略图;缺省 → 中性占位图标(不伪造缩略图)。SizedBox 定
// 尺寸——Row 内直接放 AspectRatio 是无界约束。
if (record.photo.preview?.thumb.url case final String thumbUrl)
SizedBox(
width: 48,
height: 48,
child: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusBadge),
child: PhotoThumbnail(url: thumbUrl, aspectRatio: 1),
),
)
else
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(kRadiusBadge),
),
child: Icon(
Icons.cloud_done_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
),
child: Icon(
Icons.cloud_done_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: kSpace12),
Expanded(
child: Column(
+93 -23
View File
@@ -8,6 +8,7 @@
/// 相册未公开时琥珀提示明确「该链接目前无法公开浏览」;URL 文本可长按复制。
library;
import 'dart:async';
import 'dart:ui' as ui;
import 'package:dio/dio.dart';
@@ -27,6 +28,7 @@ import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/photo_thumbnail.dart';
import '../ui/components/segment_tabs.dart';
import '../ui/tokens.dart';
/// 外链打开注入点(生产=url_launcher;测试 override)。
@@ -207,6 +209,7 @@ class ShareQrPage extends ConsumerWidget {
child: PhotoThumbnail(
url: album.cover?.url,
aspectRatio: 16 / 9,
maxCacheWidth: 600,
),
),
const SizedBox(height: kSpace16),
@@ -324,6 +327,9 @@ class ShareQrPage extends ConsumerWidget {
/// 二维码海报弹层:宣传图(banner→cover 兜底)+ 标题 + 时间 + 地址 + 二维码;
/// 「保存海报图」= RepaintBoundary 3x 渲染 PNG → 相册(gal)。
///
/// 弹层内「中文/English」切换海报语言(默认跟 App 语言):内容文案/相册
/// 字段/二维码 `?lang=` 随切换,App 语言设置与弹层 chrome 不受影响。
class _PosterSheet extends ConsumerStatefulWidget {
const _PosterSheet({required this.album});
@@ -333,10 +339,28 @@ class _PosterSheet extends ConsumerStatefulWidget {
ConsumerState<_PosterSheet> createState() => _PosterSheetState();
}
/// 保存状态机:idle → saving →(success 自动复原 / error 留置可重试)。
/// 反馈一律在弹层内呈现——模态弹层遮挡页面 SnackBar,页面级提示不可见。
enum _SaveState { idle, saving, success, error }
class _PosterSheetState extends ConsumerState<_PosterSheet> {
final GlobalKey _posterKey = GlobalKey();
String? _bannerUrl;
bool _saving = false;
/// 海报语言('zh'|'en';打开时刻快照 App 语言前缀,切换只影响海报)。
late String _posterLang =
AppLocalizations.of(context).localeName.toLowerCase().startsWith('zh')
? 'zh'
: 'en';
_SaveState _saveState = _SaveState.idle;
Timer? _saveResetTimer;
@override
void dispose() {
_saveResetTimer?.cancel();
super.dispose();
}
@override
void initState() {
@@ -355,20 +379,23 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
}
Future<void> _save() async {
setState(() => _saving = true);
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
_saveResetTimer?.cancel();
setState(() => _saveState = _SaveState.saving);
try {
final boundary =
_posterKey.currentContext!.findRenderObject()
as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3);
await ref.read(posterSaverProvider)(image);
messenger.showSnackBar(SnackBar(content: Text(l10n.posterSaved)));
if (!mounted) return;
// 成功:按钮翻成功态,2.5s 后自动复原(期间可切语言再存一张)。
setState(() => _saveState = _SaveState.success);
_saveResetTimer = Timer(const Duration(milliseconds: 2500), () {
if (mounted) setState(() => _saveState = _SaveState.idle);
});
} on Object {
messenger.showSnackBar(SnackBar(content: Text(l10n.posterSaveFailed)));
} finally {
if (mounted) setState(() => _saving = false);
// 失败:弹层内红字留置、按钮复位可重试(对齐隐藏照片弹层模式)。
if (mounted) setState(() => _saveState = _SaveState.error);
}
}
@@ -376,9 +403,10 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final localeName = l10n.localeName;
final url = shareAlbumUrl(widget.album, localeName);
final title = albumDisplayTitle(widget.album, localeName);
// 海报内容按海报语言渲染(弹层 chrome 仍跟 App 语言)。
final posterL10n = lookupAppLocalizations(Locale(_posterLang));
final url = shareAlbumUrl(widget.album, _posterLang);
final title = albumDisplayTitle(widget.album, _posterLang);
// 海报日期:日期粒度(UI06 选择器已去时间;展示同口径)。
String posterDate(DateTime utc) {
final wall = tryToAlbumZone(utc, widget.album.timezone) ?? utc.toUtc();
@@ -390,9 +418,9 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
? null
: posterDate(widget.album.endAt!);
final dateRange = end == null ? start : '$start ~ $end';
final venue = albumDisplayVenue(widget.album, localeName);
final venue = albumDisplayVenue(widget.album, _posterLang);
String? addressForPoster(AlbumSummary album) {
for (final t in albumDisplayTranslations(album, localeName)) {
for (final t in albumDisplayTranslations(album, _posterLang)) {
final a = t.address?.trim();
if (a != null && a.isNotEmpty) return a;
}
@@ -425,6 +453,18 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
),
],
),
const SizedBox(height: kSpace8),
// ── 海报语言切换:内容/二维码随之,App 语言不动 ──
SegmentTabs<String>(
value: _posterLang,
onChanged: (lang) => setState(() => _posterLang = lang),
style: SegmentTabsStyle.light,
segments: [
('zh', l10n.localeChinese),
('en', l10n.localeEnglish),
],
),
const SizedBox(height: kSpace12),
// ── 海报(整块 RepaintBoundary;下载即渲染此区域)──
RepaintBoundary(
key: _posterKey,
@@ -519,12 +559,13 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.white,
semanticsLabel: url,
),
),
const SizedBox(height: kSpace12),
Center(
child: Text(
l10n.shareScanHint,
posterL10n.shareScanHint,
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF69778E),
),
@@ -538,18 +579,47 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
),
),
const SizedBox(height: kSpace16),
// ── 下载海报图 ──
// ── 下载海报图:成功=按钮翻成功态自动复原;失败=下行红字可重试 ──
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
onPressed: _saveState == _SaveState.saving ? null : _save,
style: _saveState == _SaveState.success
? FilledButton.styleFrom(
backgroundColor:
theme.brightness == Brightness.light
? kColorSuccess
: kColorSuccessDark,
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(l10n.posterDownload),
: null,
icon: switch (_saveState) {
_SaveState.saving => const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
_SaveState.success => const Icon(
Icons.check_circle_outline,
size: 18,
),
_ => const Icon(Icons.download_outlined, size: 18),
},
label: Text(
_saveState == _SaveState.success
? l10n.posterSaved
: l10n.posterDownload,
),
),
if (_saveState == _SaveState.error) ...[
const SizedBox(height: kSpace8),
Text(
l10n.posterSaveFailed,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.brightness == Brightness.light
? kColorError
: kColorErrorDark,
),
),
],
],
),
),
@@ -15,6 +15,7 @@ class PhotoThumbnail extends StatelessWidget {
required this.url,
required this.aspectRatio,
this.fit = BoxFit.cover,
this.maxCacheWidth,
super.key,
});
@@ -27,6 +28,11 @@ class PhotoThumbnail extends StatelessWidget {
/// 图片适配(默认 cover 裁切;大图查看用 contain)。
final BoxFit fit;
/// 解码宽度上限(P0-6 内存管理):网格/列表传入,限制 Image.network
/// 实际解码的像素宽度——防止 4000px 原图全量解码导致 OOM。
/// null = 不限制(查看器大图等需要完整分辨率的场景)。
final int? maxCacheWidth;
@override
Widget build(BuildContext context) {
if (url == null) {
@@ -40,6 +46,8 @@ class PhotoThumbnail extends StatelessWidget {
child: Image.network(
url!,
fit: fit,
// P0-6:限制解码宽度(网格缩略图无需全量分辨率)。
cacheWidth: maxCacheWidth,
// 帧 ready 前显示中性占位(不是 spinner):布局稳定优先。
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
wasSynchronouslyLoaded || frame != null
+244 -12
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "13.0.0"
archive:
dependency: transitive
description:
name: archive
sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.3.0"
args:
dependency: transitive
description:
@@ -101,26 +109,26 @@ packages:
dependency: transitive
description:
name: camera_android_camerax
sha256: "977f54512d338b3b36d7f82929794886766d8f41ae84a48fe103eb0106ecfe25"
sha256: a9d527411429f97976dc1f97b0dbe57560c9d3ae482e2f45b7d0a2a61c6bf8f9
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.4+8"
version: "0.7.5"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "843247230a583e04742ea1434ce3f91e536760922fab67c72ce46aa34fac3e9d"
sha256: e409a731fad3fca10daa9567cf981720398659e6390f871a4e0f576058ff8940
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.10.3"
version: "0.10.3+1"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498"
sha256: "9365babc7488a9e93d0ed6dcccd917619fb12ea6f9e601519e1191d4890ee2fe"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
version: "2.14.0"
camera_web:
dependency: transitive
description:
@@ -157,10 +165,10 @@ packages:
dependency: transitive
description:
name: cli_util
sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab"
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.5.2"
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -185,6 +193,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.3.1"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
convert:
dependency: transitive
description:
@@ -193,6 +217,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
crop_your_image:
dependency: "direct main"
description:
name: crop_your_image
sha256: "14c8977b11a009dc5e73e0f6522970f93363e38183f1b2ffefe1676dc9c3f49d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
cross_file:
dependency: transitive
description:
@@ -209,6 +241,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
dart_style:
dependency: transitive
description:
@@ -217,6 +257,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.15"
dio:
dependency: "direct main"
description:
@@ -249,6 +297,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.35.0"
extended_image:
dependency: transitive
description:
name: extended_image
sha256: e81d801cd692921f152834e01f39bba8b3d6302c48ad420d499c6062a7e24737
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.1.0"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.1"
fake_async:
dependency: transitive
description:
@@ -331,6 +395,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -422,10 +494,10 @@ packages:
dependency: "direct main"
description:
name: fluwx
sha256: "4b526d281be8560a490bd1b945373a23ab2a12088c8fc5997ffb31cc6fb41082"
sha256: "766439fadfcaf29063cd89b330953d46f395b79a8840e377a060e2f6ada9c187"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.0.2"
version: "6.0.4"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
@@ -463,6 +535,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
html:
dependency: transitive
description:
name: html
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.15.7"
http:
dependency: transitive
description:
@@ -471,6 +551,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.0"
http_multi_server:
dependency: transitive
description:
@@ -487,6 +575,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
image:
dependency: "direct main"
description:
name: image
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.10.1"
image_picker:
dependency: "direct main"
description:
@@ -515,10 +611,10 @@ packages:
dependency: transitive
description:
name: image_picker_ios
sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.8.13+7"
version: "0.8.13+8"
image_picker_linux:
dependency: transitive
description:
@@ -596,6 +692,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
@@ -692,6 +796,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.19.2"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
@@ -764,6 +884,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
photo_manager:
dependency: transitive
description:
name: photo_manager
sha256: "2d00a785b501bf1e5f6180bbef772846e767be11d45d11112b63092ef54a419e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.12.0"
photo_manager_image_provider:
dependency: transitive
description:
name: photo_manager_image_provider
sha256: b6015b67b32f345f57cf32c126f871bced2501236c405aafaefa885f7c821e4f
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
platform:
dependency: transitive
description:
@@ -788,6 +932,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.5.2"
process:
dependency: transitive
description:
@@ -796,6 +948,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.6"
provider:
dependency: transitive
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
@@ -1145,6 +1305,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
video_player:
dependency: transitive
description:
name: video_player
sha256: "8c837b570dccb9ae6ff73d2e0b03c7e708bfefd3bd1194faa7f3e7f200dfc399"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.14.0"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: d27054dea34d748a44f06d433a57005d2aac69944485b0abbaed3303dbcfa3f1
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.12.2"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "2fc56e537324a19eb649fca991aa308ac961b5406292876fa657b062de461fd8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.12.0"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.9.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.0"
visibility_detector:
dependency: transitive
description:
name: visibility_detector
sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.4.0+2"
vm_service:
dependency: transitive
description:
@@ -1193,6 +1401,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.0"
wechat_assets_picker:
dependency: "direct main"
description:
name: wechat_assets_picker
sha256: ccd92f3e8c968e2cbad939bce82b454e798a7669c1e4ab74e871822ce2440b2d
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.1.3"
wechat_picker_library:
dependency: transitive
description:
name: wechat_picker_library
sha256: "5cb61b9aa935b60da5b043f8446fbb9c5077419f20ccc4856bf444aec4f44bc1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.7"
win32:
dependency: transitive
description:
@@ -1209,6 +1433,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
+16
View File
@@ -26,6 +26,8 @@ dependencies:
shared_preferences: ^2.5.3
# 选图 / 连拍
image_picker: ^1.2.3
# 应用内相册选择器(连选/全选/无系统 100 张上限;基于 photo_manager)
wechat_assets_picker: ^10.1.3
camera: ^0.12.1
# 分享二维码
qr_flutter: ^4.1.0
@@ -39,6 +41,19 @@ dependencies:
share_plus: ^13.3.0
gal: ^2.3.3
fluwx: ^6.0.2
connectivity_plus: ^7.3.1
# 封面/宣传图裁剪(纯 Flutter)
crop_your_image: ^2.0.0
# 像素编码输出(裁剪页直接 import)
image: ^4.3.0
flutter_launcher_icons:
android: true
ios: false
image_path: "assets/brand/app-icon.png"
adaptive_icon_background: "#1A73E8"
adaptive_icon_foreground: "assets/brand/app-icon.png"
dev_dependencies:
flutter_test:
@@ -49,6 +64,7 @@ dev_dependencies:
flutter_lints: ^6.0.0
build_runner: ^2.15.1
drift_dev: ^2.35.0
flutter_launcher_icons: ^0.14.0
flutter:
# l10n 代码生成(arb 见 lib/l10n/,配置见 l10n.yaml)
+132
View File
@@ -557,4 +557,136 @@ void main() {
expect(store.session?.token, 'tok-live-1', reason: '旧密码错误不清会话');
expect(unauthorizedCalls, 0, reason: 'password-change 401 不驱逐');
});
group('checkUploadDuplicates:上传查重预检(Task 2,分块)', () {
test('≤500 项单请求:albumId + items 透传,命中含共享 bytes 逐条返回',
() async {
seedSession();
final adapter = FakeAdapter(
(o) => (
200,
{
'duplicates': [
{'index': 1, 'bytes': 2048, 'uploadedAt': '2026-09-25T08:00:00Z'},
{'index': 0, 'bytes': 1024, 'uploadedAt': '2026-09-24T08:00:00Z'},
// 两个请求条目共享同一命中 bytes:契约保证各返回一条。
{'index': 2, 'bytes': 1024, 'uploadedAt': '2026-09-24T08:00:00Z'},
],
},
),
);
final hits = await wired(adapter).checkUploadDuplicates('alb-1', const [
(filename: 'a.jpg', bytes: 1024),
(filename: 'b.jpg', bytes: 2048),
(filename: 'c.jpg', bytes: 1024),
]);
expect(adapter.requests, hasLength(1), reason: '≤500 项不分块');
final req = adapter.requests.single;
expect(req.method, 'POST');
expect(req.path, '/v1/uploads/duplicates/check');
expect(req.headers['Authorization'], 'Bearer tok-live-1');
expect(req.body, {
'albumId': 'alb-1',
'items': [
{'filename': 'a.jpg', 'bytes': 1024},
{'filename': 'b.jpg', 'bytes': 2048},
{'filename': 'c.jpg', 'bytes': 1024},
],
}, reason: '请求体对齐契约 UploadDuplicateCheckRequest');
expect(hits, [
(
index: 1,
bytes: 2048,
uploadedAt: DateTime.utc(2026, 9, 25, 8),
),
(
index: 0,
bytes: 1024,
uploadedAt: DateTime.utc(2026, 9, 24, 8),
),
(
index: 2,
bytes: 1024,
uploadedAt: DateTime.utc(2026, 9, 24, 8),
),
]);
});
test('>500 项分块:1200 项 → 3 个请求(500/500/200)+ 局部下标换算全局',
() async {
seedSession();
final adapter = FakeAdapter((o) {
// 每块只命中块内第 0 项:全局下标 = 分块起点 + 0。
final items = (o.data as Map<String, dynamic>)['items'] as List<dynamic>;
return (
200,
{
'duplicates': [
{
'index': 0,
'bytes': items[0]['bytes'],
'uploadedAt': '2026-09-25T08:00:00Z',
},
],
},
);
});
final items = List.generate(
1200,
(i) => (filename: 'f$i.jpg', bytes: 1000 + i),
);
final hits = await wired(adapter).checkUploadDuplicates('alb-1', items);
expect(adapter.requests, hasLength(3), reason: '契约单批上限 500');
expect(
adapter.requests.map(
(r) => ((r.body as Map<String, dynamic>)['items'] as List).length,
),
[500, 500, 200],
reason: '顺序分块,末块为余量',
);
expect(hits.map((h) => h.index), [0, 500, 1000],
reason: '分块局部下标换算为对原始列表的全局下标');
expect(hits.map((h) => h.bytes), [1000, 1500, 2000]);
});
test('空列表不发起请求(契约 items minItems=1)', () async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
final hits = await wired(adapter).checkUploadDuplicates('alb-1', const []);
expect(hits, isEmpty);
expect(adapter.requests, isEmpty);
});
test('错误响应经 _send 归一为 AppException(404 ALBUM_NOT_FOUND)',
() async {
seedSession();
final adapter = FakeAdapter(
(o) => (
404,
{
'code': 'ALBUM_NOT_FOUND',
'message': 'no such album',
'requestId': 'r1',
},
),
);
final err = await captureError(
() => wired(adapter).checkUploadDuplicates('alb-x', const [
(filename: 'a.jpg', bytes: 1024),
]),
);
expect(
err,
isA<AppException>()
.having((e) => e.code, 'code', ErrorCodes.albumNotFound),
);
});
});
}
@@ -106,6 +106,50 @@ void main() {
});
});
group('mapDioException:P0-1 服务端查重 409 归一(Task 8)', () {
test('409 VALIDATION_FAILED + message PHOTO_DUPLICATE → 归一为 PHOTO_DUPLICATE', () {
final ex = ApiClient.mapDioException(_httpError(409, {
'code': ErrorCodes.validationFailed,
'message': 'PHOTO_DUPLICATE',
'requestId': 'req-dup',
}));
expect(ex.code, ErrorCodes.photoDuplicate);
expect(ex.statusCode, 409);
expect(ex.requestId, 'req-dup', reason: '诊断字段保留');
});
test('契约日后设独立 code=PHOTO_DUPLICATE → 原样透传(归一兼容演进)', () {
final ex = ApiClient.mapDioException(_httpError(409, {
'code': ErrorCodes.photoDuplicate,
'message': 'duplicate photo',
'requestId': 'r',
}));
expect(ex.code, ErrorCodes.photoDuplicate);
expect(ex.statusCode, 409);
});
test('非查重语义不受归一影响(同 code 不同 message / 非 409)', () {
// 409 VALIDATION_FAILED 但 message 不是查重(如幂等参数校验)。
expect(
ApiClient.mapDioException(_httpError(409, {
'code': ErrorCodes.validationFailed,
'message': 'bytes out of range',
'requestId': 'r',
})).code,
ErrorCodes.validationFailed,
);
// 400 VALIDATION_FAILED 是普通校验失败(非查重拒绝路径)。
expect(
ApiClient.mapDioException(_httpError(400, {
'code': ErrorCodes.validationFailed,
'message': 'PHOTO_DUPLICATE',
'requestId': 'r',
})).code,
ErrorCodes.validationFailed,
);
});
});
group('mapDioException:网络层(客户端伪码)', () {
test('连接失败 → NETWORK_ERROR', () {
final ex = ApiClient.mapDioException(
@@ -13,7 +13,7 @@ void main() {
test('UTC 瞬间按 Asia/Shanghai 展示(intl 格式化)', () {
final utc = DateTime.utc(2026, 9, 18, 6, 30);
expect(formatAlbumDateTime(utc, 'Asia/Shanghai', 'en'), '2026/9/18 14:30');
expect(formatAlbumDateTime(utc, 'Asia/Shanghai', 'en'), '2026/9/18');
});
test('非 Asia/Shanghai 时区亦按各自墙钟展示(America/New_York)', () {
@@ -21,13 +21,13 @@ void main() {
// 9 月为纽约夏令时 UTC-4 → 02:30。
expect(
formatAlbumDateTime(utc, 'America/New_York', 'en'),
'2026/9/18 02:30',
'2026/9/18',
);
});
test('无效时区回退 UTC 展示', () {
final utc = DateTime.utc(2026, 9, 18, 6, 30);
expect(formatAlbumDateTime(utc, 'Mars/Olympus', 'en'), '2026/9/18 06:30');
expect(formatAlbumDateTime(utc, 'Mars/Olympus', 'en'), '2026/9/18');
});
test('墙钟 → UTC 往返一致(表单提交与回显)', () {
@@ -0,0 +1,494 @@
/// checkDuplicatesUnified 单元测试(Task 2 评审 #1/#3)+ 选图点选拦截器
/// [PickerDuplicateGate] 五态用例(方案 A)。
///
/// 页级测试覆盖弹窗三态;本文件钉住合并语义:同一文件被本机 TaskStore
/// 与服务器历史同时命中 → 单条结果(不重复呈现)且本地 uploadedAt 优先。
/// 经 Consumer 捕获 WidgetRef 驱动真实函数——drift/文件 stat 均真实异步,
/// 整体跑在 runAsync(真实事件循环)窗口内(同页级测试的 real-io 模式;
/// fake-async 区内不做真实异步 IO)。
///
/// 拦截器用例直接构造 [PickerDuplicateGate](构造注入真实内存 TaskStore +
/// dio 打桩 ApiClient,同 checkDuplicatesUnified 的替身面);资产事实用
/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。
library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'
show BuildContext, Builder, MaterialApp, Scaffold, ScaffoldMessenger,
SnackBar;
import 'package:flutter/widgets.dart' show SizedBox;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/enqueue.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import '../../helpers/fakes.dart';
/// JPEG 魔数最小字节(同页级测试)。
const List<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
void main() {
testWidgets('本机∪服务器:同文件双命中 → 单条结果 + sourcePath + 本地时间优先',
(tester) async {
final session = FakeSessionStore()
..session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
final store = TaskStore.memory();
final tmp = Directory.systemTemp.createTempSync('enqueue-test-');
addTearDown(() async {
await store.close();
try {
tmp.deleteSync(recursive: true);
} on Object {
// Windows 句柄残留容忍(同页级测试)。
}
});
// 层 2 命中源:服务器预检命中 pick1(index 0;uploadedAt 早于本地)。
final adapter = FakeAdapter(
(o) => (
200,
{
'duplicates': [
{
'index': 0,
'bytes': kJpgBytes.length,
'uploadedAt': '2020-01-01T00:00:00Z',
},
],
},
),
);
final client = ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
late WidgetRef capturedRef;
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: session,
taskStore: store,
client: client,
),
],
child: Consumer(
builder: (context, ref, _) {
capturedRef = ref;
return const SizedBox();
},
),
),
);
// 本批照片(同步写盘;pick2 字节不同 = 两层的非命中)。
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')
..writeAsBytesSync([...kJpgBytes, 0x00]);
// 层 1 命中源 + 统一查重:真实异步链整体在真实事件循环内完成。
final seededAt = DateTime.utc(2026, 9, 20, 12);
late final DateTime seedCreatedAt;
late List<DuplicateMatch>? result;
await tester.runAsync(() async {
final seed = await store.create(
UploadTaskDraft(
id: 'task-seed',
accountId: 'alice',
albumId: 'alb-1',
clientTaskId: 'ct-seed',
sourceFilePath: '${tmp.path}/seed.jpg',
filename: 'pick1.jpg',
bytes: kJpgBytes.length,
contentType: 'image/jpeg',
),
now: seededAt,
);
for (final state in [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.completed,
]) {
await store.updateState(seed.id, state);
}
seedCreatedAt = seed.createdAt;
result = await checkDuplicatesUnified(
capturedRef,
accountId: 'alice',
albumId: 'alb-1',
sourcePaths: [pick1.path, pick2.path],
);
});
expect(result, isNotNull, reason: '双命中至少一条');
final matches = result!;
expect(
matches, hasLength(1), reason: '同一文件本地+服务器双命中合并为单条(不重复呈现)');
expect(
matches.single.sourcePath,
pick1.path,
reason: '重复项携带完整源路径(路径精确派生 dupPaths 的依据)',
);
expect(
matches.single.uploadedAt,
seedCreatedAt,
reason: '本地历史时间优先(服务器回显 2020 不覆盖本地 2026-09-20)',
);
expect(
adapter.requests,
hasLength(1),
reason: '服务器预检发起(≤500 项单请求)',
);
expect(
(adapter.requests.single.body as Map<String, dynamic>)['items'],
hasLength(2),
reason: '两张均送预检(含本地已命中的那张——union 语义不做短路)',
);
});
group('选图点选拦截器 PickerDuplicateGate(v2 硬封锁:仅本机判定)', () {
/// 宿主树:MaterialApp(含根 ScaffoldMessenger + l10n)——SnackBar 提示
/// 与文案断言的载体;测试环境默认 en locale(同页级测试)。
Future<void> pumpGateHost(
WidgetTester tester,
void Function(BuildContext) capture,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) {
capture(context);
return const SizedBox();
},
),
),
),
);
}
/// 测试资产事实(fileSize 惰性求值——gate 缓存未命中才调用)。
PickerAssetFact gateAsset({
String id = 'asset-1',
String? title = 'pick1.jpg',
required int bytes,
}) =>
(
id: id,
title: title,
fileSize: () async => bytes,
);
/// 种子:同账号+相册的已完成任务(本机命中源;runAsync 区内调用)。
Future<void> seedCompletedTask(
TaskStore store,
String filename,
int bytes, {
String accountId = 'alice',
String albumId = 'alb-1',
}) async {
final seed = await store.create(
UploadTaskDraft(
id: 'task-seed-$accountId-$albumId',
accountId: accountId,
albumId: albumId,
clientTaskId: 'ct-seed-$accountId-$albumId',
sourceFilePath: '/tmp/seed.jpg',
filename: filename,
bytes: bytes,
contentType: 'image/jpeg',
),
);
for (final state in [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.completed,
]) {
await store.updateState(seed.id, state);
}
}
/// 拦截提示收尾:等 SnackBar 挂载 → 断言由调用方完成 → 隐藏并冲刷,
/// 避免 4s 自动关闭 timer 残留到 testWidgets 收尾。
Future<void> drainBlockedSnackBar(WidgetTester tester, BuildContext ctx) async {
await tester.pump();
ScaffoldMessenger.of(ctx).hideCurrentSnackBar();
await tester.pumpAndSettle();
}
testWidgets('① 本机命中 → 每次点击均拒选(无强制活口)+ 提示不堆积',
(tester) async {
final store = TaskStore.memory();
addTearDown(() => store.close());
late BuildContext ctx;
await pumpGateHost(tester, (c) => ctx = c);
final gate = PickerDuplicateGate(
store: store,
accountId: 'alice',
albumId: 'alb-1',
);
final asset = gateAsset(bytes: kJpgBytes.length);
late List<bool> verdicts;
await tester.runAsync(() async {
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
verdicts = [
await gate.decideFact(ctx, asset, false),
await gate.decideFact(ctx, asset, false),
await gate.decideFact(ctx, asset, false),
];
});
expect(
verdicts,
everyElement(isFalse),
reason: 'v2 硬封锁:连点三次均拒选(二次点击强制已移除)',
);
// 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 新文案)。
await tester.pump();
expect(find.byType(SnackBar), findsOneWidget);
expect(
find.text('This photo has already been uploaded to this album'),
findsOneWidget,
);
// 不堆积:快速点另一个重复资产 → 旧条清除后只留一条(无 N×4s 队列)。
late bool otherBlocked;
await tester.runAsync(() async {
otherBlocked = await gate.decideFact(
ctx,
gateAsset(id: 'asset-2', bytes: kJpgBytes.length),
false,
);
});
expect(otherBlocked, isFalse, reason: '另一重复资产同样拦下');
await tester.pumpAndSettle();
expect(
find.text('This photo has already been uploaded to this album'),
findsOneWidget,
reason: '呈现前清旧条——当前仅一条提示',
);
ScaffoldMessenger.of(ctx).hideCurrentSnackBar();
await tester.pumpAndSettle();
expect(
find.text('This photo has already been uploaded to this album'),
findsNothing,
reason: '隐藏后无第二条在队列等待(不堆积)',
);
});
testWidgets('② 非重复(bytes/文件名/相册/账号 任一不同)→ 放行', (
tester,
) async {
final store = TaskStore.memory();
addTearDown(() => store.close());
late BuildContext ctx;
await pumpGateHost(tester, (c) => ctx = c);
final gate = PickerDuplicateGate(
store: store,
accountId: 'alice',
albumId: 'alb-1',
);
await tester.runAsync(() async {
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
});
late bool diffBytes;
late bool diffName;
late bool diffAlbum;
late bool diffAccount;
await tester.runAsync(() async {
diffBytes = await gate.decideFact(
ctx,
gateAsset(id: 'a-bytes', bytes: 12345),
false,
);
diffName = await gate.decideFact(
ctx,
gateAsset(id: 'a-name', title: 'other.jpg', bytes: kJpgBytes.length),
false,
);
// 判定范围(相册/账号)不同 → 各自构造 gate 验证隔离。
diffAlbum = await PickerDuplicateGate(
store: store,
accountId: 'alice',
albumId: 'alb-2',
).decideFact(
ctx,
gateAsset(id: 'a-album', bytes: kJpgBytes.length),
false,
);
diffAccount = await PickerDuplicateGate(
store: store,
accountId: 'bob',
albumId: 'alb-1',
).decideFact(
ctx,
gateAsset(id: 'a-account', bytes: kJpgBytes.length),
false,
);
});
expect(diffBytes, isTrue, reason: '字节数不同 → 非重复');
expect(diffName, isTrue, reason: '文件名不同 → 非重复');
expect(diffAlbum, isTrue, reason: '其它相册的历史不算本相册重复');
expect(diffAccount, isTrue, reason: '其它账号的历史不算本人重复');
});
testWidgets('③ in-flight 共享:同一资产并发连点只判定一次', (tester) async {
final store = TaskStore.memory();
addTearDown(() => store.close());
late BuildContext ctx;
await pumpGateHost(tester, (c) => ctx = c);
final gate = PickerDuplicateGate(
store: store,
accountId: 'alice',
albumId: 'alb-1',
);
var sizeCalls = 0;
final asset = (
id: 'asset-1',
title: 'pick1.jpg' as String?,
fileSize: () async {
sizeCalls++;
return kJpgBytes.length;
},
);
late List<bool> verdicts;
await tester.runAsync(() async {
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
verdicts = await Future.wait([
gate.decideFact(ctx, asset, false),
gate.decideFact(ctx, asset, false),
]);
});
expect(verdicts, everyElement(isFalse), reason: '并发两次均按重复拦下');
expect(
sizeCalls,
1,
reason: '判定缓存共享 in-flight Future——连点只查一次(清偿登记遗留)',
);
await drainBlockedSnackBar(tester, ctx);
});
testWidgets('④ title 为空(iOS 场景)→ 放行(本机无从匹配)', (
tester,
) async {
final store = TaskStore.memory();
addTearDown(() => store.close());
late BuildContext ctx;
await pumpGateHost(tester, (c) => ctx = c);
final gate = PickerDuplicateGate(
store: store,
accountId: 'alice',
albumId: 'alb-1',
);
await tester.runAsync(() async {
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
});
late bool verdict;
await tester.runAsync(() async {
verdict = await gate.decideFact(
ctx,
gateAsset(id: 'a-null', title: null, bytes: kJpgBytes.length),
false,
);
});
expect(verdict, isTrue,
reason: 'title 空 → 本机半场无从匹配 → 放行(服务器判定在入队弹窗)');
});
testWidgets('⑤ fileSize 获取失败 → 放行(判定故障不挡选图)', (
tester,
) async {
final store = TaskStore.memory();
addTearDown(() => store.close());
late BuildContext ctx;
await pumpGateHost(tester, (c) => ctx = c);
final gate = PickerDuplicateGate(
store: store,
accountId: 'alice',
albumId: 'alb-1',
);
late bool verdict;
await tester.runAsync(() async {
verdict = await gate.decideFact(
ctx,
(
id: 'asset-1',
title: 'pick1.jpg',
fileSize: () async => throw StateError('size failed'),
),
false,
);
});
expect(verdict, isTrue, reason: 'fileSize 失败 → 放行');
});
testWidgets('⑥ 取消选择恒放行且零查询;再点选仍按重复拦下', (tester) async {
final store = TaskStore.memory();
addTearDown(() => store.close());
late BuildContext ctx;
await pumpGateHost(tester, (c) => ctx = c);
final gate = PickerDuplicateGate(
store: store,
accountId: 'alice',
albumId: 'alb-1',
);
var sizeCalls = 0;
final asset = (
id: 'asset-1',
title: 'pick1.jpg' as String?,
fileSize: () async {
sizeCalls++;
return kJpgBytes.length;
},
);
late bool deselect;
late bool reselect;
await tester.runAsync(() async {
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
deselect = await gate.decideFact(ctx, asset, true);
reselect = await gate.decideFact(ctx, asset, false);
});
expect(deselect, isTrue, reason: '取消选择(selected=true)恒放行');
expect(sizeCalls, 1, reason: '仅再点选触发一次判定;取消路径零查询');
expect(reselect, isFalse, reason: '再点选仍按重复拦下(硬封锁)');
await drainBlockedSnackBar(tester, ctx);
});
});
}
@@ -47,11 +47,15 @@ void main() {
return f;
}
UploadPreparer preparer({int maxBytes = UploadPreparer.kMaxUploadBytes}) =>
UploadPreparer preparer({
int maxBytes = UploadPreparer.kMaxUploadBytes,
int maxConcurrentPrepares = kMaxConcurrentPrepares,
}) =>
UploadPreparer(
store,
baseDirProvider: () async => managedBase,
maxBytes: maxBytes,
maxConcurrentPrepares: maxConcurrentPrepares,
);
group('prepare(快乐路径)', () {
@@ -120,6 +124,26 @@ void main() {
await p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path);
expect(enqueued, 1);
});
test('force 透传(Task 8):缺省 false,force=true 落到任务行', () async {
final src = sourceFile('force.jpg', _jpgBytes);
final p = preparer();
final normal = await p.prepare(
accountId: 'alice',
albumId: 'alb-1',
sourcePath: src.path,
);
expect(normal.force, isFalse, reason: '缺省不强传');
final forced = await p.prepare(
accountId: 'alice',
albumId: 'alb-1',
sourcePath: src.path,
force: true,
);
expect(forced.force, isTrue, reason: '弹窗「全部上传」的强传标记落库');
});
});
group('prepare(预检失败 → preparationFailed)', () {
@@ -216,9 +240,106 @@ void main() {
});
});
group('串行执行(连拍防 IO 风暴)', () {
test('并发 prepare 按提交顺序串行完成,各自独立成行', () async {
final p = preparer();
group('有界并发执行(连拍防 IO 风暴的上限钳位)', () {
/// 构造带并发探针的准备器:baseDirProvider 在 _prepare 内被 await,
/// 其重叠度是「在途 prepare 数」的可观测下界(且受信号量上限约束)。
(UploadPreparer, int Function()) probePreparer(int limit) {
var active = 0;
var peak = 0;
final p = UploadPreparer(
store,
baseDirProvider: () async {
active++;
if (active > peak) peak = active;
await Future<void>.delayed(const Duration(milliseconds: 30));
active--;
return managedBase;
},
maxConcurrentPrepares: limit,
);
return (p, () => peak);
}
test('并发 prepare 峰值 = maxConcurrentPrepares(≤上限且真并行),9 任务全部 queued', () async {
final (p, peakOf) = probePreparer(3);
final srcs = [
for (var i = 0; i < 9; i++)
sourceFile('par-$i.jpg', _jpgBytes + [i]),
];
final tasks = await Future.wait([
for (final src in srcs)
p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path),
]);
// 全部入队、ID/幂等键互不相同、副本都在。
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
expect(tasks.map((t) => t.id).toSet().length, 9);
expect(tasks.map((t) => t.clientTaskId).toSet().length, 9);
for (final t in tasks) {
expect(File(t.sourceFilePath).existsSync(), isTrue);
}
// 峰值恰为 3:≤3 证明上限钳位(防连拍 IO 风暴初衷保留);
// 达到 3 证明未退化为串行。
expect(peakOf(), 3);
});
test('maxConcurrentPrepares=1 等价旧串行行为:峰值 1、任务仍全部成功', () async {
final (p, peakOf) = probePreparer(1);
final srcs = [
for (var i = 0; i < 4; i++)
sourceFile('ser-$i.jpg', _jpgBytes + [i]),
];
final tasks = await Future.wait([
for (final src in srcs)
p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path),
]);
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
expect(peakOf(), 1, reason: '并发 1 时任意时刻在途 prepare 至多 1(串行)');
});
test('maxConcurrentPrepares<1 钳位为 1(0 不死锁、串行等价)', () async {
final (p, peakOf) = probePreparer(0);
final srcs = [
for (var i = 0; i < 3; i++)
sourceFile('clamp-$i.jpg', _jpgBytes + [i]),
];
final tasks = await Future.wait([
for (final src in srcs)
p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path),
]);
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
expect(peakOf(), 1, reason: '运行时 <1 按 1 处理:峰值 1(不死锁、不并行)');
});
test('失败与抛错操作都释放并发名额(cap=1 后续 prepare 不被卡死)', () async {
final p = preparer(maxConcurrentPrepares: 1);
final src = sourceFile('after-error.jpg', _jpgBytes);
// 业务失败:源不存在 → preparationFailed(正常返回、不抛)。
final failed = await p.prepare(
accountId: 'alice',
albumId: 'alb-1',
sourcePath: '${sourceDir.path}/missing.jpg',
);
expect(failed.state, UploadTaskState.preparationFailed);
// 真错误:retry 不存在的任务 → StateError 穿过 _runBounded——
// 钉死 whenComplete 语义(若改成 then,此处名额泄漏)。
await expectLater(
p.retry(accountId: 'alice', taskId: 'no-such-task', sourcePath: src.path),
throwsA(isA<StateError>()),
);
// 名额已释放:后续 prepare 正常完成(泄漏则卡死,超时快速失败)。
final task = await p
.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path)
.timeout(const Duration(seconds: 5));
expect(task.state, UploadTaskState.queued);
});
});
group('串行回归(maxConcurrentPrepares=1 等价旧全局串行队列)', () {
test('并发=1:prepare 按提交顺序串行完成,各自独立成行', () async {
final p = preparer(maxConcurrentPrepares: 1);
final srcs = [
for (var i = 0; i < 3; i++)
sourceFile('burst-$i.jpg', _jpgBytes + [i]),
@@ -234,7 +355,7 @@ void main() {
for (final t in tasks) {
expect(File(t.sourceFilePath).existsSync(), isTrue);
}
// createdAt 严格递增(串行执行的间接证据:无交叉写库)。
// createdAt 非降(并发=1 串行的间接证据:无交叉写库)。
final stamps = tasks.map((t) => t.createdAt).toList();
for (var i = 1; i < stamps.length; i++) {
expect(!stamps[i].isBefore(stamps[i - 1]), isTrue);
@@ -11,6 +11,10 @@
/// - datetime 文本落库的亚秒精度。
library;
import 'dart:io';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
@@ -26,6 +30,7 @@ UploadTaskDraft draft({
String filename = 'IMG_0001.jpg',
int bytes = 1024,
String contentType = 'image/jpeg',
bool force = false,
}) {
return UploadTaskDraft(
id: id,
@@ -37,6 +42,7 @@ UploadTaskDraft draft({
filename: filename,
bytes: bytes,
contentType: contentType,
force: force,
);
}
@@ -76,6 +82,10 @@ Future<UploadTask> advanceTo(
}
void main() {
// 迁移组在同一测试内另建独立执行器的库(与 setUp 的内存库并存)——
// drift 多实例告警在此是误报(见 helpers/fakes.dart 同款处理)。
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
late TaskStore store;
setUp(() {
@@ -103,6 +113,13 @@ void main() {
expect(task.bytes, 1024);
expect(task.createdAt, createdAt);
expect(task.updatedAt, createdAt);
expect(task.force, isFalse, reason: '缺省不强传(服务端查重正常裁决)');
});
test('force=true 落库(弹窗「全部上传」确认强传的任务级标记)', () async {
final task = await store.create(draft(force: true));
expect(task.force, isTrue);
expect((await store.get('alice', 'task-0001'))!.force, isTrue);
});
test('datetime 文本落库保住亚秒/微秒精度,且本地输入统一 UTC 落库', () async {
@@ -541,4 +558,116 @@ void main() {
expect(await store.delete('task-0001'), 0, reason: '重复删除返回 0');
});
});
group('schema 迁移(v1 → v2:force 列,Task 8)', () {
/// v1 底库种子(建表无 force 列 + legacy 行 + user_version=1)。语句
/// 拆列供内存/文件两种打开方式复用(raw 类型由 setup 上下文推断,
/// 免引 sqlite3 传递依赖)。
const List<String> v1SeedStatements = <String>[
'''CREATE TABLE IF NOT EXISTS "upload_tasks" (
"id" TEXT NOT NULL PRIMARY KEY,
"account_id" TEXT NOT NULL,
"album_id" TEXT NOT NULL,
"category_id" TEXT NULL,
"client_task_id" TEXT NOT NULL,
"source_file_path" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"bytes" INTEGER NOT NULL,
"content_type" TEXT NOT NULL,
"state" TEXT NOT NULL,
"attempt" INTEGER NOT NULL DEFAULT 0,
"next_retry_at" TEXT NULL,
"server_upload_id" TEXT NULL,
"server_photo_id" TEXT NULL,
"last_error" TEXT NULL,
"cleaned_at" TEXT NULL,
"created_at" TEXT NOT NULL,
"updated_at" TEXT NOT NULL
);''',
'''INSERT INTO upload_tasks (
"id", "account_id", "album_id", "client_task_id",
"source_file_path", "filename", "bytes", "content_type",
"state", "created_at", "updated_at"
) VALUES (
'task-legacy', 'alice', 'alb-1', 'ct-legacy',
'/managed/uploads/alb-1/legacy.jpg', 'IMG_LEGACY.jpg', 2048,
'image/jpeg', 'completed',
'2026-09-01T00:00:00.000Z', '2026-09-01T00:01:00.000Z'
);''',
'PRAGMA user_version = 1;',
];
void runSeed(raw) {
for (final statement in v1SeedStatements) {
raw.execute(statement);
}
}
/// v1 底库(无 force 列)+ 一条既有行,user_version=1——升级后打开。
UploadTaskDb legacyV1Db() =>
UploadTaskDb(NativeDatabase.memory(setup: runSeed));
test('旧库升级:既有行保留、force=false 默认、新列可写', () async {
final db = legacyV1Db();
final migrated = TaskStore(db);
addTearDown(migrated.close);
final legacy = await migrated.get('alice', 'task-legacy');
expect(legacy, isNotNull, reason: '升级不丢既有行');
expect(legacy!.state, UploadTaskState.completed);
expect(legacy.filename, 'IMG_LEGACY.jpg');
expect(legacy.bytes, 2048);
expect(legacy.force, isFalse, reason: 'v1 行无 force 列:迁移默认 false');
// 升级后新任务可写 force=true(列已由迁移补齐)。
final created = await migrated.create(
draft(id: 'task-new', clientTaskId: 'ct-new', force: true),
);
expect(created.force, isTrue);
});
test('降级重开后再升级:force 列已存在不重复补列(旧 APK 覆盖安装防护)',
() async {
final tmp = await Directory.systemTemp.createTemp('store-mig-');
addTearDown(() async {
try {
await tmp.delete(recursive: true);
} on Object {
// Windows 句柄延迟释放:残留交由系统临时目录清理。
}
});
final dbFile = File('${tmp.path}/upload_tasks.sqlite');
// ① 新 APK 打开 v1 底库:升级补 force 列(user_version → 2)。
final first = TaskStore(
UploadTaskDb(NativeDatabase(dbFile, setup: runSeed)),
);
expect((await first.get('alice', 'task-legacy'))!.force, isFalse,
reason: '前置:v1 → v2 升级成功');
await first.close();
// ② 模拟旧 APK 覆盖安装后的降级打开残留态:drift 降级路径不删列、
// 只把 user_version 写回 1(force 列留存)——setup 直接置 1。
// ③ 再装回新 APK:from=1 重跑 onUpgrade——不得因列已存在抛
// 「duplicate column name: force」致库打不开(测试服务器工作流:
// 测试员旧包覆盖装新包的常态路径)。
final second = TaskStore(
UploadTaskDb(
NativeDatabase(
dbFile,
setup: (raw) => raw.execute('PRAGMA user_version = 1;'),
),
),
);
addTearDown(second.close);
final legacy = await second.get('alice', 'task-legacy');
expect(legacy, isNotNull, reason: '降级-重升级循环不丢既有行');
expect(legacy!.filename, 'IMG_LEGACY.jpg');
final created = await second.create(
draft(id: 'task-after', clientTaskId: 'ct-after', force: true),
);
expect(created.force, isTrue, reason: '重升级后新列仍可写');
});
});
}
@@ -71,6 +71,10 @@ class Harness {
int initNetworkFailures = 0;
int initUnauthorized = 0;
int initAlbumDisabled = 0;
/// initUpload 查重 409 次数(Task 8:wire 体复用 VALIDATION_FAILED code、
/// message=PHOTO_DUPLICATE)。
int initDuplicateRejects = 0;
int completeVerifyFailures = 0;
int completeNetworkFailures = 0;
int putNetworkFailures = 0;
@@ -124,6 +128,19 @@ class Harness {
initAlbumDisabled--;
return (409, errorBody(ErrorCodes.albumUploadDisabled));
}
if (initDuplicateRejects > 0) {
initDuplicateRejects--;
// P0-1 契约现状:查重拒绝 409 的 code 复用 VALIDATION_FAILED,
// 靠 message=PHOTO_DUPLICATE 识别(Task 8 客户端归一依据)。
return (
409,
<String, dynamic>{
'code': ErrorCodes.validationFailed,
'message': 'PHOTO_DUPLICATE',
'requestId': 'req-dup',
},
);
}
if (initUnauthorized > 0) {
initUnauthorized--;
return (401, errorBody(ErrorCodes.authTokenExpired));
@@ -218,6 +235,7 @@ class Harness {
String? categoryId,
String clientTaskId = 'ct-default',
DateTime? createdAt,
bool force = false,
}) async {
final draft = UploadTaskDraft(
id: 'task-$clientTaskId',
@@ -229,6 +247,7 @@ class Harness {
filename: file.uri.pathSegments.last,
bytes: await file.length(),
contentType: 'image/jpeg',
force: force,
);
final task = await store.create(draft, now: createdAt);
return store.updateState(
@@ -399,6 +418,8 @@ void main() {
expect(body['bytes'], 1024);
expect(body['contentType'], 'image/jpeg');
expect(body['sourceDeviceId'], _kDeviceId);
expect(body.containsKey('force'), isFalse,
reason: '非强传任务不下发 force(缺省语义)');
// PUT:预签名 URL + 凭证头 + Content-Length;无 Authorization
// (裸 Dio——Bearer 会破坏 MinIO 预签名)。
@@ -417,6 +438,24 @@ void main() {
expect(progress[seeded.id]?.last, 1024);
});
test('force 任务:init 请求体携带 force:true(Task 8 查重强传透传)',
() async {
final file = photoFile('force-1.jpg');
engine = h.buildEngine();
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(
file,
clientTaskId: 'ct-force-1',
force: true,
);
await cycle();
expect((await task(seeded.id)).state, UploadTaskState.completed);
expect(h.initBodies.single['force'], isTrue,
reason: '弹窗「全部上传」确认的强传必须到达服务端');
});
test('与准备器集成:preparing → queued → completed(受管副本上传)',
() async {
final src = photoFile('src.jpg');
@@ -621,6 +660,31 @@ void main() {
expect(h.initBodies.length, initCount, reason: '无自动重试');
});
test('409 PHOTO_DUPLICATE(服务端查重拒绝)→ retryableFailed 不自动重试',
() async {
final file = photoFile('dup.jpg');
engine = h.buildEngine();
engine.start('alice', period: const Duration(hours: 1));
h.initDuplicateRejects = 99; // 永远 409(重试同样被拒)。
final seeded = await h.seedTask(file, clientTaskId: 'ct-dup');
await cycle();
final t = await task(seeded.id);
expect(t.state, UploadTaskState.retryableFailed);
// 归一后 lastError=PHOTO_DUPLICATE(此前缺陷:VALIDATION_FAILED 落
// 桶 4 自动重试,文案误导)。
expect(t.lastError, ErrorCodes.photoDuplicate);
expect(t.nextRetryAt, isNull, reason: '重试必然再 409:不自动重试');
expect(t.attempt, 0, reason: '客户端重试无意义,不烧次数');
final initCount = h.initBodies.length;
h.clock = h.clock.add(const Duration(hours: 1));
await cycle();
expect(h.initBodies.length, initCount,
reason: '无自动重试(单次 init 即终判)');
});
test('401 会话过期 → waitingNetwork 短退避;重登录后(重试)完成,'
'attempt 不增长', () async {
final file = photoFile('g.jpg');
@@ -21,6 +21,7 @@ import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/enqueue.dart';
import 'package:photo_live/features/uploads/preparer.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_categories_page.dart';
@@ -46,11 +47,13 @@ void main() {
});
/// 路由:GET 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认
/// 管理者空列表)/ 分类 CRUD;其余路径直接抛错。
/// 管理者空列表)/ 分类 CRUD / 查重预检([duplicateCheck],Task 2);
/// 其余路径直接抛错(含未配置的查重预检——驱动「服务器失败静默降级」)。
FakeAdapter detailAdapter({
List<Map<String, dynamic>>? photos,
bool collaborator = false,
(int, Object?)? Function(String method, String path)? onCategoryMutation,
(int, Object?)? duplicateCheck,
}) =>
FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
@@ -74,6 +77,10 @@ void main() {
if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') {
return onCategoryMutation?.call('DELETE', o.path) ?? (204, null);
}
if (o.path == '/v1/uploads/duplicates/check' && o.method == 'POST') {
if (duplicateCheck != null) return duplicateCheck;
throw StateError('unexpected ${o.method} ${o.path}');
}
throw StateError('unexpected ${o.method} ${o.path}');
});
@@ -126,7 +133,7 @@ void main() {
findsOneWidget,
);
expect(
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai) · 大宴会厅'),
find.text('2026/9/18 ~ Not set (Asia/Shanghai) · 大宴会厅'),
findsOneWidget,
);
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
@@ -305,6 +312,7 @@ void main() {
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
late Directory tmp;
late List<String> galleryPaths;
late List<(String, String)> pickerScopes;
late TaskStore taskStore;
setUp(() async {
@@ -315,6 +323,8 @@ void main() {
);
tmp = await Directory.systemTemp.createTemp('detail-upload-');
galleryPaths = <String>[];
// 方案 A 接线断言:选图器收到的 (accountId, albumId)。
pickerScopes = <(String, String)>[];
taskStore = TaskStore.memory();
});
@@ -324,19 +334,26 @@ void main() {
});
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
/// 非平台相机源)。
Future<void> pumpDetailForUpload(WidgetTester tester) async {
final adapter = detailAdapter();
/// 非平台相机源;[adapter] 可注入查重预检路由,Task 2)。
Future<void> pumpDetailForUpload(
WidgetTester tester, {
FakeAdapter? adapter,
}) async {
final effective = adapter ?? detailAdapter();
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: store,
taskStore: taskStore,
client: clientOf(adapter),
client: clientOf(effective),
),
multiImagePickerProvider.overrideWith(
(ref) => (context, {required accountId, required albumId}) async {
pickerScopes.add((accountId, albumId));
return galleryPaths;
},
),
multiImagePickerProvider
.overrideWith((ref) => () async => galleryPaths),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
taskStore,
@@ -396,15 +413,267 @@ void main() {
find.text('Added 2 photo(s) to the upload queue'),
findsOneWidget,
);
// 方案 A 接线:拦截判定范围随调用点传入(当前账号+当前相册)。
expect(pickerScopes, [('alice', 'alb-1')]);
final tasks = await taskStore.listByAccount('alice');
expect(tasks, hasLength(2));
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
});
testWidgets('查重弹窗「全部上传」→ 重复项 force 入队(Task 8)',
(tester) async {
// 既有已完成任务:与 pick1 同名同字节(本地查重命中一张)。
final seed = await taskStore.create(UploadTaskDraft(
id: 'task-dup-seed',
accountId: 'alice',
albumId: 'alb-1',
clientTaskId: 'ct-dup-seed',
sourceFilePath: '${tmp.path}/seed-dup.jpg',
filename: 'pick1.jpg',
bytes: kJpgBytes.length,
contentType: 'image/jpeg',
));
for (final state in [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.completed,
]) {
await taskStore.updateState(seed.id, state);
}
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpDetailForUpload(tester);
// 触发选图:真实 IO 区完成查重,随后弹窗挂起等待选择。
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
await tester.runAsync(() async {
(button.evaluate().single.widget as OutlinedButton).onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('可能已上传过'), findsOneWidget, reason: '查重弹窗出现');
// 「全部上传」= 用户确认强传:重复项 force=true、非重复项不受影响。
await tester.tap(find.text('全部上传 2 张'));
await tester.pump(const Duration(milliseconds: 100));
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(
find.text('Added 2 photo(s) to the upload queue'),
findsOneWidget,
);
final tasks = await taskStore.listByAccount('alice');
final fresh = tasks.where((t) => t.id != seed.id).toList();
expect(fresh, hasLength(2));
expect(
fresh.firstWhere((t) => t.filename == 'pick1.jpg').force,
isTrue,
reason: '重复项经「全部上传」确认强传',
);
expect(
fresh.firstWhere((t) => t.filename == 'pick2.jpg').force,
isFalse,
reason: '非重复项正常查重语义',
);
});
testWidgets('服务器历史命中(本机无记录)→ 统一查重弹窗 + force 入队(Task 2)',
(tester) async {
// THE 用户场景:新设备/重装——本机 TaskStore 空,服务器有同 bytes
// 历史(预检命中也应在点击上传时刻弹确认)。
final adapter = detailAdapter(
duplicateCheck: (
200,
{
'duplicates': [
{
'index': 0,
'bytes': kJpgBytes.length,
'uploadedAt': '2026-09-25T08:00:00Z',
},
],
},
),
);
// pick1 与服务器历史同 bytes(命中);pick2 字节数不同(不命中)。
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')
..writeAsBytesSync([...kJpgBytes, 0x00]);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpDetailForUpload(tester, adapter: adapter);
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
await tester.runAsync(() async {
(button.evaluate().single.widget as OutlinedButton).onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump(const Duration(milliseconds: 300));
expect(
find.text('可能已上传过'),
findsOneWidget,
reason: '服务器历史命中(本机空)也弹查重确认',
);
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget);
expect(
find.text('跳过重复,上传 1 张'),
findsOneWidget,
reason: '另 1 张不重复 → 提供跳过重复入口',
);
// 请求形状:albumId + 逐项 filename/bytes(index 指向该 items 数组)。
final req = adapter.requests.firstWhere(
(r) => r.path == '/v1/uploads/duplicates/check',
);
expect(req.method, 'POST');
expect(req.body, {
'albumId': 'alb-1',
'items': [
{'filename': 'pick1.jpg', 'bytes': kJpgBytes.length},
{'filename': 'pick2.jpg', 'bytes': kJpgBytes.length + 1},
],
});
// 「全部上传」= 确认强传:仅服务器命中的 pick1 force=true。
await tester.tap(find.text('全部上传 2 张'));
await tester.pump(const Duration(milliseconds: 100));
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(
find.text('Added 2 photo(s) to the upload queue'),
findsOneWidget,
);
final tasks = await taskStore.listByAccount('alice');
expect(tasks, hasLength(2));
expect(
tasks.firstWhere((t) => t.filename == 'pick1.jpg').force,
isTrue,
reason: '服务器命中的重复项经「全部上传」确认强传',
);
expect(
tasks.firstWhere((t) => t.filename == 'pick2.jpg').force,
isFalse,
reason: '非重复项正常查重语义',
);
});
testWidgets('服务器预检失败 → 静默降级本机-only:无重复不弹窗直接入队(Task 2)',
(tester) async {
// 本机 TaskStore 空 + 服务器 500:降级后无重复 → 不阻塞入队。
final adapter = detailAdapter(
duplicateCheck: (500, errorBody(ErrorCodes.internalError)),
);
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
galleryPaths.add(pick1.path);
await pumpDetailForUpload(tester, adapter: adapter);
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
await tester.runAsync(() async {
(button.evaluate().single.widget as OutlinedButton).onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(find.text('可能已上传过'), findsNothing, reason: '静默降级不弹窗');
expect(
find.text('Added 1 photo(s) to the upload queue'),
findsOneWidget,
reason: '降级不阻塞入队(服务端 init 409 仍是兜底)',
);
});
testWidgets('双方均无重复(服务器空命中)→ 不弹窗直接入队(Task 2)',
(tester) async {
final adapter = detailAdapter(
duplicateCheck: (200, {'duplicates': <Map<String, dynamic>>[]}),
);
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
galleryPaths.add(pick1.path);
await pumpDetailForUpload(tester, adapter: adapter);
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
await tester.runAsync(() async {
(button.evaluate().single.widget as OutlinedButton).onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(find.text('可能已上传过'), findsNothing);
expect(find.text('Added 1 photo(s) to the upload queue'), findsOneWidget);
});
testWidgets('同 basename 兄弟文件:仅命中那张被标记/跳过(评审 #2 路径精确)',
(tester) async {
// 批内两个同名文件(不同目录、不同字节):仅 dirA 那张服务器命中。
// basename 派生会把两张都误标为重复(「跳过重复」丢非重复项);
// 路径精确派生只标记命中那张。
final adapter = detailAdapter(
duplicateCheck: (
200,
{
'duplicates': [
{
'index': 0,
'bytes': kJpgBytes.length,
'uploadedAt': '2026-09-25T08:00:00Z',
},
],
},
),
);
// 同步建目录:fake-async 区内不做真实异步 IO(同 enqueue_test 教训)。
final dirA = Directory('${tmp.path}/a')..createSync();
final dirB = Directory('${tmp.path}/b')..createSync();
final hit = File('${dirA.path}/IMG_SAME.jpg')
..writeAsBytesSync(kJpgBytes);
final other = File('${dirB.path}/IMG_SAME.jpg')
..writeAsBytesSync([...kJpgBytes, 0x00]);
galleryPaths.addAll([hit.path, other.path]);
await pumpDetailForUpload(tester, adapter: adapter);
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
await tester.runAsync(() async {
(button.evaluate().single.widget as OutlinedButton).onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('可能已上传过'), findsOneWidget);
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget,
reason: '仅命中那张计为重复(同名兄弟不误伤)');
expect(find.text('跳过重复,上传 1 张'), findsOneWidget);
// 「跳过重复」:仅未命中的同名兄弟入队,命中那张被跳过。
await tester.tap(find.text('跳过重复,上传 1 张'));
await tester.pump(const Duration(milliseconds: 100));
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(find.textContaining('1 张已入队,1 张重复已跳过'), findsOneWidget);
final tasks = await taskStore.listByAccount('alice');
expect(tasks, hasLength(1), reason: '命中那张被跳过,仅同名兄弟入队');
expect(
tasks.single.bytes,
kJpgBytes.length + 1,
reason: '入队的是未命中那张(字节不同可区分;sourceFilePath 已是'
'准备器复制路径,原始选图路径不再保留);命中那张(同 basename)'
'被精确跳过',
);
});
});
}
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。
class _StaticCaptureSource implements CaptureSource {
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。extends 继承
/// 接口默认实现(闪光灯/变焦能力默认不支持——UI 不显示开关不启用手势)。
class _StaticCaptureSource extends CaptureSource {
@override
Future<void> initialize() async {}
@@ -414,12 +683,6 @@ class _StaticCaptureSource implements CaptureSource {
@override
Widget buildPreview() => const SizedBox();
@override
bool get supportsFlash => false;
@override
Future<void> setFlashEnabled(bool enabled) async {}
@override
Future<void> dispose() async {}
}
@@ -144,7 +144,7 @@ void main() {
expect(find.byIcon(Icons.person_add_outlined), findsOneWidget);
});
testWidgets('邀请搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)',
testWidgets('邀请搜索防抖:单字符即请求;连续输入合并为最后一次(300ms)',
(tester) async {
final adapter = membersAdapter();
await pumpMembers(tester, clientOf(adapter));
@@ -152,15 +152,15 @@ void main() {
await openInviteSheet(tester);
final field = find.byType(TextField);
// 1 个字符:无请求。
// 单字符即发请求(2026-09-29 与服务端 1..64 bounds 对齐——中文姓氏单字)。
await tester.enterText(field, 's');
await tester.pump(const Duration(milliseconds: 400));
expect(
adapter.requests.where((r) => r.path == '/v1/admin/users'),
isEmpty,
isNotEmpty,
);
// 连续输入两次,防抖合并为最后一次。
// 连续输入两次,防抖合并为最后一次:'st' 不产生独立请求,只有 'sta'。
await tester.enterText(field, 'st');
await tester.pump(const Duration(milliseconds: 100));
await tester.enterText(field, 'sta');
@@ -169,8 +169,12 @@ void main() {
final lookupsRequests = adapter.requests
.where((r) => r.path == '/v1/admin/users')
.toList();
expect(lookupsRequests, hasLength(1));
expect(lookupsRequests.single.uri.queryParameters['accountPrefix'], 'sta');
// 前段 's' 一条 + 合并后的 'sta' 一条(单字符即搜后基线为 2)。
expect(lookupsRequests, hasLength(2));
expect(
lookupsRequests.map((r) => r.uri.queryParameters['accountPrefix']).toList(),
['s', 'sta'],
);
});
testWidgets('邀请流:搜索命中 → Add → POST userId 载荷 → 已添加态 + 列表刷新',
@@ -108,7 +108,7 @@ void main() {
// 信息栏:上传者 / 相册时区墙钟·尺寸·大小 / 真实状态 / ID 诊断区。
expect(find.text('Uploaded by Staff Two'), findsOneWidget);
// uploadedAt 07:00Z → 相册时区上海 15:00;4000×3000 · 121 KB。
expect(find.text('2026/9/18 15:00 · 4000×3000 · 121 KB'), findsOneWidget);
expect(find.text('2026/9/18 · 4000×3000 · 121 KB'), findsOneWidget);
expect(find.text('Status: Published'), findsOneWidget);
expect(find.text('ph-info-1'), findsOneWidget);
});
@@ -118,7 +118,7 @@ void main() {
final title = tester.widget<Text>(find.text('Opening Gala'));
expect(title.style?.color, Colors.white);
final meta = tester.widget<Text>(
find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'),
find.text('2026/9/18 ~ Not set · 大宴会厅'),
);
expect(meta.style?.color, Colors.white70);
// 底部暗色渐变叠层存在(LinearGradient 的 DecoratedBox)。
+1 -1
View File
@@ -64,7 +64,7 @@ void main() {
// 标题按界面语言回退(en → en 翻译),最多两行。
expect(find.text('Opening Gala'), findsOneWidget);
// 时间用相册时区(上海墙钟 14:30)+ 场地(en 未填场地回退 zh-CN)。
expect(find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'), findsOneWidget);
expect(find.text('2026/9/18 ~ Not set · 大宴会厅'), findsOneWidget);
// 封面未配置 → 固定 16:9 中性占位(非转圈)。
expect(find.byType(PhotoThumbnail), findsOneWidget);
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
+196 -7
View File
@@ -5,7 +5,8 @@
/// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类
/// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务
/// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图、捏合变焦
/// 与双击复位。
library;
import 'dart:io';
@@ -81,12 +82,31 @@ class FakeCaptureSource extends CaptureSource {
Future<void> dispose() async => disposeCalls++;
}
/// 变焦能力替身(Task 7 捏合变焦):能力 1-8×,记录 setZoom 调用序列。
/// 继承 [FakeCaptureSource](预览标记/初始化/释放行为一致)。
class ZoomFakeCaptureSource extends FakeCaptureSource {
ZoomFakeCaptureSource(super.files);
final List<double> zoomCalls = <double>[];
@override
double get minZoom => 1;
@override
double get maxZoom => 8;
@override
Future<void> setZoom(double level) async => zoomCalls.add(level);
}
void main() {
late FakeSessionStore session;
late TaskStore store;
late Directory tmp;
late FakeCaptureSource source;
late List<String> galleryPaths;
// 方案 A 接线断言:选图器收到的 (accountId, albumId)。
late List<(String, String)> pickerScopes;
/// 工厂调用记录(前后摄切换断言)。
late List<bool> factoryPreferFront;
@@ -126,6 +146,7 @@ void main() {
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
source = FakeCaptureSource([shot1, shot2, shot3]);
galleryPaths = <String>[];
pickerScopes = <(String, String)>[];
factoryPreferFront = <bool>[];
});
@@ -147,7 +168,12 @@ void main() {
categories: categories,
);
Future<void> pumpCapture(WidgetTester tester) async {
Future<void> pumpCapture(
WidgetTester tester, {
CaptureSource? cameraSource,
FakeAdapter? apiAdapter,
}) async {
final effectiveSource = cameraSource ?? source;
await tester.pumpWidget(
ProviderScope(
overrides: [
@@ -157,14 +183,18 @@ void main() {
client: ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = FakeAdapter(
(_) => throw StateError('连拍页不应发起 API 请求')),
..httpClientAdapter = apiAdapter ??
FakeAdapter(
// 多选入队的查重预检会发 API 请求:默认打桩为失败,
// 驱动「服务器预检失败 → 静默降级本机-only」路径。
(_) => throw StateError('连拍页测试 API 请求一律失败'),
),
),
),
captureSourceFactoryProvider.overrideWith(
(ref) => ({bool preferFront = false}) async {
factoryPreferFront.add(preferFront);
return source;
return effectiveSource;
},
),
uploadPreparerProvider.overrideWith(
@@ -176,8 +206,12 @@ void main() {
notificationPermissionProvider.overrideWith(
(ref) => NotificationPermission(isAndroid: false),
),
multiImagePickerProvider
.overrideWith((ref) => () async => galleryPaths),
multiImagePickerProvider.overrideWith(
(ref) => (context, {required accountId, required albumId}) async {
pickerScopes.add((accountId, albumId));
return galleryPaths;
},
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -383,9 +417,112 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
// 方案 A 接线:拦截判定范围随调用点传入(连拍目标账号+相册)。
expect(pickerScopes, [('alice', 'alb-1')]);
expect(await store.listByAccount('alice'), hasLength(2));
});
testWidgets('查重弹窗「全部上传」→ 重复项 force 入队、非重复项正常(Task 8)',
(tester) async {
// 既有已完成任务:与 pick1 同名同字节(本地查重命中一张)。
final seed = await store.create(UploadTaskDraft(
id: 'task-dup-seed',
accountId: 'alice',
albumId: 'alb-1',
clientTaskId: 'ct-dup-seed',
sourceFilePath: '${tmp.path}/seed-dup.jpg',
filename: 'pick1.jpg',
bytes: kJpgBytes.length,
contentType: 'image/jpeg',
));
for (final state in [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.completed,
]) {
await store.updateState(seed.id, state);
}
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpCapture(tester);
// 相册入口:真实 IO 区完成查重,随后弹窗挂起等待选择。
await pressRealIo(tester, Icons.photo_library_outlined);
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('可能已上传过'), findsOneWidget, reason: '查重弹窗出现');
// 「全部上传」= 用户确认强传:重复项 force=true、非重复项不受影响。
await tester.tap(find.text('全部上传 2 张'));
await tester.pump(const Duration(milliseconds: 100));
// 入队复制走真实文件 IO:真实事件循环窗口内完成。
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
final tasks = await store.listByAccount('alice');
final fresh = tasks.where((t) => t.id != seed.id).toList();
expect(fresh, hasLength(2));
expect(
fresh.firstWhere((t) => t.filename == 'pick1.jpg').force,
isTrue,
reason: '重复项经「全部上传」确认强传',
);
expect(
fresh.firstWhere((t) => t.filename == 'pick2.jpg').force,
isFalse,
reason: '非重复项正常查重语义',
);
});
testWidgets('服务器历史查重命中(本机无记录)→ 统一查重弹窗,取消不上传(Task 2)',
(tester) async {
// 新设备场景:本机 TaskStore 空,服务器预检命中 pick1(bytes 一致);
// pick2 字节数不同不命中——弹窗应精确提示 1 张重复。
final adapter = FakeAdapter((o) {
if (o.path == '/v1/uploads/duplicates/check' && o.method == 'POST') {
return (
200,
{
'duplicates': [
{
'index': 0,
'bytes': kJpgBytes.length,
'uploadedAt': '2026-09-25T08:00:00Z',
},
],
},
);
}
throw StateError('unexpected ${o.method} ${o.path}');
});
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')
..writeAsBytesSync([...kJpgBytes, 0x00]);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpCapture(tester, apiAdapter: adapter);
await pressRealIo(tester, Icons.photo_library_outlined);
await tester.pump(const Duration(milliseconds: 300));
expect(
find.text('可能已上传过'),
findsOneWidget,
reason: '服务器历史命中(新设备场景)也弹查重确认',
);
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget);
expect(find.text('跳过重复,上传 1 张'), findsOneWidget);
// 取消:整批不上传(服务器命中同样尊重用户取消)。
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(await store.listByAccount('alice'), isEmpty);
});
testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
(tester) async {
await pumpCapture(tester);
@@ -462,4 +599,56 @@ void main() {
expect(find.text('Ceremony'), findsOneWidget);
expect(find.text('Gala'), findsOneWidget);
});
testWidgets('捏合变焦:双指张开驱动 setZoom 并显示倍数徽标', (tester) async {
final zoomSource = ZoomFakeCaptureSource(<File>[]);
await pumpCapture(tester, cameraSource: zoomSource);
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
final center = tester.getCenter(find.text('PREVIEW_MARK'));
// 必须两根指针:ScaleGestureRecognizer 的 scale = 当前指距/初始指距,
// 单指针 span=0 → scale 恒为 1.0,永远测不到变焦。
final p1 = await tester.startGesture(center.translate(-40, 0));
final p2 = await tester.startGesture(center.translate(40, 0));
await p1.moveBy(const Offset(-40, 0));
await p2.moveBy(const Offset(40, 0));
await tester.pump();
await p1.up();
await p2.up();
await tester.pump();
expect(zoomSource.zoomCalls.last, greaterThan(1.0));
expect(find.textContaining('×'), findsOneWidget, reason: '捏合期间显示倍数徽标');
// 停止 1s 后徽标隐藏;同时冲刷计时器避免悬挂。
await tester.pump(const Duration(seconds: 1));
expect(find.textContaining('×'), findsNothing, reason: '1s 后徽标隐藏');
});
testWidgets('双击复位 1.0×', (tester) async {
final zoomSource = ZoomFakeCaptureSource(<File>[]);
await pumpCapture(tester, cameraSource: zoomSource);
// 先捏合放大到 >1×,再双击复位。
final center = tester.getCenter(find.text('PREVIEW_MARK'));
final p1 = await tester.startGesture(center.translate(-40, 0));
final p2 = await tester.startGesture(center.translate(40, 0));
await p1.moveBy(const Offset(-40, 0));
await p2.moveBy(const Offset(40, 0));
await p1.up();
await p2.up();
await tester.pump();
expect(zoomSource.zoomCalls.last, greaterThan(1.0), reason: '前置:已放大');
await tester.tap(find.text('PREVIEW_MARK'));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('PREVIEW_MARK'));
await tester.pump();
expect(zoomSource.zoomCalls.last, 1.0);
expect(find.text('1.0×'), findsOneWidget, reason: '复位后徽标显示 1.0×');
// 冲刷徽标隐藏计时器避免悬挂。
await tester.pump(const Duration(seconds: 1));
});
}
@@ -0,0 +1,208 @@
/// 封面/宣传图裁剪页 widget 测试(crop_your_image 回调式 API)。
///
/// 真实异步边界(源文件读取 + 包内 parse/crop 的 compute + 编码落盘
/// Isolate.run)在 testWidgets 的 FakeAsync 下不会自行推进——参照
/// crop_your_image 自身测试的驱动方式:tap + 等待都包进 tester.runAsync,
/// 用真实 Future.delayed 等 isolate 完成。
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/image_crop_page.dart';
void main() {
late final Directory tmp;
late final String fixtureJpg;
late final String fixtureGif;
setUpAll(() {
tmp = Directory.systemTemp.createTempSync('image_crop_page_test');
// 320x240(4:3)JPG 夹具:运行时生成,避免仓库二进制资产。
final source = img.Image(width: 320, height: 240, numChannels: 3);
img.fill(source, color: img.ColorRgb8(80, 120, 200));
fixtureJpg = '${tmp.path}${Platform.pathSeparator}fixture.jpg';
File(fixtureJpg).writeAsBytesSync(img.encodeJpg(source, quality: 90));
// GIF 夹具:包内 formatDetector 兜底按 png 解码必然失败挂起,
// 页面预检须直接判不支持(防 never-ready)。
fixtureGif = '${tmp.path}${Platform.pathSeparator}fixture.gif';
File(fixtureGif).writeAsBytesSync(img.encodeGif(source));
});
tearDownAll(() {
tmp.deleteSync(recursive: true);
});
/// 打开裁剪页(zh 本地化);onPopped 接住 pop 结果。
Future<void> openCropPage(
WidgetTester tester,
String sourcePath, {
required double aspectRatio,
int? maxOutputWidth,
void Function(String? value)? onPopped,
}) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: FloatingActionButton(
onPressed: () async {
final value = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => ImageCropPage(
sourcePath: sourcePath,
aspectRatio: aspectRatio,
maxOutputWidth: maxOutputWidth,
),
),
);
onPopped?.call(value);
},
),
),
),
),
),
);
await tester.runAsync(() async {
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// 真实异步必须「等→pump→再等→再 pump」:读取完成后经 pump 才构建
// Crop(parse compute 此刻才启动),需再等真实时间让 isolate 回复落地。
await Future.delayed(const Duration(seconds: 2));
await tester.pump();
await Future.delayed(const Duration(seconds: 2));
await tester.pump();
});
await tester.pump();
}
testWidgets('取消 → pop(null)', (tester) async {
String? popped = 'sentinel';
await openCropPage(
tester,
fixtureJpg,
aspectRatio: 16 / 9,
onPopped: (value) => popped = value,
);
await tester.runAsync(() async {
await tester.tap(find.text('取消'));
await tester.pump();
});
await tester.pumpAndSettle();
expect(popped, isNull);
});
testWidgets('确认 → pop 出存在的 .jpg 路径', (tester) async {
String? popped;
await openCropPage(
tester,
fixtureJpg,
aspectRatio: 16 / 9,
maxOutputWidth: 1920,
onPopped: (value) => popped = value,
);
await tester.runAsync(() async {
await tester.tap(find.text('完成'));
await tester.pump();
// crop(compute)→ 编码落盘(Isolate.run)→ pop。
await Future.delayed(const Duration(seconds: 5));
await tester.pump();
});
await tester.pumpAndSettle();
expect(popped, isNotNull);
expect(popped!.endsWith('.jpg'), isTrue);
expect(File(popped!).existsSync(), isTrue);
});
testWidgets('确认 → 输出尊重 maxOutputWidth 且保持纵横比', (tester) async {
String? popped;
await openCropPage(
tester,
fixtureJpg,
aspectRatio: 16 / 9,
maxOutputWidth: 160,
onPopped: (value) => popped = value,
);
await tester.runAsync(() async {
await tester.tap(find.text('完成'));
await tester.pump();
await Future.delayed(const Duration(seconds: 5));
await tester.pump();
});
await tester.pumpAndSettle();
expect(popped, isNotNull);
final decoded = img.decodeImage(File(popped!).readAsBytesSync());
expect(decoded, isNotNull);
expect(decoded!.width, 160);
expect(decoded.height, 90);
});
testWidgets('非图片源 → 提示不支持且不 pop', (tester) async {
final bogus = File('${tmp.path}${Platform.pathSeparator}bogus.jpg');
bogus.writeAsStringSync('definitely not an image');
final popped = <String?>[];
await openCropPage(
tester,
bogus.path,
aspectRatio: 3 / 1,
onPopped: popped.add,
);
await tester.pump();
expect(find.text('暂不支持该图片格式,请选择 JPG/PNG'), findsOneWidget);
expect(popped, isEmpty);
// 完成不可用(无裁剪可做)。
final done = find.text('完成');
expect(
tester
.widget<FilledButton>(
find.ancestor(of: done, matching: find.byType(FilledButton)),
)
.enabled,
isFalse,
);
bogus.deleteSync();
});
testWidgets('GIF 源 → 预检判不支持(防包内 png 兜底解码挂起)且不 pop',
(tester) async {
final popped = <String?>[];
await openCropPage(
tester,
fixtureGif,
aspectRatio: 16 / 9,
onPopped: popped.add,
);
await tester.pump();
expect(find.text('暂不支持该图片格式,请选择 JPG/PNG'), findsOneWidget);
expect(popped, isEmpty);
// 完成不可用:包内若按 png 兜底解析 GIF,将永远到不了 ready。
final done = find.text('完成');
expect(
tester
.widget<FilledButton>(
find.ancestor(of: done, matching: find.byType(FilledButton)),
)
.enabled,
isFalse,
);
});
}
@@ -279,6 +279,28 @@ void main() {
reason: 'History 入口 push /server-history(与本机队列数据源区分)');
});
testWidgets('空态引导:本机 0 条记录 → 服务端历史说明 + 跳转按钮', (
tester,
) async {
// setUp 每次新内存库:不种任务即整体队列空态(重装后场景)。
await pumpQueue(tester);
expect(find.text('No upload tasks'), findsOneWidget);
// 本机存储口径说明 + 服务端历史引导(EmptyState subtitle + tonal 按钮)。
expect(
find.text(
'Upload records live on this device only and are cleared on reinstall',
),
findsOneWidget,
);
expect(find.text('View server history'), findsOneWidget);
await tester.tap(find.text('View server history'));
await tester.pumpAndSettle();
expect(find.text('SERVER_HISTORY_MARK'), findsOneWidget,
reason: '空态引导与右上 History 同路由');
});
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)',
(tester) async {
await seed('done1.jpg', state: UploadTaskState.completed);
@@ -1,7 +1,8 @@
/// 服务端上传历史页 widget 测试(UI-03 页面 16;A19 语义)。
///
/// 覆盖:标题/副标题(跨设备口径)与 A19 数据源区分横幅、行内容
/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、相册与
/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、行首缩略图
/// (有 preview 渲染 PhotoThumbnail;无 preview 回退中性占位)、相册与
/// 日期客户端筛选、下拉刷新、游标加载更多、加载更多失败页脚重试、
/// 首屏错误重试。
library;
@@ -15,6 +16,7 @@ import 'package:photo_live/core/device_id.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/server_history_page.dart';
import 'package:photo_live/ui/components/photo_thumbnail.dart';
import '../helpers/fakes.dart';
@@ -43,6 +45,7 @@ Map<String, dynamic> sessionJson({
String photoId = 'pho-0001',
String processingState = 'ready',
String? sourceDeviceId = _kOwnDevice,
Map<String, dynamic>? preview,
}) =>
<String, dynamic>{
'uploadId': uploadId,
@@ -57,6 +60,7 @@ Map<String, dynamic> sessionJson({
'photoId': photoId,
'processingState': processingState,
'visibility': 'published',
'preview': ?preview,
},
};
@@ -182,6 +186,41 @@ void main() {
);
});
testWidgets('行首缩略图:有 preview 渲染 PhotoThumbnail;无 preview 回退中性占位', (
tester,
) async {
final adapter = adapterOf((o) {
if (o.method == 'GET' && o.path == '/v1/uploads') {
return (
200,
cursorPageBody([
// 已处理照片(变体齐备)→ 服务端下发 preview。
sessionJson(
clientTaskId: 'withthumb000000000000000000000001',
preview: photoPreviewJson(
thumbUrl: 'http://test.local/thumb-1.jpg',
),
),
// 未处理会话 → 无 preview 字段(App 回退占位图标)。
sessionJson(
uploadId: 'upl-0002',
clientTaskId: 'nopreview000000000000000000000002',
state: 'pending',
processingState: 'processing',
),
]),
);
}
throw StateError('unexpected ${o.method} ${o.path}');
});
await pumpPage(tester, clientOf(adapter));
// 有 preview:行首是缩略图组件;无 preview:中性占位图标仍在。
expect(find.byType(PhotoThumbnail), findsOneWidget);
expect(find.byIcon(Icons.cloud_done_outlined), findsOneWidget);
});
testWidgets('相册筛选(候选来自相册列表;客户端过滤已加载记录)',
(tester) async {
final adapter = adapterOf(
+109 -2
View File
@@ -4,7 +4,10 @@
/// 可选中链接)、链接携带正确相册 token 与 `?lang=` 语言参数、Copy link
/// 主操作写剪贴板 + 已复制提示、Open in browser 次操作经注入替身启动器
/// 打开并退出页面、未公开相册显示「该链接目前无法公开浏览」琥珀提示
/// (公开相册不显示)。
/// (公开相册不显示)、海报弹层内「中文/English」切换(默认跟 App 语言,
/// 提示文案/相册标题/二维码 lang 随切换,弹层 chrome 仍跟 App 语言)、
/// 下载海报反馈在弹层内可见(成功=按钮翻成功态自动复原;失败=弹层内
/// 红字可重试;SnackBar 在模态弹层下不可见,不得作为反馈通道)。
library;
import 'package:flutter/material.dart';
@@ -25,12 +28,15 @@ void main() {
WidgetTester tester, {
bool publicEnabled = true,
UrlLauncher? launcher,
PosterSaver? saver,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
if (launcher != null)
urlLauncherProvider.overrideWith((ref) => launcher),
if (saver != null)
posterSaverProvider.overrideWith((ref) => saver),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -59,7 +65,7 @@ void main() {
// 名称与日期地点(en 回退:Opening Gala;上海墙钟 14:30)。
expect(find.text('Opening Gala'), findsOneWidget);
expect(
find.text('2026/9/18 14:30 · 大宴会厅'),
find.text('2026/9/18 · 大宴会厅'),
findsOneWidget,
reason: '开始时间(相册时区;结束未定不伪造区间)+ 场地(展示回退链)',
);
@@ -85,6 +91,107 @@ void main() {
expect(find.text('2026/9/18'), findsOneWidget);
});
testWidgets('海报语言切换:默认跟 App 语言,切中文后提示/标题/二维码随之', (
tester,
) async {
await pumpPage(tester);
await tester.tap(find.text('Generate QR poster'));
await tester.pumpAndSettle();
// 默认 English 档(App 语言 en):英文提示 + lang=en 的二维码
// (qr_flutter 4.1 无公开 data getter,经 semanticsLabel 断言编码内容)。
expect(find.text('Scan to view photos'), findsOneWidget);
expect(
tester.widget<QrImageView>(find.byType(QrImageView)).semanticsLabel,
'http://10.0.2.2:5175/a/$token?lang=en',
reason: '二维码 lang 跟海报语言(默认=App 语言前缀)',
);
// 两档切换(语言名以自身语言书写,与语言选择面板同约定)。
expect(find.text('中文'), findsOneWidget);
expect(find.text('English'), findsOneWidget);
// 英文档标题取英文翻译(中文翻译不出现在海报上)。
expect(find.text('开幕晚宴'), findsNothing);
// 切「中文」:提示/标题换中文、二维码 lang=zh-CN。
await tester.tap(find.text('中文'));
await tester.pumpAndSettle();
expect(find.text('扫码查看照片'), findsOneWidget);
expect(find.text('Scan to view photos'), findsNothing);
expect(find.text('开幕晚宴'), findsOneWidget);
expect(
tester.widget<QrImageView>(find.byType(QrImageView)).semanticsLabel,
'http://10.0.2.2:5175/a/$token?lang=zh-CN',
);
// 弹层 chrome(标题/下载按钮)不随海报语言切换,仍跟 App 语言。
expect(find.text('Download poster image'), findsOneWidget);
});
testWidgets('下载海报成功:按钮翻成功态并自动复原,全程无 SnackBar', (
tester,
) async {
await pumpPage(tester, saver: (_) async {});
await tester.tap(find.text('Generate QR poster'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Download poster image'));
await tester.pumpAndSettle();
await tester.tap(find.text('Download poster image'));
await tester.pumpAndSettle();
// 反馈必须在弹层内可见(SnackBar 被模态弹层遮挡,禁用)。
expect(find.byType(SnackBar), findsNothing);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Poster saved to gallery'),
),
findsOneWidget,
reason: '成功反馈显示在下载按钮上(弹层内)',
);
// 约 2.5s 后自动复原,可再次下载(配合语言切换存第二张)。
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('Poster saved to gallery'), findsNothing);
expect(find.text('Download poster image'), findsOneWidget);
});
testWidgets('下载海报失败:弹层内红字提示且可重试', (tester) async {
var calls = 0;
await pumpPage(
tester,
saver: (_) async {
calls++;
throw StateError('save failed');
},
);
await tester.tap(find.text('Generate QR poster'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Download poster image'));
await tester.pumpAndSettle();
await tester.tap(find.text('Download poster image'));
await tester.pumpAndSettle();
expect(find.byType(SnackBar), findsNothing);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Failed to save poster'),
),
findsOneWidget,
reason: '失败反馈在弹层内(对齐隐藏照片弹层的面板内提示模式)',
);
// 按钮复位可重试。
expect(find.text('Download poster image'), findsOneWidget);
await tester.ensureVisible(find.text('Download poster image'));
await tester.pumpAndSettle();
await tester.tap(find.text('Download poster image'));
await tester.pumpAndSettle();
expect(calls, 2, reason: '失败后可重试');
});
testWidgets('Copy link(主操作)→ 剪贴板收到带 lang 的 URL + 已复制提示',
(tester) async {
final clipboardCalls = <MethodCall>[];
+13 -33
View File
@@ -309,44 +309,24 @@ test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', (
expect(after).toBeLessThan(resumedAt); // 恢复滚动(translateY 更负)
});
test('photo click opens viewer deep link for the clicked photo (§4.1)', async ({ page }) => {
test('photo click opens album page in a new tab (user decision 2026-09-24)', async ({ page }) => {
await openHost(page, { width: 1440, height: 900 });
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame.waitFor();
// 悬停冻结 + 坐标直击当前命中的照片(.first() 可能已滚出视口——flaky 源)。
// 悬停冻结(点击防错图语义下稳定命中)。
const box = await frame.boundingBox();
const cx = (box!.x ?? 0) + 90;
const cy = (box!.y ?? 0) + 140;
await page.mouse.move(cx, cy);
await page.mouse.move((box?.x ?? 0) + 90, (box?.y ?? 0) + 140);
await page.waitForTimeout(400);
const embedFrame0 = page.frames().find((f) => f.url().includes('v=2'));
expect(embedFrame0).toBeDefined();
const photoId = await embedFrame0!.evaluate(([_x, _y]) => {
const el = document.elementFromPoint(_x, _y)?.closest('a.stream-photo');
return el?.getAttribute('data-photo-id') ?? '';
}, [90, 140] as const);
expect(photoId).not.toBe('');
// 宿主内弹框(用户决策 2026-09-23):不新开标签,宿主页覆盖层 + 详情 iframe。
let popupOpened = false;
page.once('popup', () => {
popupOpened = true;
});
await page.mouse.click(cx, cy);
const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]');
await modalFrame.waitFor({ timeout: 5000 });
expect(popupOpened).toBe(false);
await expect
.poll(async () => {
const f = page.frames().find((fr) => (fr.url().includes('/p/') ?? false));
return f?.url() ?? '';
})
.toContain(`/p/${photoId}`);
await page.screenshot({ path: `${OUT_ROOT}/actual/server/photo-modal.png` });
await page
.locator('button[aria-label*="Close photo detail"], button[aria-label*="关闭照片详情"]')
.click();
await expect(modalFrame).toHaveCount(0);
const popupPromise = page.waitForEvent('popup', { timeout: 6000 });
await page.mouse.click((box?.x ?? 0) + 90, (box?.y ?? 0) + 140);
const popup = await popupPromise;
// 新标签打开的是相册页(/a/:token),不含照片深链 /p/。
expect(popup.url()).toMatch(/\/a\/[^/]+\?lang=/);
expect(popup.url()).not.toContain('/p/');
await popup.close();
// 宿主内不出现弹框。
await page.waitForTimeout(600);
expect(await page.locator('iframe[title="PhotoLive photo detail"]').count()).toBe(0);
});
test('drag beyond 8px cancels click (§4.1)', async ({ page }) => {
+48 -3
View File
@@ -2,7 +2,14 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import App from '../App.vue';
import { ApiError, getPublicAlbum, getPublicPhotos, type PublicAlbum } from '../api/client';
import {
ApiError,
getPublicAds,
getPublicAlbum,
getPublicPhotos,
type PublicAd,
type PublicAlbum,
} from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import ActivityPage from '../pages/ActivityPage.vue';
import { router } from '../router';
@@ -12,13 +19,15 @@ import { makeAlbum, makeEnAlbum } from './fixtures';
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
// 保证 ApiError/errors 映射等真实逻辑参与断言。Task 10 起活动页组装 WS/浏览
// 上报:RealtimeClient 以无操作桩替换(jsdom WebSocket 会发起真实连接)、
// postAlbumVisit 替身防真实 fetch。
// postAlbumVisit 替身防真实 fetch;广告位轮播(Task 10)getPublicAds 同样
// 替身(相册加载成功后页面会独立拉取广告,不替身会发起真实 fetch)。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return {
...actual,
getPublicAlbum: vi.fn(),
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
getPublicAds: vi.fn().mockResolvedValue({ items: [] }),
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
};
});
@@ -33,6 +42,7 @@ vi.mock('../realtime/ws', () => {
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPublicAds = vi.mocked(getPublicAds);
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
@@ -63,9 +73,11 @@ async function pickLanguage(wrapper: VueWrapper, option: '中文' | 'English'):
describe('activity page (Task 6)', () => {
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear();
i18n = createViewerI18n('zh-CN');
document.title = '';
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
mockedGetPublicAds.mockReset().mockResolvedValue({ items: [] });
await router.push('/a/tok-activity');
await router.isReady();
});
@@ -95,7 +107,7 @@ describe('activity page (Task 6)', () => {
// 无 banner 资产 → 不渲染 img;viewCount 未知(null)→ 浏览项显示「—」而非 0(UI-02 §4.1)。
// (参考稿:浏览项前置 ◉ Unicode 图标,属渲染内容的一部分。)
expect(wrapper.find('img.banner').exists()).toBe(false);
expect(wrapper.find('.view-count').text()).toBe('◉—');
expect(wrapper.find('.view-count').exists()).toBe(false); // 已隐藏(2026-09-24)
expect(document.title).toBe('2026 公司年会');
});
@@ -264,4 +276,37 @@ describe('activity page (Task 6)', () => {
expect(chips[1].classes()).toContain('active');
expect(chips[1].text()).toBe('Check-in Wall');
});
// —— 广告位轮播(Task 10 终审补位):会话级关闭记忆在页面(组件只 emit
// close,无 sessionStorage 副作用)—— dismissed 时不拉取不渲染、
// close 后写键并清空,两段都在页面级守护 ——
function makeAd(adId: string): PublicAd {
return { adId, imageUrl: `https://ad.test/${adId}.jpg`, linkUrl: `https://link.test/${adId}` };
}
it('skips the ads request and carousel when the session dismissed flag is set', async () => {
sessionStorage.setItem('viewer.adsDismissed', '1');
mockedGetPublicAds.mockResolvedValue({ items: [makeAd('dismissed-ad')] });
const wrapper = mountPage();
await flushPromises();
// 命中会话级记忆 → 连请求都省(设计:关闭后本会话不再拉取)。
expect(mockedGetPublicAds).not.toHaveBeenCalled();
expect(wrapper.find('.ad-carousel').exists()).toBe(false);
});
it('dismissing the carousel writes the sessionStorage flag and clears it from the page', async () => {
mockedGetPublicAds.mockResolvedValue({ items: [makeAd('ad-1')] });
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('.ad-carousel').exists()).toBe(true);
await wrapper.get('.ad-close').trigger('click');
await nextTick();
// 页面 close 处理器:写会话级键 + 清空数据(轮播整块缺席)。
expect(sessionStorage.getItem('viewer.adsDismissed')).toBe('1');
expect(wrapper.find('.ad-carousel').exists()).toBe(false);
});
});
@@ -0,0 +1,147 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PublicAd } from '../api/client';
import { createViewerI18n } from '../i18n';
import AdCarousel from '../components/ads/AdCarousel.vue';
// 广告轮播组件(Task 10 Step 3):圆点跳页、关闭 emit、slide 点击新标签打开、
// 4s 自动轮播与指针悬停暂停、单条静态(无圆点)。会话级关闭记忆在 ActivityPage
//(组件只 emit close,无 sessionStorage 副作用)。
function makeAd(id: string): PublicAd {
return { adId: id, imageUrl: `https://ad.test/${id}.jpg`, linkUrl: `https://link.test/${id}` };
}
const ADS = [makeAd('a'), makeAd('b'), makeAd('c')];
const mounted: VueWrapper[] = [];
/** window.open 桩(记录调用即可):经工厂推导 spy 类型,绕开 spyOn 泛型约束。 */
function spyWindowOpen() {
return vi.spyOn(window, 'open').mockReturnValue({} as Window);
}
function mountCarousel(ads: PublicAd[] = ADS): VueWrapper {
const wrapper = mount(AdCarousel, {
props: { ads },
global: { plugins: [createViewerI18n('zh-CN')] },
});
mounted.push(wrapper);
return wrapper;
}
function currentSrc(wrapper: VueWrapper): string | null {
// 全帧堆叠后当前帧=活跃帧(2026-09-29 淡入淡出改造)。
const img = wrapper.find('.ad-slide.active img').element as HTMLImageElement | undefined;
return img?.getAttribute('src') ?? null;
}
/** 图加载失败(含现签 URL 过期)→ emit imageError(adId)(2026-09-29 裂图自愈)。 */
function triggerImgError(wrapper: VueWrapper): void {
// 触发活跃帧的图错误(头/尾克隆帧常驻轨道,首个 .ad-slide 是头克隆)。
wrapper.find('.ad-slide.active img').trigger('error');
}
describe('AdCarousel', () => {
let openSpy: ReturnType<typeof spyWindowOpen>;
beforeEach(() => {
vi.useFakeTimers();
openSpy = spyWindowOpen();
});
afterEach(() => {
openSpy.mockRestore();
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
});
it('renders the ad badge and switches to the clicked dot', async () => {
const wrapper = mountCarousel();
expect(wrapper.get('.ad-badge').text()).toBe('广告');
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg');
const dots = wrapper.findAll('.ad-dots button');
expect(dots.length).toBe(3);
expect(dots[0].attributes('aria-current')).toBe('true'); // 当前项程序化状态
expect(dots[1].attributes('aria-current')).toBeUndefined();
await dots[1].trigger('click');
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg');
expect(dots[1].classes()).toContain('active');
expect(dots[1].attributes('aria-current')).toBe('true');
expect(dots[0].attributes('aria-current')).toBeUndefined();
});
it('emits close when the ✕ button is clicked', async () => {
const wrapper = mountCarousel();
const close = wrapper.get('.ad-close');
expect(close.attributes('aria-label')).toBe('关闭广告');
await close.trigger('click');
expect(wrapper.emitted('close')?.length).toBe(1);
});
it('opens the current ad link in a new tab via window.open', async () => {
const wrapper = mountCarousel();
await wrapper.get('.ad-slide.active').trigger('click');
expect(openSpy).toHaveBeenCalledWith('https://link.test/a', '_blank', 'noopener,noreferrer');
});
it('auto-advances every 4s, pauses on pointer enter and resumes on leave', async () => {
const wrapper = mountCarousel();
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg');
await vi.advanceTimersByTimeAsync(4000);
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg');
await wrapper.find('.ad-carousel').trigger('pointerenter');
await vi.advanceTimersByTimeAsync(8000);
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg'); // 悬停期间不切换
await wrapper.find('.ad-carousel').trigger('pointerleave');
await vi.advanceTimersByTimeAsync(4000);
expect(currentSrc(wrapper)).toBe('https://ad.test/c.jpg'); // 离开自动恢复
});
it('switches by horizontal swipe (touch) and cancels the synthetic click', async () => {
const wrapper = mountCarousel();
const root = wrapper.find('.ad-carousel');
await root.trigger('touchstart', { touches: [{ clientX: 200, clientY: 20 }] });
await root.trigger('touchend', { changedTouches: [{ clientX: 80, clientY: 25 }] });
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg'); // 左滑 → 下一张
expect(openSpy).not.toHaveBeenCalled(); // 滑动不触发广告打开
await root.trigger('touchstart', { touches: [{ clientX: 100, clientY: 10 }] });
await root.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 12 }] });
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg'); // 右滑 → 上一张
});
it('renders a single ad statically without dots or auto rotation', async () => {
const wrapper = mountCarousel([makeAd('solo')]);
expect(wrapper.find('.ad-dots').exists()).toBe(false);
await vi.advanceTimersByTimeAsync(12000);
expect(currentSrc(wrapper)).toBe('https://ad.test/solo.jpg');
});
it('emits imageError with adId when the current image fails to load', async () => {
const wrapper = mountCarousel(ADS);
triggerImgError(wrapper);
const emitted = wrapper.emitted('imageError');
expect(emitted).toHaveLength(1);
expect(emitted![0]).toEqual(['a']); // 当前帧(index 0)的 adId
});
it('wraps last→first as one natural leftward slide then a silent snap', async () => {
const wrapper = mountCarousel(ADS);
await vi.advanceTimersByTimeAsync(8000); // a→b→c(真实末帧)
expect(currentSrc(wrapper)).toBe('https://ad.test/c.jpg');
expect(wrapper.find('.ad-slides').attributes('style')).toContain('translateX(-300%)');
await vi.advanceTimersByTimeAsync(4000); // 环回:a 经尾克隆右→左滑入
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg'); // 活跃真实帧已回 a
let track = wrapper.find('.ad-slides');
expect(track.attributes('style')).toContain('translateX(-400%)'); // 视觉在尾克隆位
expect(track.classes()).not.toContain('no-transition');
await vi.advanceTimersByTimeAsync(600); // 过渡结束 → 关过渡瞬时回位
track = wrapper.find('.ad-slides');
expect(track.attributes('style')).toContain('translateX(-100%)'); // 等视觉真实首帧
});
});
+14 -20
View File
@@ -75,7 +75,8 @@ describe('album header info area (UI-02 V02)', () => {
// pre-line:换行保留;文本插值(非 HTML 注入)。
expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍');
// Intl.NumberFormat 分组 + 词条拼接(§4.1);参考稿:浏览项前置 ◉ Unicode 图标。
expect(wrapper.find('.view-count').text()).toBe('◉1,286 次浏览');
// 浏览次数已按用户要求隐藏(2026-09-24)——计数与上报不受影响,仅不展示。
expect(wrapper.find('.view-count').exists()).toBe(false);
expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号');
// Asia/Shanghai(夹具时区):UTC 10-01 → 本地 10月1日。
expect(wrapper.find('.meta').text()).toContain('10月1日');
@@ -98,8 +99,8 @@ describe('album header info area (UI-02 V02)', () => {
expect(wrapper.find('img.banner').exists()).toBe(false);
expect(wrapper.find('.description').exists()).toBe(false);
expect(wrapper.findAll('.meta-item')).toHaveLength(2); // 浏览(—)+ 日期;地点整项省略
expect(wrapper.find('.view-count').text()).toBe('◉—');
expect(wrapper.findAll('.meta-item')).toHaveLength(1); // 浏览已隐藏,仅日期;地点整项省略
expect(wrapper.find('.view-count').exists()).toBe(false); // 已隐藏(2026-09-24)
expect(wrapper.find('.categories').exists()).toBe(false);
});
@@ -186,9 +187,7 @@ describe('album header info area (UI-02 V02)', () => {
await Promise.resolve();
// 弹层出现:链接文本 + 复制按钮(页面级「已复制」提示已退役)。
expect(sheetEl()).not.toBeNull();
expect(sheetEl()!.querySelector('[data-test="share-sheet-url"]')!.textContent).toBe(
'http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN',
);
// 链接文本已隐藏(2026-09-24):二维码 + 复制按钮仍在。
expect(document.querySelector('.share-status')).toBeNull();
const copyBtn = sheetEl()!.querySelector<HTMLButtonElement>('[data-test="share-sheet-copy"]')!;
@@ -204,25 +203,20 @@ describe('album header info area (UI-02 V02)', () => {
expect(copyBtn.textContent).toContain('复制失败');
});
it('share sheet offers navigator.share when available (native panel self-reports)', async () => {
it('native share button removed (user request 2026-09-24): copy only', async () => {
const share = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
const writeText = stubClipboard(() => Promise.resolve());
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
await wrapper.find('.share-btn').trigger('click');
await Promise.resolve();
await Promise.resolve();
const nativeBtn = sheetEl()!.querySelector<HTMLButtonElement>('[data-test="share-sheet-native"]')!;
expect(nativeBtn).not.toBeNull();
nativeBtn.click();
await Promise.resolve();
await Promise.resolve();
expect(share).toHaveBeenCalledWith({
title: '2026 公司年会',
url: 'http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN',
});
// 原生分享路径不落剪贴板(面板自反馈,§5.4)。
expect(writeText).not.toHaveBeenCalled();
await flushPromises();
// 仅复制按钮;系统分享不再提供(navigator.share 存在也不显示)。
expect(sheetEl()!.querySelector('[data-test="share-sheet-native"]')).toBeNull();
const copyBtn = sheetEl()!.querySelector<HTMLButtonElement>('[data-test="share-sheet-copy"]')!;
copyBtn.click();
await flushPromises();
expect(writeText).toHaveBeenCalled();
expect(share).not.toHaveBeenCalled();
});
});
+2 -24
View File
@@ -589,30 +589,6 @@ describe('photo lightbox interactions (UI-02 V05)', () => {
return document.querySelector('.share-sheet');
}
it('share opens the sheet with the photo deep link; native share via sheet button', async () => {
const share = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
const { wrapper } = mountV05('p1', {
albumTitle: '2026 公司年会',
shareUrl: '/a/tok-lb/p/p1?lang=zh-CN',
});
await flushPromises();
await wrapper.find('button[aria-label="分享照片"]').trigger('click');
await flushPromises();
// 弹层出现并携带照片深链(页面级提示已退役)。
expect(lbSheet()).not.toBeNull();
expect(
lbSheet()!.querySelector('[data-test="share-sheet-url"]')!.textContent,
).toBe('/a/tok-lb/p/p1?lang=zh-CN');
expect(wrapper.find('.share-status').exists()).toBe(false);
const nativeBtn = lbSheet()!.querySelector<HTMLButtonElement>('[data-test="share-sheet-native"]')!;
nativeBtn.click();
await flushPromises();
expect(share).toHaveBeenCalledWith({ title: '2026 公司年会', url: '/a/tok-lb/p/p1?lang=zh-CN' });
});
it('sheet copy button writes the deep link with on-button feedback', async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
@@ -621,6 +597,8 @@ describe('photo lightbox interactions (UI-02 V05)', () => {
await wrapper.find('button[aria-label="分享照片"]').trigger('click');
await flushPromises();
// 系统分享按钮已移除(2026-09-24):仅复制。
expect(lbSheet()!.querySelector('[data-test="share-sheet-native"]')).toBeNull();
const copyBtn = lbSheet()!.querySelector<HTMLButtonElement>('[data-test="share-sheet-copy"]')!;
copyBtn.click();
await flushPromises();
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { nextIndex, swipeStep } from './carousel';
// 广告轮播纯逻辑(Task 10 Step 1):推进/环形回绕/单条静止 + 滑动手势判定
//(阈值 40px、水平分量须严格大于垂直分量)。
describe('nextIndex', () => {
it('advances forward and wraps from the tail to the head (ring)', () => {
expect(nextIndex(3, 0)).toBe(1);
expect(nextIndex(3, 1)).toBe(2);
expect(nextIndex(3, 2)).toBe(0); // 末→首
});
it('keeps a single item pinned at 0 (never switches)', () => {
expect(nextIndex(1, 0)).toBe(0);
expect(nextIndex(1, 0, -1)).toBe(0);
expect(nextIndex(0, 0)).toBe(0); // 空表防御:同 0
});
it('steps backwards wrapping from the head to the tail', () => {
expect(nextIndex(3, 0, -1)).toBe(2);
expect(nextIndex(3, 2, -1)).toBe(1);
expect(nextIndex(5, 4, -1)).toBe(3);
});
});
describe('swipeStep', () => {
it('maps an unambiguous horizontal swipe to its direction', () => {
expect(swipeStep(-80, 5)).toBe(1); // 左滑 → 下一张
expect(swipeStep(80, -5)).toBe(-1); // 右滑 → 上一张
});
it('ignores movement under the 40px threshold', () => {
expect(swipeStep(-39, 0)).toBe(0);
expect(swipeStep(39, 2)).toBe(0);
});
it('accepts exactly 40px (>= threshold)', () => {
expect(swipeStep(-40, 0)).toBe(1);
expect(swipeStep(40, 0)).toBe(-1);
});
it('ignores diagonal or vertically dominant movement', () => {
expect(swipeStep(60, 70)).toBe(0); // 垂直分量更大(斜向)
expect(swipeStep(50, 50)).toBe(0); // 等分量:水平须严格大于垂直
expect(swipeStep(-100, 100)).toBe(0);
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* 广告轮播纯逻辑(Task 10 Step 1):推进/环形回绕/滑动手势判定抽为纯函数,
* 供 AdCarousel.vue 复用并由本 spec 直测(不依赖组件挂载)。
*/
/** 轮播推进:环形(末→首);单条恒 0(不切换)。 */
export function nextIndex(count: number, current: number, step: 1 | -1 = 1): number {
if (count <= 1) return 0;
return (current + step + count) % count;
}
/** 滑动手势判定:水平位移 ≥40px 且水平分量大于垂直分量才视为翻页。 */
export function swipeStep(dx: number, dy: number): 1 | -1 | 0 {
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return 0;
return dx < 0 ? 1 : -1;
}
+10
View File
@@ -22,6 +22,11 @@ export type PublicEmbedAlbum = components['schemas']['PublicEmbedAlbum'];
export type PublicEmbedPhoto = components['schemas']['PublicEmbedPhoto'];
export type EmbedSlotSettings = components['schemas']['EmbedSlotSettings'];
// 广告位轮播 Task 10 公开数据类型(ov 域 GET /public/albums/{token}/ads:
// 全局投放 ∨ 绑定本相册的启用广告,仅展示所需字段)。
export type PublicAdListResponse = components['schemas']['PublicAdListResponse'];
export type PublicAd = components['schemas']['PublicAd'];
/** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */
export type SortMode = 'latest' | 'hot';
@@ -140,6 +145,11 @@ export function getPublicPhoto(token: string, photoId: string, locale?: string):
);
}
/** 公开广告列表(Task 10):无广告返回空 items(非 404);失败由调用方静默。 */
export function getPublicAds(token: string): Promise<PublicAdListResponse> {
return apiRequest<PublicAdListResponse>(`/public/albums/${encodeURIComponent(token)}/ads`);
}
/** 点赞批次上报(Task 10):失败重试必须复用同一 batchId(服务端幂等键)。 */
export function postLikeBatch(token: string, photoId: string, body: LikeBatchRequest): Promise<LikeBatchResponse> {
return apiRequest<LikeBatchResponse>(
@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicAd } from '../../api/client';
import { nextIndex, swipeStep } from '../../ads/carousel';
// 广告位轮播(Task 10 Step 3):相册头部 10:3 轮播。4s 自动推进(本地常量,
// 不入契约);pointerenter/pointerdown 暂停、pointerleave 恢复(恢复重开完整
// 4s 窗口);圆点跳页;移动端水平滑动翻页(swipeStep,滑动 cancel 合成 click
// 不打开广告);单条=静态(无圆点无自动切换)。左上角常驻「广告」徽标(合规
// 可识别性,随轮播恒可见)。组件无副作用:会话级关闭记忆(sessionStorage)在
// ActivityPage——此处只 emit close。
/** 自动轮播间隔(本地常量;服务端契约不含节奏,前端自治)。 */
const ROTATE_MS = 4000;
const props = defineProps<{ ads: PublicAd[] }>();
// image-error:当前图加载失败(含现签 URL 过期)上报 adId——页面层节流重拉
// 广告列表换新签名 URL(组件保持无副作用,2026-09-29 裂图修复)。
const emit = defineEmits<{ close: []; imageError: [adId: string] }>();
const { t } = useI18n();
const current = ref(0);
/** 指针悬停/按住期间暂停(恢复时重开完整 4s 窗口,非冻结剩余时长)。 */
const paused = ref(false);
// —— 无缝环回(2026-09-29 用户反馈:末帧→首帧也要右→左自然滑入)——
// 轨道 = [末帧克隆, 真实帧…, 首帧克隆](仅多条时)。正进步进到尾克隆 /
// 后退步进到头克隆都获得单帧宽的自然滑动;过渡结束(定时器兜底)后关过渡、
// 瞬时跳回等视觉位的真实帧,再恢复过渡——观感连续无回卷。
const SLIDE_MS = 450;
/** 轨道渲染位:真实 index + 1(头克隆占位 0);克隆位 0 与 n+1 仅在环回瞬间。 */
const trackPos = ref(1);
/** 瞬时回位期间关闭过渡(transform 跳转不产生动画)。 */
const noTransition = ref(false);
/** 轨道元素(snapBack 强制重排用)。 */
const trackEl = ref<HTMLElement | null>(null);
let snapTimer: ReturnType<typeof setTimeout> | undefined;
/** 轨道帧序列:{ ad, real }——real 为真实 index,克隆帧为 null。 */
const trackSlides = computed(() => {
const n = props.ads.length;
if (n <= 1) return props.ads.map((ad) => ({ ad, real: 0 }));
return [
{ ad: props.ads[n - 1]!, real: null },
...props.ads.map((ad, real) => ({ ad, real })),
{ ad: props.ads[0]!, real: null },
];
});
function clearSnapTimer(): void {
if (snapTimer !== undefined) {
clearTimeout(snapTimer);
snapTimer = undefined;
}
}
async function snapBack(): Promise<void> {
noTransition.value = true;
trackPos.value = current.value + 1;
await nextTick();
// 强制重排 + 双 rAF:确保「无过渡 + 新 transform」先完成一次绘制,再恢复
// 过渡。若在同一帧内直接恢复(浏览器样式计算时过渡仍在),回跳会变成可见
// 的反向长滚(2026-09-29 线上反馈根因;测试假时钟下恰好掩盖)。
void trackEl.value?.offsetWidth;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
noTransition.value = false;
});
});
}
/** 移动到真实 index(圆点/滑动/自动轮播共用):环回走克隆位 + 到位后回跳。 */
function moveTo(index: number): void {
clearSnapTimer();
const n = props.ads.length;
const from = current.value;
let pos = index + 1;
if (n > 1) {
if (from === n - 1 && index === 0) pos = n + 1; // 末→首:走尾克隆(右→左滑入)
else if (from === 0 && index === n - 1) pos = 0; // 首→末:走头克隆(右滑入)
}
current.value = index;
trackPos.value = pos;
if (pos === 0 || pos === n + 1) {
// 过渡时长 + 缓冲后瞬时回位;期间用户再操作会经 clearSnapTimer 作废。
snapTimer = setTimeout(() => {
snapTimer = undefined;
void snapBack();
}, SLIDE_MS + 60);
}
syncRotation();
}
let rotateTimer: ReturnType<typeof setInterval> | undefined;
function stopRotation(): void {
if (rotateTimer !== undefined) {
clearInterval(rotateTimer);
rotateTimer = undefined;
}
}
/** 按当前状态(条数/暂停)对齐定时器:多条件变化共用的一个收敛点。 */
function syncRotation(): void {
stopRotation();
if (props.ads.length <= 1 || paused.value) return;
rotateTimer = setInterval(() => {
moveTo(nextIndex(props.ads.length, current.value));
}, ROTATE_MS);
}
/** 跳页(圆点/滑动共用):就位后重开完整 4s 窗口。 */
function goTo(index: number): void {
moveTo(index);
}
function stepBy(step: 1 | -1): void {
moveTo(nextIndex(props.ads.length, current.value, step));
}
/** 新广告列表(token/locale 重载整表替换)→ 回首项并重对齐轮播。 */
watch(
() => props.ads,
() => {
clearSnapTimer();
noTransition.value = false;
current.value = 0;
trackPos.value = props.ads.length > 1 ? 1 : 0;
syncRotation();
},
);
watch(paused, syncRotation);
onMounted(() => {
trackPos.value = props.ads.length > 1 ? 1 : 0;
syncRotation();
});
onBeforeUnmount(() => {
stopRotation();
clearSnapTimer();
});
let touchStart: { x: number; y: number } | null = null;
function onTouchStart(event: TouchEvent): void {
const touch = event.touches[0];
if (touch !== undefined) touchStart = { x: touch.clientX, y: touch.clientY };
}
function onTouchEnd(event: TouchEvent): void {
if (touchStart === null) return;
const start = touchStart;
touchStart = null;
const touch = event.changedTouches[0];
if (touch === undefined) return;
const step = swipeStep(touch.clientX - start.x, touch.clientY - start.y);
if (step === 0) return;
// 滑动判定成立:取消随后合成的 click(翻页不打开广告链接)。
event.preventDefault();
stepBy(step);
}
function openAd(ad: PublicAd): void {
// noopener/noreferrer:新标签不持有 opener(与 useDownload/EmbedPage 同规)。
window.open(ad.linkUrl, '_blank', 'noopener,noreferrer');
}
</script>
<template>
<!-- aria-live=off:轮播自动切换不应打扰读屏(用户主动操作经按钮语义可达)。 -->
<section
class="ad-carousel" role="region" :aria-label="t('ads.label')" aria-live="off"
@pointerenter="paused = true" @pointerdown="paused = true" @pointerleave="paused = false"
@touchstart="onTouchStart" @touchend="onTouchEnd"
>
<!-- 轨道式滑动(2026-09-29 用户定稿:从右往左滑入,不做淡入淡出):全帧
常驻轨道预加载(首轮切换不空白),track 整体 translateX;首尾克隆帧
令环回也是单帧宽自然滑入(到位后关过渡瞬时回位,见 script);仅活跃
真实帧可聚焦;错误上报只看活跃帧。 -->
<div
ref="trackEl"
class="ad-slides" :class="{ 'no-transition': noTransition }"
:style="{ transform: `translateX(-${trackPos * 100}%)` }"
>
<button
v-for="(slide, i) in trackSlides" :key="`${slide.real ?? 'clone'}-${slide.ad.adId}-${i}`"
type="button" class="ad-slide"
:class="{ active: slide.real !== null && slide.real === current }"
:aria-label="t('ads.open')"
:aria-hidden="slide.real !== null && slide.real === current ? undefined : 'true'"
:tabindex="slide.real !== null && slide.real === current ? 0 : -1"
@click="openAd(slide.ad)"
>
<img
:src="slide.ad.imageUrl"
:alt="t('ads.label')"
decoding="async"
draggable="false"
@error="slide.real !== null && slide.real === current && emit('imageError', slide.ad.adId)"
>
</button>
</div>
<span class="ad-badge">{{ t('ads.label') }}</span>
<button type="button" class="ad-close" :aria-label="t('ads.close')" @click="emit('close')">✕</button>
<nav v-if="ads.length > 1" class="ad-dots" :aria-label="t('ads.dots')">
<button
v-for="(ad, i) in ads" :key="ad.adId" type="button" class="ad-dot"
:class="{ active: i === current }" :aria-current="i === current ? 'true' : undefined"
:aria-label="t('ads.goto', { n: i + 1 })"
@click="goTo(i)"
/>
</nav>
</section>
</template>
<style scoped>
/* 视觉 token 一律 var(--viewer-*)(UI-02 §3.1);半透明层经 color-mix 自 token
* 派生(不写死色值、不扩 token 作用域文件)。 */
.ad-carousel {
position: relative;
width: 100%;
margin: 16px 0;
overflow: hidden;
border-radius: var(--viewer-radius-img);
background: var(--viewer-dark-bg); /* 图像加载期的暗色衬底 */
font-family: var(--viewer-font-family);
/* 水平翻页手势自持(touchstart/end 判定),纵向滚动交还页面——
* ThumbnailStrip pan-x 的对偶(pan-y)。 */
touch-action: pan-y;
}
.ad-slides {
display: flex;
width: 100%;
align-items: flex-start; /* 各帧自然高度(运营上传统一高度图);track 高=最高帧 */
/* 从右往左滑动切换(下一帧自右侧滑入;后退/右滑自然反向)。 */
transition: transform 0.45s ease;
}
/* 环回瞬时回位:克隆位→真实位等视觉跳转(无动画)。 */
.ad-slides.no-transition {
transition: none;
}
.ad-slide {
flex: 0 0 100%;
display: block;
width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
/* 位移类动画尊重系统减动效偏好(HeartComponents 同规)。 */
@media (prefers-reduced-motion: reduce) {
.ad-slides {
transition: none;
}
}
.ad-slide img {
display: block;
width: 100%;
height: auto;
}
.ad-badge {
position: absolute;
top: 2px;
left: 1px;
padding: 2px 10px;
border-radius: var(--viewer-radius-pill);
font-size: 10px;
line-height: 12px;
color: var(--viewer-surface);
background: color-mix(in srgb, #686868 72%, transparent);
pointer-events: none; /* 徽标不拦截 slide 点击 */
}
.ad-close {
position: absolute;
top: 2px;
right: 1px;
width:20px; /* ≥44px 触达 */
height: 20px;
border: 0;
border-radius: 50%;
font-size: 10px;
line-height: 1;
color: var(--viewer-surface);
background: color-mix(in srgb, #686868 72%, transparent);
cursor: pointer;
}
.ad-dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
}
.ad-dot {
padding: 8px; /* 8px 视觉圆点 + 8px 外扩 = 24px 命中区 */
border: 0;
background: transparent;
cursor: pointer;
}
.ad-dot::before {
content: '';
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--viewer-surface) 55%, transparent);
}
.ad-dot.active::before {
background: var(--viewer-surface);
}
/* 键盘焦点指示(AlbumHeader/ThumbnailStrip 同规:2px 主色描边)。主色对
* 暗色衬底/color-mix 蒙层对比 ≈4:1(非文本 ≥3:1)。 */
.ad-slide:focus-visible,
.ad-close:focus-visible,
.ad-dot:focus-visible {
outline: 2px solid var(--viewer-primary);
outline-offset: 1px;
}
</style>
@@ -73,10 +73,6 @@ watch(
watch([titleEl, descEl, titleExpanded, descExpanded], () => measureOverflow(), { flush: 'post' });
// —— 元信息行 ——
const viewsText = computed(() => {
if (props.viewCount === null) return t('viewer.viewsUnknown');
return t('viewer.views', { count: new Intl.NumberFormat(locale.value).format(props.viewCount) });
});
/** 紧凑日期(无时分):首页元信息行用;窄屏也不爆宽。 */
const dateText = computed(() => formatDate({ year: 'numeric', month: 'long', day: 'numeric' }));
@@ -168,6 +164,10 @@ watch(
>
</div>
<!-- 宣传图与标题之间的插入位(广告位轮播 2026-09-29 位置调整:用户定稿
「标题的上面」——ActivityPage 经此插槽注入 AdCarousel,本组件不感知广告域)。 -->
<slot name="after-banner" />
<header class="album-info">
<div class="title-row">
<h1 ref="titleEl" class="title" :class="{ clamped: !titleExpanded }">{{ album.translation.title }}</h1>
@@ -205,10 +205,7 @@ watch(
<!-- 参考稿:元信息行用简洁 Unicode 图标(◉ 浏览 / ▦ 日期 / ♧ 地点) -->
<div class="meta">
<span class="meta-item view-count">
<span class="meta-icon" aria-hidden="true">◉</span>
<span>{{ viewsText }}</span>
</span>
<!-- 浏览次数已按用户要求隐藏(2026-09-24)——后端计数与上报不受影响。 -->
<span v-if="dateText !== ''" class="meta-item" :title="dateFullText">
<span class="meta-icon" aria-hidden="true">▦</span>
<span>{{ dateText }}</span>
@@ -268,8 +265,8 @@ watch(
.banner {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
max-height: 320px;
object-fit: cover;
}
/* —— 信息区:16px 内边距由本区负责(页面无全局包围,§3.2;参考稿 12px 16px 0) —— */
@@ -370,7 +367,7 @@ watch(
gap: 8px 20px;
margin: 9px 0 0;
padding-bottom: 10px;
border-bottom: 1px solid var(--viewer-divider, #e5eaf1);
/* border-bottom: 1px solid var(--viewer-divider, #e5eaf1); */
color: var(--viewer-text-secondary, #647084);
font-size: 0.75rem;
line-height: 1.5;
@@ -392,9 +389,6 @@ watch(
line-height: 1;
text-align: center;
}
.view-count {
font-variant-numeric: tabular-nums;
}
/* —— 排序 tab(参考稿:分隔线由上方元信息行承担;选中项 2px 底线) —— */
.sort-tabs {
@@ -12,7 +12,6 @@
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
import { canNativeShare } from '../../support/share';
import { copyText } from '../../support/clipboard';
const props = defineProps<{
@@ -34,7 +33,6 @@ const copyState = ref<'idle' | 'copied' | 'failed'>('idle');
let copyResetTimer: ReturnType<typeof setTimeout> | undefined;
let qrAbort: { cancel: () => void } | null = null;
const showNativeShare = canNativeShare();
/** 二维码绘制(url 变化重绘;弹层打开时按可见尺寸绘制)。 */
async function drawQr(): Promise<void> {
@@ -82,14 +80,6 @@ async function onCopy(): Promise<void> {
}, 2500);
}
async function onNativeShare(): Promise<void> {
try {
await navigator.share({ title: props.title, url: props.url });
} catch {
// 取消(AbortError)或其他失败:静默——弹层仍在,复制路径可用。
}
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
@@ -142,18 +132,10 @@ onBeforeUnmount(() => {
<canvas ref="qrCanvas" width="188" height="188" aria-hidden="true"></canvas>
</div>
<p class="share-sheet-url" data-test="share-sheet-url">{{ url }}</p>
<!-- 链接文本已按用户要求隐藏(2026-09-24)——二维码 + 复制按钮保留。 -->
<div class="share-sheet-actions">
<button
v-if="showNativeShare"
type="button"
class="share-sheet-btn is-secondary"
data-test="share-sheet-native"
@click="onNativeShare"
>
{{ t('viewer.share') }}
</button>
<!-- 系统分享按钮已按用户要求移除(2026-09-24)——仅保留复制链接。 -->
<button
type="button"
class="share-sheet-btn is-primary"
@@ -266,14 +248,6 @@ onBeforeUnmount(() => {
width: 188px;
height: 188px;
}
.share-sheet-url {
margin: 0 0 12px;
font-size: 0.75rem;
color: #647084;
word-break: break-all;
text-align: center;
user-select: text;
}
.share-sheet-actions {
display: flex;
gap: 10px;
@@ -297,13 +271,6 @@ onBeforeUnmount(() => {
.share-sheet-btn.is-primary[data-state='failed'] {
background: #b54a4a;
}
.share-sheet-btn.is-secondary {
flex: 0 0 auto;
padding: 0 18px;
background: #ffffff;
border-color: var(--viewer-divider, #d7dfeb);
color: var(--viewer-text, #172033);
}
.share-sheet-btn:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: 2px;
+8
View File
@@ -19,6 +19,14 @@ export default {
},
lang: { switch: 'Switch language' },
prefs: { reduceMotion: 'Reduce motion' },
// Ad carousel (Task 10): badge text = label (compliance, always visible).
ads: {
label: 'Ad',
close: 'Close ads',
open: 'Open ad link',
dots: 'Carousel position',
goto: 'Go to {n}',
},
// Embed carousel iframe page (P4b Task 4): subset driven by the lang query.
embed: {
comingSoon: 'Photos coming soon',
+8
View File
@@ -19,6 +19,14 @@ export default {
},
lang: { switch: '切换语言' },
prefs: { reduceMotion: '减少动画' },
// 广告位轮播(Task 10):徽标文案=label(合规可识别性,随轮播恒可见)。
ads: {
label: '广告',
close: '关闭广告',
open: '打开广告链接',
dots: '轮播位置',
goto: '第 {n} 张',
},
// 嵌入轮播 iframe 页(P4b Task 4;UI-01 补全):独立于宿主语言的词条子集
// (lang 查询驱动)。图片无真实描述时只给上下文标签(§5.3 不编造画面)。
embed: {
+56 -1
View File
@@ -5,14 +5,17 @@ import { useRoute, useRouter } from 'vue-router';
import {
ApiError,
NETWORK_ERROR_CODE,
getPublicAds,
getPublicAlbum,
getPublicPhoto,
getPublicPhotos,
type PublicAd,
type PublicAlbum,
type SortMode,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import { useDownload } from '../download/useDownload';
import AdCarousel from '../components/ads/AdCarousel.vue';
import AlbumHeader from '../components/album/AlbumHeader.vue';
import LikeBubbles from '../components/LikeBubbles.vue';
import PhotoFeed from '../components/PhotoFeed.vue';
@@ -56,6 +59,8 @@ const loadErrorKey = ref<string | null>(null);
const loading = ref(true);
/** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */
const viewCount = ref<number | null>(null);
/** 广告位轮播数据(Task 10):相册加载成功后独立拉取;关闭为会话级记忆。 */
const ads = ref<PublicAd[]>([]);
// 快速连续切换语言/路由时的竞态防护:仅最新请求的结果可落地。
let loadSequence = 0;
@@ -69,6 +74,20 @@ async function load(): Promise<void> {
album.value = data;
loadErrorKey.value = null;
viewCount.value = data.viewCount;
// 广告独立拉取(Task 10):失败静默(广告缺席不阻塞相册浏览);会话级
// 关闭记忆先行(命中则连请求都省)。沿用 sequence 防跨相册竞态落地。
ads.value = [];
if (sessionStorage.getItem('viewer.adsDismissed') !== '1') {
getPublicAds(token.value)
.then((result) => {
if (sequence !== loadSequence) return;
ads.value = result.items;
})
.catch(() => {
if (sequence !== loadSequence) return;
ads.value = [];
});
}
// 浏览上报(Task 10):加载成功后(可见时)一次性上报——幂等键由 reporter
// 持久,窗口内刷新不重复计数(A14)。
visitReport.notifyAlbumLoaded();
@@ -101,6 +120,32 @@ function selectCategory(categoryId: string | null): void {
selectedCategory.value = categoryId;
}
/** 关闭广告(Task 10):会话级记忆(本次会话不再拉取/渲染),组件只 emit。 */
function dismissAds(): void {
sessionStorage.setItem('viewer.adsDismissed', '1');
ads.value = [];
}
/** 裂图自愈(2026-09-29):图加载失败(含现签 URL 过期)→ 节流重拉广告列表
* 换新签名 URL。冷却 60s:真坏图(对象缺失)重拉也无济于事,防循环;关闭态
* 不再打扰。 */
const ADS_REFRESH_COOLDOWN_MS = 60_000;
let adsLastRefreshAt = 0;
function refreshAdsOnImageError(): void {
if (sessionStorage.getItem('viewer.adsDismissed') === '1') return;
const now = Date.now();
if (now - adsLastRefreshAt < ADS_REFRESH_COOLDOWN_MS) return;
adsLastRefreshAt = now;
getPublicAds(token.value)
.then((result) => {
ads.value = result.items;
})
.catch(() => {
/* 静默:保持现状,下一轮播错误再试 */
});
}
// —— 照片序列(Task 8 提升:feed + lightbox 共享) ——
/** 排序模式(Task 10):latest=发布倒序(缺省)/ hot=点赞倒序短期快照。
@@ -338,7 +383,17 @@ onBeforeUnmount(() => {
:sort-locked="lightboxPhotoId !== null"
@select-sort="selectSort"
@select-category="selectCategory"
/>
>
<!-- 广告轮播:宣传图与标题之间(用户定稿 2026-09-29;无 banner 时仍居标题上方)。 -->
<template #after-banner>
<AdCarousel
v-if="ads.length > 0"
:ads="ads"
@close="dismissAds"
@image-error="refreshAdsOnImageError"
/>
</template>
</AlbumHeader>
<!-- 深链目标不可见(404 防枚举同码):页内本地化提示 + 返回相册。 -->
<div v-if="deepLinkNotFound || deepLinkErrorKey !== null" class="deep-link-notice" role="alert">
+8 -24
View File
@@ -18,7 +18,6 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useEmbedFeed } from '../embed/useEmbedFeed';
import { clampEmbedIntervalMs, EMBED_CAROUSEL_DEFAULT_MS } from '../embed/feed';
import { postEmbedMessage } from '../embed/postMessage';
import EmbedCarousel from './embed/EmbedCarousel.vue';
import EmbedStream from './embed/EmbedStream.vue';
@@ -118,28 +117,13 @@ const albumUrl = computed(() => {
return url.toString();
});
/** v2 照片深链构造器(真实 <a> href;受控打开同 openPhoto)。 */
function streamPhotoUrl(photoId: string): string {
const albumToken = feed.album?.publicToken;
const base = albumToken ?? '';
const url = new URL(`/a/${encodeURIComponent(base)}/p/${encodeURIComponent(photoId)}`, window.location.origin);
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
return url.toString();
}
function openPhoto(photoId: string): void {
const albumToken = feed.album?.publicToken;
if (albumToken === undefined) return;
const url = new URL(`/a/${encodeURIComponent(albumToken)}/p/${encodeURIComponent(photoId)}`, window.location.origin);
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
// 宿主内弹框(用户决策 2026-09-23):有父窗(loader 嵌入)→ postMessage
// 由宿主弹框内嵌详情页,不跳出当前网站;无父窗(直接打开/预览)→ 新标签
// noopener(sandbox allow-popups-to-escape-sandbox 配合)。
if (window.parent !== window) {
postEmbedMessage({ photoliveEmbed: true, type: 'open-photo', url: url.toString() });
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
// 用户决策 2026-09-24:点击照片 → 新标签页打开观众页(相册页),不定位到
// 具体照片、不在当前页弹框——v1 与 v2 统一该语义(此前的宿主内弹框与照片
// 深链均退役;loader v2 的 open-photo 弹框逻辑保留但不再被触发)。
function openPhoto(): void {
if (albumUrl.value === null) return;
// noopener:新标签页不持有 opener(sandbox allow-popups-to-escape-sandbox 配合)。
window.open(albumUrl.value, '_blank', 'noopener,noreferrer');
}
</script>
@@ -151,7 +135,7 @@ function openPhoto(photoId: string): void {
:photos="feed.status === 'ready' ? feed.photos : []"
:album-token="feed.album?.publicToken ?? null"
:album-title="feed.album?.title ?? ''"
:photo-url="streamPhotoUrl"
:photo-url="() => albumUrl ?? '#'"
:speed="streamSpeed"
:reactions="streamReactions"
:active="feed.active"
+4
View File
@@ -57,6 +57,10 @@ RUN pnpm --filter @photo/contracts run build \
# ── 阶段 2:nginx ──
FROM nginx:alpine
# G4 §4.8 事件 B:最新 digest 的 10-listen-on-ipv6 entrypoint 在服务器卡死
# (nginx 不启动、healthcheck 拒连)——构建期移除该脚本(本地旧 digest 无此问题,
# 服务器新拉必现)。钉基础镜像版本后可移除本行(待办 G4 登记)。
RUN rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
# 官方镜像启动时 envsubst 渲染 /etc/nginx/templates/*.template → conf.d;
# API_UPSTREAM 由 compose environment 注入(缺省指向 cn API 容器名)。
ENV API_UPSTREAM=http://api-cn:8080

Some files were not shown because too many files have changed in this diff Show More