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:
@@ -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,
|
||||
|
||||
@@ -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 => '宣传图上传失败';
|
||||
}
|
||||
|
||||
@@ -417,5 +417,9 @@
|
||||
"deleteAlbum": "删除相册",
|
||||
"deleteAlbumConfirmTitle": "删除这个相册?",
|
||||
"deleteAlbumConfirmBody": "删除后立即不可见且不可恢复。数据保留,恢复需运维处理。",
|
||||
"albumDeleted": "相册已删除"
|
||||
"albumDeleted": "相册已删除",
|
||||
"bannerPicker": "宣传图",
|
||||
"bannerPickerHint": "点击选择宣传图(展示在观众页顶部)",
|
||||
"bannerSelected": "已选择宣传图——保存时上传",
|
||||
"bannerUploadFailed": "宣传图上传失败"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user