Compare commits
91 Commits
d331d55880
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53c25697a6 | |||
| b0a3515bf2 | |||
| 8c47a88c79 | |||
| 38b913b429 | |||
| 048732dfef | |||
| f88c1c170a | |||
| d0755aa466 | |||
| 07f81ff59b | |||
| 29204bc08a | |||
| a90cb9953e | |||
| 3728eb93d9 | |||
| f1912998a8 | |||
| 3833bc3bc6 | |||
| 744d45cc61 | |||
| 8741bded7f | |||
| a02d7a5e98 | |||
| c68e7115e4 | |||
| 0673f394d5 | |||
| 7f8cda1cd3 | |||
| 7e5c550fb3 | |||
| 2261b8aebc | |||
| 328140494f | |||
| c64db3c53a | |||
| 19d4fd143a | |||
| bfba76b5e6 | |||
| 041cfc77b7 | |||
| b3e89f7efb | |||
| 239ef5648f | |||
| 7f9789eb46 | |||
| f1c13bff7c | |||
| 80405cfcc1 | |||
| 373f0fc533 | |||
| 9ec8226577 | |||
| 60b675a908 | |||
| 4979091d2c | |||
| 121afadf1e | |||
| 4db90b0e9e | |||
| d86f452bed | |||
| 539597e9f0 | |||
| 318f69dae7 | |||
| 726f6a1be8 | |||
| 001d3aaf73 | |||
| ec55d5718b | |||
| af8f3b78dd | |||
| ac37794d43 | |||
| 0fa23e015e | |||
| 64e1be09d3 | |||
| 805f77be76 | |||
| 7d744da56e | |||
| 4b5940c0d8 | |||
| 3b7d37110a | |||
| 678225e8b5 | |||
| 8453167b39 | |||
| ab412a3630 | |||
| 0220a9cc05 | |||
| 573a559e4a | |||
| 46f9d93fd3 | |||
| 29f3bde9c2 | |||
| 4f89c25bad | |||
| ac602acf47 | |||
| 65cd6849cc | |||
| 683e19bdc1 | |||
| 97db504e25 | |||
| 2e5fc40636 | |||
| aea797c7c7 | |||
| 45dbde3719 | |||
| 203f62e7cb | |||
| 93e7046332 | |||
| b79edf447b | |||
| dc5ae4a24a | |||
| cc47278f50 | |||
| 441a24a54c | |||
| 90dcc58c33 | |||
| 33f347cdea | |||
| 638dd9b45d | |||
| 9edcb3f2b1 | |||
| 98e073d8c8 | |||
| 984fe8981c | |||
| a5d71686ff | |||
| dc8d3ff967 | |||
| 2ce529dd5b | |||
| d279cefd71 | |||
| 643f837bca | |||
| cf490c7892 | |||
| 07761b1b26 | |||
| c199d0630c | |||
| bd4db7b458 | |||
| 32eb4ceb8e | |||
| 76380477a0 | |||
| e5302e3929 | |||
| 30a3520669 |
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "登录失败次数过多,请稍后再试",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
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(""))
|
||||
|
||||
|
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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -244,6 +244,7 @@ class AlbumCard extends StatelessWidget {
|
||||
child: PhotoThumbnail(
|
||||
url: album.cover?.url,
|
||||
aspectRatio: 16 / 9,
|
||||
maxCacheWidth: 600,
|
||||
),
|
||||
),
|
||||
const Positioned.fill(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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%)'); // 等视觉真实首帧
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||