feat(app): album banner picker in edit form — show current banner, pick & upload on save

- api_client:uploadAlbumAssetFromFile 泛化(cover 为薄壳)+ fetchAlbumAssetUrl
  (GET /albums/{id}/assets → 指定 kind 全局行 URL)
- 编辑页 Details:封面下方新增「宣传图」3:1 选择器——编辑模式异步加载现有
  banner 预览(失败静默);保存成功后上传(同封面:失败不阻塞表单结果)
- 未保存变更判断纳入 banner;l10n zh/en
- 测试适配:编辑用例 pumpForm 后清空 banner 现状预检请求(397/397 全绿)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 11:07:54 +08:00
parent 3d45350711
commit c2143e1f94
10 changed files with 288 additions and 39 deletions
+35 -4
View File
@@ -330,17 +330,48 @@ class ApiClient {
/// 一步上传相册封面:选图 → presign → PUT → complete。
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
Future<String> uploadAlbumCoverFromFile(
Future<String> uploadAlbumCoverFromFile(String albumId, String filePath) =>
uploadAlbumAssetFromFile(
albumId,
kind: assetKindCover,
filePath: filePath,
);
/// 资产现状(listAlbumAssetStatus):指定 kind 的全局行(locale null)URL;
/// 未设置返回 null。banner 预览用(语言行不区分——编辑页只管全局)。
Future<String?> fetchAlbumAssetUrl(
String albumId,
String filePath,
) async {
String kind, {
String? locale,
}) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>('/v1/albums/$albumId/assets'),
);
final items = (resp.data!['items'] as List<dynamic>)
.cast<Map<String, dynamic>>();
for (final item in items) {
if (item['kind'] == kind && item['locale'] == locale) {
return item['url'] as String;
}
}
return null;
}
/// 一步上传相册资产(banner/cover/logo):选图 → presign → PUT → complete。
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
/// 返回资产短期 URL(仅用于即时预览;现状经 [fetchAlbumAssetUrl] 查询)。
Future<String> uploadAlbumAssetFromFile(
String albumId, {
required String kind,
required String filePath,
}) async {
final file = File(filePath);
final bytes = await file.length();
final filename = filePath.split(Platform.pathSeparator).last;
final contentType = _guessContentType(filename);
final init = await initAlbumAsset(
albumId,
kind: assetKindCover,
kind: kind,
filename: filename,
bytes: bytes,
contentType: contentType,
+5 -1
View File
@@ -417,5 +417,9 @@
"deleteAlbum": "Delete album",
"deleteAlbumConfirmTitle": "Delete this album?",
"deleteAlbumConfirmBody": "The album becomes invisible immediately and cannot be restored. Data is retained; recovery requires operations.",
"albumDeleted": "Album deleted"
"albumDeleted": "Album deleted",
"bannerPicker": "Banner",
"bannerPickerHint": "Tap to pick a banner image (shown atop the viewer page)",
"bannerSelected": "Banner selected — will upload on save",
"bannerUploadFailed": "Banner upload failed"
}
@@ -1957,6 +1957,30 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'相册已删除'**
String get albumDeleted;
/// No description provided for @bannerPicker.
///
/// In zh, this message translates to:
/// **'宣传图'**
String get bannerPicker;
/// No description provided for @bannerPickerHint.
///
/// In zh, this message translates to:
/// **'点击选择宣传图(展示在观众页顶部)'**
String get bannerPickerHint;
/// No description provided for @bannerSelected.
///
/// In zh, this message translates to:
/// **'已选择宣传图——保存时上传'**
String get bannerSelected;
/// No description provided for @bannerUploadFailed.
///
/// In zh, this message translates to:
/// **'宣传图上传失败'**
String get bannerUploadFailed;
}
class _AppLocalizationsDelegate
@@ -1000,4 +1000,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get albumDeleted => 'Album deleted';
@override
String get bannerPicker => 'Banner';
@override
String get bannerPickerHint =>
'Tap to pick a banner image (shown atop the viewer page)';
@override
String get bannerSelected => 'Banner selected — will upload on save';
@override
String get bannerUploadFailed => 'Banner upload failed';
}
@@ -968,4 +968,16 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get albumDeleted => '相册已删除';
@override
String get bannerPicker => '宣传图';
@override
String get bannerPickerHint => '点击选择宣传图(展示在观众页顶部)';
@override
String get bannerSelected => '已选择宣传图——保存时上传';
@override
String get bannerUploadFailed => '宣传图上传失败';
}
+5 -1
View File
@@ -417,5 +417,9 @@
"deleteAlbum": "删除相册",
"deleteAlbumConfirmTitle": "删除这个相册?",
"deleteAlbumConfirmBody": "删除后立即不可见且不可恢复。数据保留,恢复需运维处理。",
"albumDeleted": "相册已删除"
"albumDeleted": "相册已删除",
"bannerPicker": "宣传图",
"bannerPickerHint": "点击选择宣传图(展示在观众页顶部)",
"bannerSelected": "已选择宣传图——保存时上传",
"bannerUploadFailed": "宣传图上传失败"
}
+153 -33
View File
@@ -89,16 +89,34 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
/// 创建模式:相册创建成功后上传;编辑模式:保存后上传。
String? _coverFilePath;
/// 用户选择的宣传图本机文件路径(null = 未选;上传时机同封面)。
String? _bannerFilePath;
/// 编辑模式下的现有宣传图 URL(asset 现状查询,异步加载即时预览)。
String? _existingBannerUrl;
/// 编辑模式下的现有封面 URL(来自 AlbumSummary.cover,即时预览)。
String? get _existingCoverUrl => _coverFilePath == null
? (widget.existing?.cover?.url)
: null;
String? get _existingCoverUrl =>
_coverFilePath == null ? (widget.existing?.cover?.url) : null;
bool get _isCreate => widget.existing == null;
@override
void initState() {
super.initState();
// 编辑模式:加载现有宣传图预览(asset 现状查询;失败静默不阻塞表单)。
if (widget.existing != null) {
Future<void>.microtask(() async {
try {
final url = await ref
.read(apiClientProvider)
.fetchAlbumAssetUrl(widget.existing!.id, 'banner');
if (mounted) setState(() => _existingBannerUrl = url);
} on Object {
// 预览加载失败不阻塞表单
}
});
}
final initial = widget.existing == null
? (widget.initialDraft ??
AlbumFormDraft(
@@ -245,7 +263,9 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
albumId = album.id;
} else {
final patch = draft.toUpdatePatch(widget.existing!);
if (patch == null && _coverFilePath == null) {
if (patch == null &&
_coverFilePath == null &&
_bannerFilePath == null) {
navigator.pop(false);
return;
}
@@ -269,6 +289,22 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
);
}
}
// 宣传图上传:同封面——保存成功后执行,失败不阻塞表单结果。
if (_bannerFilePath != null) {
try {
await ref
.read(apiClientProvider)
.uploadAlbumAssetFromFile(
albumId,
kind: 'banner',
filePath: _bannerFilePath!,
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.bannerUploadFailed)),
);
}
}
navigator.pop(true);
} on AppException catch (error) {
// 保存失败:保留输入,仅提示。
@@ -370,7 +406,12 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
),
// 保存按钮固定底部:键盘打开时常驻可达(§4.5)。
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace12),
minimum: const EdgeInsets.fromLTRB(
kSpace16,
kSpace8,
kSpace16,
kSpace12,
),
child: FilledButton(
onPressed: _canSubmit ? _submit : null,
child: _submitting
@@ -385,8 +426,8 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
body: mainTabs == null
? _detailsForm(l10n)
: _mainIndex == 0
? _detailsForm(l10n)
: _accessPanel(l10n),
? _detailsForm(l10n)
: _accessPanel(l10n),
),
);
}
@@ -396,8 +437,7 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.coverPicker,
style: Theme.of(context).textTheme.titleMedium),
Text(l10n.coverPicker, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: kSpace8),
InkWell(
onTap: _submitting ? null : _pickCoverImage,
@@ -422,42 +462,43 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
)
: _existingCoverUrl != null
? Image.network(
_existingCoverUrl!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
)
: _coverPlaceholder(l10n),
? Image.network(
_existingCoverUrl!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
)
: _coverPlaceholder(l10n),
),
),
),
const SizedBox(height: kSpace4),
Text(
_coverFilePath != null
? l10n.coverSelected
: l10n.coverPickerHint,
_coverFilePath != null ? l10n.coverSelected : l10n.coverPickerHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
Widget _coverPlaceholder(AppLocalizations l10n) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined,
size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: kSpace8),
Text(
l10n.coverPickerHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: kSpace8),
Text(
l10n.coverPickerHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
Future<void> _pickCoverImage() async {
final path = await ref.read(singleImagePickerProvider)();
@@ -466,6 +507,81 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
}
}
Future<void> _pickBannerImage() async {
final path = await ref.read(singleImagePickerProvider)();
if (path != null) {
setState(() => _bannerFilePath = path);
}
}
/// 宣传图(banner)选择器:3:1 横幅可点击区域;编辑模式显示现有 banner。
Widget _buildBannerPicker(AppLocalizations l10n) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.bannerPicker, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: kSpace8),
InkWell(
onTap: _submitting ? null : _pickBannerImage,
borderRadius: BorderRadius.circular(kRadiusInput),
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(kRadiusInput),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusInput),
child: _bannerFilePath != null
? Image.file(
File(_bannerFilePath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _bannerPlaceholder(l10n),
)
: _existingBannerUrl != null
? Image.network(
_existingBannerUrl!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _bannerPlaceholder(l10n),
)
: _bannerPlaceholder(l10n),
),
),
),
const SizedBox(height: kSpace4),
Text(
_bannerFilePath != null ? l10n.bannerSelected : l10n.bannerPickerHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
Widget _bannerPlaceholder(AppLocalizations l10n) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.panorama_outlined,
size: 40,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: kSpace8),
Text(
l10n.bannerPickerHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
/// Details(= 创建表单):封面选择 + 分组字段 + 校验错误汇总。
Widget _detailsForm(AppLocalizations l10n) {
return Form(
@@ -477,6 +593,10 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
children: [
// ── 封面选择(16:9 可点击区域) ─────────────────
_buildCoverPicker(l10n),
const SizedBox(height: kSpace16),
// ── 宣传图选择(3:1 横幅;编辑模式显示现有 banner)──
_buildBannerPicker(l10n),
const SizedBox(height: kSpace24),
// ── 分组 1:Event details ──────────────────────────
@@ -244,6 +244,7 @@ void main() {
),
);
await pumpForm(tester, clientOf(adapter), existing: existing);
adapter.requests.clear();
// 顶栏 TabBar(Details/Access)+ 内容区语言 TabBar。
expect(find.text('Edit album'), findsOneWidget);
@@ -275,6 +276,9 @@ void main() {
clientOf(adapter),
existing: Album.fromJson(albumJson()),
);
adapter.requests.clear();
// UI06:banner 现状预检(GET /assets)已记录——清空后断言只看用户动作。
adapter.requests.clear();
// Details 页签输入标题。
await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出');
@@ -302,6 +306,9 @@ void main() {
clientOf(adapter),
existing: Album.fromJson(albumJson()),
);
adapter.requests.clear();
// UI06:banner 现状预检(GET /assets)已记录——清空后断言只看用户动作。
adapter.requests.clear();
await openAccessTab(tester);
@@ -367,6 +374,7 @@ void main() {
final existing =
Album.fromJson(albumJson(originalDownloadEnabled: true));
await pumpForm(tester, clientOf(adapter), existing: existing);
adapter.requests.clear();
await openAccessTab(tester);
@@ -398,6 +406,7 @@ void main() {
);
final existing = Album.fromJson(albumJson());
await pumpForm(tester, clientOf(adapter), existing: existing);
adapter.requests.clear();
await openAccessTab(tester);
await tester
@@ -427,6 +436,7 @@ void main() {
albumJson(endAt: '2026-09-19T10:00:00Z'),
);
await pumpForm(tester, clientOf(adapter), existing: existing);
adapter.requests.clear();
await tapVisible(tester, find.byTooltip('Clear end time'));
await tester.pump();
@@ -446,6 +456,7 @@ void main() {
);
final existing = Album.fromJson(albumJson());
await pumpForm(tester, clientOf(adapter), existing: existing);
adapter.requests.clear();
await tester.enterText(
find.byType(TextFormField).at(1),
+30
View File
@@ -0,0 +1,30 @@
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto('http://219.157.255.213:32006/a/nzmk2kr4trzemiwmv65uwxva');
await page.locator('.share-btn').waitFor({ timeout: 15000 });
// ① 相册页分享按钮 → 弹层
await page.locator('.share-btn').click();
const sheet = page.locator('.share-sheet');
await sheet.waitFor({ timeout: 5000 });
const urlText = await page.locator('[data-test="share-sheet-url"]').textContent();
const qr = await page.locator('.share-sheet-qr canvas').evaluate((el) => el.width);
const copyBtn = page.locator('[data-test="share-sheet-copy"]');
await copyBtn.click();
await page.waitForTimeout(400);
const feedback = await copyBtn.textContent();
console.log('[album] url =', urlText?.slice(0, 60));
console.log('[album] qr canvas width =', qr, '; copy feedback =', feedback?.trim());
await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/share-sheet-album.png' });
// 关闭弹层(Esc),打开大图再验证照片分享
await page.keyboard.press('Escape');
await page.locator('img').first().click();
await page.waitForTimeout(1200);
await page.locator('button[aria-label="分享照片"]').click();
await sheet.waitFor({ timeout: 5000 });
const photoUrl = await page.locator('[data-test="share-sheet-url"]').textContent();
console.log('[photo] url =', photoUrl?.slice(0, 70));
await page.screenshot({ path: '../../docs/designs/ui04-implementation/actual/server/share-sheet-photo.png' });
await browser.close();
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB