diff --git a/apps/mobile/lib/core/api_client.dart b/apps/mobile/lib/core/api_client.dart index c1c119a..7dfe934 100644 --- a/apps/mobile/lib/core/api_client.dart +++ b/apps/mobile/lib/core/api_client.dart @@ -330,17 +330,48 @@ class ApiClient { /// 一步上传相册封面:选图 → presign → PUT → complete。 /// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。 /// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。 - Future uploadAlbumCoverFromFile( + Future uploadAlbumCoverFromFile(String albumId, String filePath) => + uploadAlbumAssetFromFile( + albumId, + kind: assetKindCover, + filePath: filePath, + ); + + /// 资产现状(listAlbumAssetStatus):指定 kind 的全局行(locale null)URL; + /// 未设置返回 null。banner 预览用(语言行不区分——编辑页只管全局)。 + Future fetchAlbumAssetUrl( String albumId, - String filePath, - ) async { + String kind, { + String? locale, + }) async { + final resp = await _send( + () => _dio.get>('/v1/albums/$albumId/assets'), + ); + final items = (resp.data!['items'] as List) + .cast>(); + 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 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, diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 5905e57..1839ae0 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -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" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index e587c5f..2204946 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -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 diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 8d6bb9b..41df308 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 258d252..933b836 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -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 => '宣传图上传失败'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 6720737..c28b0ac 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -417,5 +417,9 @@ "deleteAlbum": "删除相册", "deleteAlbumConfirmTitle": "删除这个相册?", "deleteAlbumConfirmBody": "删除后立即不可见且不可恢复。数据保留,恢复需运维处理。", - "albumDeleted": "相册已删除" + "albumDeleted": "相册已删除", + "bannerPicker": "宣传图", + "bannerPickerHint": "点击选择宣传图(展示在观众页顶部)", + "bannerSelected": "已选择宣传图——保存时上传", + "bannerUploadFailed": "宣传图上传失败" } diff --git a/apps/mobile/lib/pages/album_form_page.dart b/apps/mobile/lib/pages/album_form_page.dart index d0b6235..a0fecdf 100644 --- a/apps/mobile/lib/pages/album_form_page.dart +++ b/apps/mobile/lib/pages/album_form_page.dart @@ -89,16 +89,34 @@ class _AlbumFormPageState extends ConsumerState /// 创建模式:相册创建成功后上传;编辑模式:保存后上传。 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.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 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 ); } } + // 宣传图上传:同封面——保存成功后执行,失败不阻塞表单结果。 + 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 ), // 保存按钮固定底部:键盘打开时常驻可达(§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 body: mainTabs == null ? _detailsForm(l10n) : _mainIndex == 0 - ? _detailsForm(l10n) - : _accessPanel(l10n), + ? _detailsForm(l10n) + : _accessPanel(l10n), ), ); } @@ -396,8 +437,7 @@ class _AlbumFormPageState extends ConsumerState 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 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 _pickCoverImage() async { final path = await ref.read(singleImagePickerProvider)(); @@ -466,6 +507,81 @@ class _AlbumFormPageState extends ConsumerState } } + Future _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 children: [ // ── 封面选择(16:9 可点击区域) ───────────────── _buildCoverPicker(l10n), + const SizedBox(height: kSpace16), + + // ── 宣传图选择(3:1 横幅;编辑模式显示现有 banner)── + _buildBannerPicker(l10n), const SizedBox(height: kSpace24), // ── 分组 1:Event details ────────────────────────── diff --git a/apps/mobile/test/pages/album_form_page_test.dart b/apps/mobile/test/pages/album_form_page_test.dart index 6d92393..d518a43 100644 --- a/apps/mobile/test/pages/album_form_page_test.dart +++ b/apps/mobile/test/pages/album_form_page_test.dart @@ -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), diff --git a/apps/viewer/e2e/verify-share.mjs b/apps/viewer/e2e/verify-share.mjs new file mode 100644 index 0000000..e525cd9 --- /dev/null +++ b/apps/viewer/e2e/verify-share.mjs @@ -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(); diff --git a/docs/designs/ui04-implementation/actual/server/share-sheet-album.png b/docs/designs/ui04-implementation/actual/server/share-sheet-album.png new file mode 100644 index 0000000..a4ff8fe Binary files /dev/null and b/docs/designs/ui04-implementation/actual/server/share-sheet-album.png differ