diff --git a/apps/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts index c0a4a4f..793a619 100644 --- a/apps/mobile/android/app/build.gradle.kts +++ b/apps/mobile/android/app/build.gradle.kts @@ -30,6 +30,13 @@ android { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") + // R8 混淆会破坏 WorkManager 反射初始化(NoSuchMethodException + // WorkDatabase_Impl),ProGuard keep 规则见 proguard-rules.pro。 + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } } diff --git a/apps/mobile/android/app/proguard-rules.pro b/apps/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000..5c2325d --- /dev/null +++ b/apps/mobile/android/app/proguard-rules.pro @@ -0,0 +1,8 @@ +# WorkManager: R8 反射初始化需要保留构造器(P3 Task 7 后台上传桥接) +-keep class androidx.work.impl.WorkDatabase_Impl { *; } +-keep class androidx.work.** { *; } +-keep class androidx.startup.** { *; } + +# OkHttp(原生上传传输):公开 API 保留即可,但 safety net 防混淆异常 +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/apps/mobile/android/app/src/debug/res/xml/network_security_config.xml b/apps/mobile/android/app/src/debug/res/xml/network_security_config.xml index 058f594..69aae96 100644 --- a/apps/mobile/android/app/src/debug/res/xml/network_security_config.xml +++ b/apps/mobile/android/app/src/debug/res/xml/network_security_config.xml @@ -2,11 +2,13 @@ + src/debug 变体参与合并)。放行宿主回环、本机回环与测试服务器 IP + (219.157.255.213,线上联调 http 反代 8088/8089),不放开全域。 --> 10.0.2.2 127.0.0.1 localhost + 219.157.255.213 diff --git a/apps/mobile/android/build/reports/problems/problems-report.html b/apps/mobile/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..f0c764f --- /dev/null +++ b/apps/mobile/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/apps/mobile/lib/core/api_client.dart b/apps/mobile/lib/core/api_client.dart index cd40bc0..cec09b0 100644 --- a/apps/mobile/lib/core/api_client.dart +++ b/apps/mobile/lib/core/api_client.dart @@ -10,6 +10,8 @@ /// 抛 [AppException](码表见 [ErrorCodes];l10n 文案解析留在 UI 层)。 library; +import 'dart:io' show File, Platform; + import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -282,6 +284,80 @@ class ApiClient { Future> listAlbums({String? cursor, int? limit}) => _getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: limit); + /// 资产上传凭证(契约 AssetInitResponse)。 + /// kind:cover | banner | logo;凭据结构与照片 presign 相同。 + static const assetKindCover = 'cover'; + static const assetKindBanner = 'banner'; + + Future<({String assetId, UploadCredential credential})> + initAlbumAsset(String albumId, { + required String kind, + required String filename, + required int bytes, + required String contentType, + }) async { + final resp = await _send( + () => _dio.post>('/v1/albums/$albumId/assets', data: { + 'kind': kind, + 'filename': filename, + 'bytes': bytes, + 'contentType': contentType, + }), + ); + final data = resp.data!; + return ( + assetId: data['assetId'] as String, + credential: UploadCredential.fromJson(data['credential'] as Map), + ); + } + + /// 完成资产上传并激活(返回短期签名 URL)。 + Future completeAlbumAsset(String albumId, String assetId) async { + final resp = await _send( + () => _dio.post>( + '/v1/albums/$albumId/assets/$assetId/complete', + ), + ); + return resp.data!['url'] as String; + } + + /// 一步上传相册封面:选图 → presign → PUT → complete。 + /// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。 + /// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。 + Future uploadAlbumCoverFromFile(String albumId, 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, + filename: filename, + bytes: bytes, + contentType: contentType); + final putRes = await Dio().put( + init.credential.url, + data: file.openRead(), + options: Options(headers: { + ...init.credential.headers, + 'Content-Length': bytes, + }), + ); + if (putRes.statusCode != 200 && putRes.statusCode != 204) { + throw Exception('cover PUT failed: ${putRes.statusCode}'); + } + return completeAlbumAsset(albumId, init.assetId); + } + + String _guessContentType(String filename) { + final ext = filename.split('.').last.toLowerCase(); + return switch (ext) { + 'jpg' || 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'heic' => 'image/heic', + _ => 'image/jpeg', + }; + } + /// operationId `createAlbum`(POST /albums → 201)。 Future createAlbum(AlbumCreateRequest req) async { final resp = await _send( diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 62dc65c..c28db80 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -80,6 +80,10 @@ "formTitleRequiredWithFields": "Title is required when description, venue or address is filled", "formEndAtNotAfterStartAt": "End time must be after start time", "formEventDetails": "Event details", + "coverPicker": "Album cover", + "coverPickerHint": "Tap to select a cover image (optional)", + "coverSelected": "Cover selected, will upload on save", + "coverUploadFailed": "Cover upload failed, you can retry from album detail later", "formDescriptionLanguages": "Description & languages", "formDefaultLanguage": "Default language", "formDetailsTab": "Details", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 038621c..b69d6a0 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -572,6 +572,30 @@ abstract class AppLocalizations { /// **'活动信息'** String get formEventDetails; + /// No description provided for @coverPicker. + /// + /// In zh, this message translates to: + /// **'相册封面'** + String get coverPicker; + + /// No description provided for @coverPickerHint. + /// + /// In zh, this message translates to: + /// **'点击选择封面图片(可选)'** + String get coverPickerHint; + + /// No description provided for @coverSelected. + /// + /// In zh, this message translates to: + /// **'已选择封面,保存后上传'** + String get coverSelected; + + /// No description provided for @coverUploadFailed. + /// + /// In zh, this message translates to: + /// **'封面上传失败,可稍后在相册详情中重新上传'** + String get coverUploadFailed; + /// No description provided for @formDescriptionLanguages. /// /// In zh, this message translates to: diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 0c4b63b..8a1c636 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -254,6 +254,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get formEventDetails => 'Event details'; + @override + String get coverPicker => 'Album cover'; + + @override + String get coverPickerHint => 'Tap to select a cover image (optional)'; + + @override + String get coverSelected => 'Cover selected, will upload on save'; + + @override + String get coverUploadFailed => + 'Cover upload failed, you can retry from album detail later'; + @override String get formDescriptionLanguages => 'Description & languages'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 2ea9f01..67b656a 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -248,6 +248,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get formEventDetails => '活动信息'; + @override + String get coverPicker => '相册封面'; + + @override + String get coverPickerHint => '点击选择封面图片(可选)'; + + @override + String get coverSelected => '已选择封面,保存后上传'; + + @override + String get coverUploadFailed => '封面上传失败,可稍后在相册详情中重新上传'; + @override String get formDescriptionLanguages => '描述与语言'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 49da3e3..f567077 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -80,6 +80,10 @@ "formTitleRequiredWithFields": "填写了描述/场地/地址时,标题不能为空", "formEndAtNotAfterStartAt": "结束时间必须晚于开始时间", "formEventDetails": "活动信息", + "coverPicker": "相册封面", + "coverPickerHint": "点击选择封面图片(可选)", + "coverSelected": "已选择封面,保存后上传", + "coverUploadFailed": "封面上传失败,可稍后在相册详情中重新上传", "formDescriptionLanguages": "描述与语言", "formDefaultLanguage": "默认语言", "formDetailsTab": "详情", diff --git a/apps/mobile/lib/pages/album_form_page.dart b/apps/mobile/lib/pages/album_form_page.dart index 8bf4e87..d0b6235 100644 --- a/apps/mobile/lib/pages/album_form_page.dart +++ b/apps/mobile/lib/pages/album_form_page.dart @@ -16,6 +16,8 @@ /// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情;失败保留输入。 library; +import 'dart:io' show File; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -25,6 +27,7 @@ import '../core/error_l10n.dart'; import '../features/albums/album_format.dart'; import '../features/albums/album_form.dart'; import '../features/albums/models.dart'; +import '../features/uploads/enqueue.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/section_header.dart'; import '../ui/tokens.dart'; @@ -82,6 +85,15 @@ class _AlbumFormPageState extends ConsumerState List _errors = const []; bool _submitting = false; + /// 用户选择的封面本机文件路径(null = 未选)。 + /// 创建模式:相册创建成功后上传;编辑模式:保存后上传。 + String? _coverFilePath; + + /// 编辑模式下的现有封面 URL(来自 AlbumSummary.cover,即时预览)。 + String? get _existingCoverUrl => _coverFilePath == null + ? (widget.existing?.cover?.url) + : null; + bool get _isCreate => widget.existing == null; @override @@ -225,17 +237,37 @@ class _AlbumFormPageState extends ConsumerState final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); try { + late final String albumId; if (_isCreate) { - await ref.read(apiClientProvider).createAlbum(draft.toCreateRequest()); + final album = await ref + .read(apiClientProvider) + .createAlbum(draft.toCreateRequest()); + albumId = album.id; } else { final patch = draft.toUpdatePatch(widget.existing!); - if (patch == null) { + if (patch == null && _coverFilePath == null) { navigator.pop(false); return; } - await ref - .read(apiClientProvider) - .updateAlbum(widget.existing!.id, patch); + if (patch != null) { + await ref + .read(apiClientProvider) + .updateAlbum(widget.existing!.id, patch); + } + albumId = widget.existing!.id; + } + // 封面上传:相册保存成功后执行;失败不阻塞表单结果(封面可后补)。 + if (_coverFilePath != null) { + try { + await ref + .read(apiClientProvider) + .uploadAlbumCoverFromFile(albumId, _coverFilePath!); + } on Object { + // 封面失败仅提示,不影响相册保存结果。 + messenger.showSnackBar( + SnackBar(content: Text(l10n.coverUploadFailed)), + ); + } } navigator.pop(true); } on AppException catch (error) { @@ -359,7 +391,82 @@ class _AlbumFormPageState extends ConsumerState ); } - /// Details(= 创建表单):分组字段 + 校验错误汇总。 + /// 封面选择器:16:9 可点击区域。编辑模式显示现有封面预览。 + Widget _buildCoverPicker(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.coverPicker, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: kSpace8), + InkWell( + onTap: _submitting ? null : _pickCoverImage, + borderRadius: BorderRadius.circular(kRadiusInput), + child: Container( + height: 180, + 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: _coverFilePath != null + ? Image.file( + File(_coverFilePath!), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _coverPlaceholder(l10n), + ) + : _existingCoverUrl != null + ? Image.network( + _existingCoverUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _coverPlaceholder(l10n), + ) + : _coverPlaceholder(l10n), + ), + ), + ), + const SizedBox(height: kSpace4), + Text( + _coverFilePath != null + ? l10n.coverSelected + : l10n.coverPickerHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + 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, + ), + ), + ], + ); + + Future _pickCoverImage() async { + final path = await ref.read(singleImagePickerProvider)(); + if (path != null) { + setState(() => _coverFilePath = path); + } + } + + /// Details(= 创建表单):封面选择 + 分组字段 + 校验错误汇总。 Widget _detailsForm(AppLocalizations l10n) { return Form( child: SingleChildScrollView( @@ -368,6 +475,10 @@ class _AlbumFormPageState extends ConsumerState child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // ── 封面选择(16:9 可点击区域) ───────────────── + _buildCoverPicker(l10n), + const SizedBox(height: kSpace24), + // ── 分组 1:Event details ────────────────────────── SectionHeader(title: l10n.formEventDetails), const SizedBox(height: kSpace12), diff --git a/apps/viewer/src/components/album/AlbumHeader.vue b/apps/viewer/src/components/album/AlbumHeader.vue index e336375..85febce 100644 --- a/apps/viewer/src/components/album/AlbumHeader.vue +++ b/apps/viewer/src/components/album/AlbumHeader.vue @@ -184,9 +184,14 @@ onBeforeUnmount(() => {

{{ album.translation.title }}

- +
`} +function row(ic,label,sub,action='',trailing=''){return `<${action?'button':'div'} class="row" ${action?`data-action="${action}"`:''}>${icon(ic)}${label}${sub?`

${sub}

`:''}
${trailing?`${trailing}`:''}${action?`${icon('next')}`:''}`} +function bar(title,back=5,right='',root=false){return `
${root?`

${title}

`:`${title}`}${right}
`} +function nav(selected){return ``} +function field(label,id,type='text',value='',placeholder=''){const pw=type==='password';return ``} +function seg(items,selected,action){return `
${items.map(([id,label])=>``).join('')}
`} +function status(){return `
${icon('globe')}${publicAccess?T('Public','公开'):T('Private','未公开')}${icon('upload')}${uploadAllowed?T('Uploads on','可上传'):T('Uploads off','上传关闭')}
`} +function grid(count=9,offset=0){return `
${Array.from({length:count},(_,k)=>k+offset).filter(i=>!removedPhotos.has(i)&&(photoFilter!=='hidden'||hiddenPhotos.has(i))&&(photoFilter!=='published'||!hiddenPhotos.has(i))).map(i=>``).join('')}
`} +function login(){return `
${[2,4,0,3,6,1].map(i=>photo(i)).join('')}
`} +function settings(){return bar(T('Settings','设置'),5,'',true)+`
AM

Alex Morgan

alex.morgan · ${T('Staff','工作人员')}

${row('globe',T('Language','语言'),'', 'language',lang==='en'?'English':'中文')}${row('lock',T('Change password','修改密码'),'', 'go:3')}${row('help',T('Upload help','上传帮助'),'', 'help')}${row('help',T('App version','应用版本'),'', '', 'Design preview')}
${row('logout',T('Sign out','退出登录'),'', 'logout')}

${T('Sample staff profile · offline prototype','示例工作人员 · 离线交互原型')}

`+nav(2)} +function password(){return bar(T('Change password','修改密码'),2)+`
${icon('lock')}
${T('Keep your account secure','保障您的账号安全')}

${T('Update the password for your company account.','修改用于企业账号登录的密码。')}

${field(T('Current password','当前密码'),'oldPassword','password','',T('Enter current password','输入当前密码'))}${field(T('New password','新密码'),'newPassword','password','',T('Enter new password','输入新密码'))}${field(T('Confirm password','确认密码'),'confirmPassword','password','',T('Enter new password again','再次输入新密码'))}
${icon('help')}

${T('Preview only. No password is saved or sent. Production rules are enforced by the account service.','仅演示校验,不保存或发送密码。正式密码规则由账号服务执行。')}

`} +function albums(){return bar('PhotoLive',5,``,true)+`

${T('Albums','相册')}

${T('Organize and manage event photos','组织活动相册,管理现场影像')}

${[[0,albumTitle,'Oct 15–19 · Guangzhou',6],[5,T('Product Launch','新品发布会'),'Sep 3 · Shenzhen',6],[2,T('Team Stories','团队故事'),'Aug 12–15 · Shanghai',8]].map(([i,title,date,link])=>``).join('')}
`+nav(5)} +function detail(empty=false){const title=empty?T('Team Stories','团队故事'):albumTitle;return bar('',5,``)+`
${photo(empty?2:6,'album-hero')}

${esc(title)}

${status()}
${empty?`
${icon('photo')}

${T('Ready for your first photo','准备好记录第一张照片')}

${T('Capture a moment or choose photos to begin.','拍摄精彩瞬间,或从手机相册选择照片。')}

`:`
${[[17,'photo',T('Photos','照片')],[11,'users',T('Members','成员')],[12,'tag',T('Categories','分类')],[19,'share',T('Share','分享')]].map(([id,ic,label])=>``).join('')}

${T('Recent photos','最近照片')}

${grid(9)}
`}
${button(T('Take photos','拍摄照片'),uploadAllowed?'go:13':'uploadOff','primary','camera')}${button(T('Choose photos','选择照片'),uploadAllowed?'choose':'uploadOff','secondary','photo')}
`} +function albumForm(edit=false){const details=`
${field(T('Album title','相册名称'),'albumTitle','text',edit?albumTitle:T('Canton Fair · Autumn','广交会 · 秋季'))}
${field(T('Starts','开始日期'),'startDate','date','2026-10-15')}${field(T('Ends','结束日期'),'endDate','date','2026-10-19')}${field(T('Venue','地点'),'venue','text','Guangzhou')}${field(T('Address','详细地址'),'address','text',T('China Import and Export Fair Complex','中国进出口商品交易会展馆'))}

${T('Description & languages','说明与语言')}

`;const access=`
${[['uploads',T('Allow uploads','允许上传'),T('Team members can upload photos to this album.','团队成员可以向本相册上传照片。'),uploadAllowed],['public',T('Public access','公开访问'),T('Anyone with the link can view this album.','获得链接的观众可以浏览相册。'),publicAccess],['downloads',T('Allow downloads','允许下载'),T('Viewers can download photos from this album.','观众可以下载相册照片。'),downloads],['original',T('Original downloads','原图下载'),T('Allow downloading full-resolution files.','允许观众下载完整分辨率的原图。'),original]].map(([id,title,desc,on])=>``).join('')}
${row('globe',T('Content language','内容语言'),T('Default language for album content','相册内容的默认语言'),'contentLanguage','English')}
`;return bar(edit?T('Edit album','编辑相册'):T('New album','新建相册'),6)+`
${edit?seg([['details',T('Details','详情')],['access',T('Access','权限')]],editTab,'editTab'):''}
${details}
${edit?`
${access}
`:''}
`} +function memberPage(){return bar(T('Members','成员'),6,``)+`
AM
Alex Morgan

alex.morgan

${T('Owner','创建者')}
${members.map((name,i)=>`
${name.split(' ').map(n=>n[0]).join('')}
${name}

${name.toLowerCase().replace(' ','.')}

${T('Can manage their own photos','可管理自己上传的照片')}

`).join('')}
${icon('help')}${T('Album owners manage all photos and members.','相册创建者管理全部照片和成员。')}
${button(T('Invite colleague','邀请同事'),'invite','secondary','users')}
`} +function categories(){return bar(T('Categories','分类'),6,``)+`
${categoryNames.map((name,i)=>`
${icon('tag')}
${esc(name)}
`).join('')}

${T('Categories help your team organize event photos.','通过分类整理团队的活动照片。')}

`} +function camera(){return bar(`${esc(albumTitle)}
`,6,``)+`
${photo(1)}
${T('Camera preview · sample photo','相机效果演示 · 示例照片')}

${T('No device camera or OSS connection','未调用设备相机,未连接 OSS')}

`} +function uploadItem(i,type='upload'){let name='IMG_'+String(85+i).padStart(3,'0')+'.jpg';const attention=type==='attention',history=type==='history';let state=attention?(i===0?T('Connection interrupted','连接中断'):T('Source file unavailable','源文件不可用')):history?[T('Uploaded','已上传'),T('Processing','处理中'),T('Ready','已就绪')][i%3]:[T('Uploading 68%','上传中 68%'),T('Waiting for network','等待网络'),T('Confirming upload','确认上传中'),T('Uploaded','已上传')][i%4];return `
${photo(i+2,'upload-thumb')}
${name}

${history?'Today 14:'+String(32-i*3):esc(albumTitle)}

${!history?`

${T('Exhibition','展会现场')}

`:''}
${!attention&&i===0&&!history?'
':icon(attention?'help':i%3===0?'check':'clock')}${state}
${attention?`

${i===0?T('The network was lost during upload. You can retry this file.','上传时网络断开,可以重试此任务。'):T('The original file cannot be found on this device. Please select it again.','本机找不到源文件,请重新选择。')}

${button(i===0?T('Retry','重试'):T('Select again','重新选择'),i===0?'retry':'reselect','secondary')}`:history?`

${i%2?T('Other device','其他设备'):T('This device','本机')}

`:''}
`} +function uploads(attention=false){const active=attention?'attention':uploadFilter;return bar(T('Uploads','上传'),5,``,true)+`

${T('Today · This device','今天 · 本机')}

82 ${T('uploaded','已上传')}

${3+extraTasks} ${T('uploading','上传中')} · 13 ${T('waiting','等待中')} · ${retried?1:2} ${T('need attention','需处理')}

${seg([['all',T('All','全部')],['active',T('Active','进行中')],['attention',T('Needs attention','需处理')]],active,'uploadFilter')}${active==='attention'?`${retried?'':uploadItem(0,'attention')}${uploadItem(1,'attention')}${button(T('Retry eligible tasks','重试可恢复任务'),'retry','primary full')}`:Array.from({length:active==='active'?3:4},(_,i)=>uploadItem(i)).join('')}${extraTasks?`
${icon('camera')}${T('Simulated photos queued: ','模拟拍摄入队:')}${extraTasks}
`:''}
`+nav(14)} +function historyPage(){return bar(T('Upload history','上传历史'),14)+`

${T('Server records · All devices','服务端记录 · 所有设备')}

${Array.from({length:5},(_,i)=>uploadItem(i,'history')).join('')}

${T('Sample server records · not combined with the local queue','示例服务端记录 · 不与本机队列合并计数')}

`} +function managePhotos(){return bar(T('Photos','照片'),6)+`

${esc(albumTitle)}

${seg([['all',T('All','全部')],['published',T('Published','已公开')],['hidden',T('Hidden','已隐藏')]],photoFilter,'photoFilter')}${grid(18)}
`} +function photoDetails(){const hidden=hiddenPhotos.has(currentPhoto);return bar('',17,``)+`
${photo(currentPhoto,'large-photo')}
Emma Chen${hidden?T('Hidden','已隐藏'):T('Published','已公开')}

Oct 15, 2026 · 10:42

2400 × 1600 · 2.8 MB

${button(hidden?T('Publish again','重新公开'):T('Hide photo','隐藏照片'),hidden?'publish':'go:20','secondary','eye')}${button(T('Delete','删除'),'delete','danger','trash')}
`} +function sharePage(){return bar(T('Share album','分享相册'),6)+`
`} +function languageSheet(){return `

${T('App language','应用语言')}

${[['auto','Follow device','跟随系统'],['en','English','English'],['zh','中文','中文']].map(([value,en,zh])=>``).join('')}${button(T('Done','完成'),'saveLanguage')}`} +function categorySheet(index='new'){const edit=index!=='new';return `

${edit?T('Edit category','编辑分类'):T('New category','新建分类')}

${seg([['en','English'],['zh','中文']],'en','categoryLang')}
${field(T('Category name','分类名称'),'categoryName','text',edit?categoryNames[Number(index)]:T('Behind the scenes','幕后花絮'))}
`} +function hideSheet(){return `

${T('Hide this photo?','隐藏这张照片?')}

${photo(currentPhoto)}

${T('Viewers will no longer see this photo. Authorized staff can publish it again.','观众将无法继续看到这张照片,有权限的工作人员可以重新公开。')}

${button(T('Hide photo','隐藏照片'),'confirmHide')}${button(T('Cancel','取消'),'closeSheet','ghost cancel')}`} +function overlay(content,close='closeSheet'){const layer=document.createElement('div');layer.className='modal-layer';layer.innerHTML=``;layer.onclick=e=>{if(e.target===layer)action(close)};$('#app').append(layer);[...$('#app').children].filter(el=>el!==layer).forEach(el=>el.inert=true);layer.querySelector('input,button')?.focus({preventScroll:true});bindForms();return layer} +function closeSheet(){const layer=$('.modal-layer');if(layer)layer.remove();[...$('#app').children].forEach(el=>el.inert=false);if([4,7,20].includes(page))go(page===4?returnPage:page===7?6:18);else $('.bar button')?.focus({preventScroll:true})} +function render(){page=Number(location.hash.slice(1))||Number(document.body.dataset.page)||1;page=Math.max(1,Math.min(page,20));$('#app').className='app'+([13,18,20].includes(page)?' dark':'');document.documentElement.lang=lang==='zh'?'zh-CN':'en';let html='';switch(page){case 1:html=login();break;case 2:case 4:html=settings();break;case 3:html=password();break;case 5:html=albums();break;case 6:case 7:html=detail();break;case 8:html=detail(true);break;case 9:html=albumForm();break;case 10:html=albumForm(true);break;case 11:html=memberPage();break;case 12:html=categories();break;case 13:html=camera();break;case 14:html=uploads();break;case 15:html=uploads(true);break;case 16:html=historyPage();break;case 17:html=managePhotos();break;case 18:case 20:html=photoDetails();break;case 19:html=sharePage();break}$('#app').innerHTML=html;bindForms();if(page===4){draftLanguage=lang;overlay(languageSheet())}if(page===7)overlay(`

${T('Album actions','相册操作')}

${row('edit',T('Edit album','编辑相册'),'','go:10')}${row('users',T('Manage members','管理成员'),'','go:11')}${row('tag',T('Categories','分类'),'','go:12')}${row('share',T('Share album','分享相册'),'','go:19')}${button(T('Cancel','取消'),'closeSheet','ghost')}`);if(page===12)overlay(categorySheet());if(page===20)overlay(hideSheet());fitPhoto();document.title=`PhotoLive · ${String(page).padStart(2,'0')} · App preview`;if(window.parent!==window)window.parent.postMessage({type:'photolive-preview-page',page},'*')} +function go(id){if(Number(id)===page){render();return}location.hash=String(id).padStart(2,'0')} +function toast(text){$('.toast')?.remove();const el=document.createElement('div');el.className='toast';el.setAttribute('role','status');el.textContent=text;$('#app').append(el);clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.remove(),2800)} +function fitPhoto(){const stage=$('#photoStage');if(!stage)return;const r=pics[currentPhoto%pics.length],w=Math.min(stage.clientWidth,stage.clientHeight*r[2]/r[3]);$('.large-photo').style.width=w+'px';$('.large-photo').style.height=w*r[3]/r[2]+'px'} +function invite(){overlay(`

${T('Invite colleague','邀请同事')}

${field(T('Search company accounts','搜索企业账号'),'searchMember','search','','Search')}
${['Olivia Wang','James Wilson','Sophia Li'].map(name=>``).join('')}

${T('Demo internal directory · no invitation is sent','示例企业通讯录 · 不发送真实邀请')}

`);$('#searchMember').oninput=e=>$$('.candidate').forEach(el=>el.hidden=!el.textContent.toLowerCase().includes(e.target.value.toLowerCase()))} +function action(raw){const [name,...rest]=raw.split(':'),value=rest.join(':');switch(name){case 'go':go(value);break;case 'language':returnPage=page;draftLanguage=lang;go(4);break;case 'saveLanguage':{const selected=$('input[name=language]:checked').value;lang=selected==='auto'?(navigator.language.startsWith('zh')?'zh':'en'):selected;go(returnPage);break}case 'closeSheet':closeSheet();break;case 'logout':overlay(`

${T('Sign out of the preview?','退出预览账号?')}

${T('Production uploads remain tied to your account. This prototype contains sample tasks only.','正式版任务按账号保留,此原型中仅有示例任务。')}

${button(T('Sign out','退出'),'go:1','danger')}${button(T('Cancel','取消'),'closeSheet','ghost')}`);break;case 'help':overlay(`

${T('Upload help','上传帮助')}

${T('Keep the app connected to the network. Interrupted uploads can be retried from Uploads.','请保持网络连接。中断的上传可从上传中心重试。')}

${T('Background and lock-screen uploads depend on the operating system, permissions and power settings. Reopen the app after a force quit to resume pending work.','后台和锁屏上传受到操作系统、权限和省电设置影响。强制退出后请重新打开应用恢复任务。')}

${T('Uploaded does not mean publicly available; server processing and album access still apply.','已上传不代表已公开,仍需完成服务端处理并遵守相册权限。')}

${button(T('Got it','知道了'),'closeSheet')}`);break;case 'uploadOff':toast(T('Uploads are disabled for this album.','该相册已关闭上传。'));break;case 'choose':case 'reselect':overlay(`

${T('Choose sample photos','选择示例照片')}

${T('This demonstrates the photo selection flow without accessing your device library.','这里演示选图流程,不访问您的设备相册。')}

${[0,1,2].map(i=>``).join('')}
${button(T('Cancel','取消'),'closeSheet','ghost')}`);break;case 'selectSample':extraTasks++;closeSheet();toast(T('Sample photo added to the demo upload queue.','示例照片已加入演示上传队列。'));break;case 'editTab':{editTab=value;$('#detailsFields').hidden=value!=='details';$('#accessFields').hidden=value!=='access';$$('[data-action^="editTab:"]').forEach(el=>el.classList.toggle('active',el.dataset.action.endsWith(value)));break}case 'contentLanguage':overlay(`

${T('Content language','内容语言')}

${button('English','setContent:English','ghost')}${button('中文','setContent:中文','ghost')}`);break;case 'setContent':$('#contentLanguage').value=value;closeSheet();toast(T('Content language draft updated.','内容语言草稿已更新。'));break;case 'invite':invite();break;case 'inviteName':if(!members.includes(value))members.push(value);closeSheet();render();toast(T('Added to the sample team.','已加入示例协作团队。'));break;case 'removeMember':overlay(`

${T('Remove collaborator?','移除协作者?')}

${esc(members[Number(value)])}

${button(T('Remove from sample team','从示例团队移除'),'confirmRemove:'+value,'danger')}${button(T('Cancel','取消'),'closeSheet','ghost')}`);break;case 'confirmRemove':members.splice(Number(value),1);render();break;case 'category':overlay(categorySheet(value));break;case 'categoryLang':{const field=$('#categoryName');field.dataset[$('.tabs button.active')?.dataset.action.endsWith('zh')?'zh':'en']=field.value;field.value=field.dataset[value]||'';$$('[data-action^="categoryLang:"]').forEach(el=>el.classList.toggle('active',el.dataset.action.endsWith(value)));break}case 'cameraCategory':overlay(`

${T('Select category','选择分类')}

${categoryNames.map(n=>button(esc(n),'chooseCategory:'+n,'ghost')).join('')}`);break;case 'chooseCategory':closeSheet();toast(T('Selected: ','已选择:')+value);break;case 'shutter':extraTasks++;$('#cameraNote').textContent=T('Added to demo upload queue · ','已加入演示上传队列 · ')+extraTasks;break;case 'flip':$('.camera-view>.art').style.transform=$('.camera-view>.art').style.transform?'':'scaleX(-1)';toast(T('Simulated camera direction switch','已模拟切换相机方向'));break;case 'uploadFilter':uploadFilter=value;if(value==='attention')go(15);else if(page===15)go(14);else render();break;case 'retry':retried=true;render();toast(T('Eligible sample task queued again.','可恢复的示例任务已重新排队。'));break;case 'photoFilter':photoFilter=value;render();break;case 'view':currentPhoto=Number(value);go(18);break;case 'photoPrev':case 'photoNext':{let n=currentPhoto;do{n=(n+(name==='photoNext'?1:17))%18}while(removedPhotos.has(n)&&removedPhotos.size<18);currentPhoto=n;render();break}case 'photoMore':overlay(`

${T('Photo actions','照片操作')}

${button(T('View metadata','查看照片信息'),'metadata','ghost')}${button(T('Delete photo','删除照片'),'delete','danger')}${button(T('Cancel','取消'),'closeSheet','ghost')}`);break;case 'metadata':closeSheet();toast(T('Sample · Emma Chen · 2400 × 1600 · 2.8 MB','示例 · Emma Chen · 2400 × 1600 · 2.8 MB'));break;case 'confirmHide':hiddenPhotos.add(currentPhoto);go(18);break;case 'publish':hiddenPhotos.delete(currentPhoto);render();toast(T('Sample photo is visible again.','示例照片已重新公开。'));break;case 'delete':$('.modal-layer')?.remove();overlay(`

${T('Delete this photo?','删除这张照片?')}

${T('This removes it from this preview only. Production deletion follows server retention rules; it does not promise immediate physical deletion from OSS.','此处仅从预览移除。正式删除遵循服务端保留规则,不代表立即从 OSS 物理删除。')}

${button(T('Delete from preview','从预览删除'),'confirmDelete','danger')}${button(T('Cancel','取消'),'closeSheet','ghost')}`);break;case 'confirmDelete':removedPhotos.add(currentPhoto);go(17);break;case 'copy':{const url=new URL('../viewer-preview-2026-09-22.html',location.href).href;if(navigator.clipboard)navigator.clipboard.writeText(url).then(()=>toast(T('Local preview link copied','本地预览链接已复制'))).catch(()=>prompt(T('Copy preview link','复制预览链接'),url));else prompt(T('Copy preview link','复制预览链接'),url);break}case 'openViewer':window.open('../viewer-preview-2026-09-22.html','_blank','noopener,noreferrer');break}} +function bindForms(){const login=$('#loginForm');if(login)login.onsubmit=e=>{e.preventDefault();if(!$('#account').value.trim()){$('#formError').textContent=T('Enter a demo account.','请输入演示账号。');return}go(5)};const password=$('#passwordForm');if(password)password.onsubmit=e=>{e.preventDefault();const a=$('#newPassword').value,b=$('#confirmPassword').value;if(!$('#oldPassword').value||!a||a!==b){$('#formError').textContent=T('Complete all fields and make sure the new passwords match.','请填写全部字段,并确保两次新密码一致。');return}go(2);setTimeout(()=>toast(T('Validation passed · no password was saved','演示校验通过 · 未保存密码')),60)};const form=$('#albumForm');if(form)form.onsubmit=e=>{e.preventDefault();if(!$('#albumTitle').value.trim()||!$('#startDate').value||!$('#endDate').value||$('#endDate').value<$('#startDate').value){$('#formError').textContent=T('Enter a title and valid start/end dates.','请填写相册名称及有效起止日期。');return}albumTitle=$('#albumTitle').value.trim();if(page===10){publicAccess=$('#access-public').checked;uploadAllowed=$('#access-uploads').checked;downloads=$('#access-downloads').checked;original=downloads&&$('#access-original').checked}go(6)};const cat=$('#categoryForm');if(cat)cat.onsubmit=e=>{e.preventDefault();const val=$('#categoryName').value.trim();if(!val){$('#categoryError').textContent=T('A name is required.','请填写分类名称。');return}if(cat.dataset.index==='new')categoryNames.push(val);else categoryNames[Number(cat.dataset.index)]=val;closeSheet();$('#app').innerHTML=categories();toast(T('Sample category saved.','示例分类已保存。'))};const download=$('#access-downloads');if(download)download.onchange=()=>{const original=$('#access-original');original.disabled=!download.checked;if(!download.checked)original.checked=false};['historyAlbum','historyDate'].forEach(id=>{const el=$('#'+id);if(el)el.onchange=()=>{$('#historyRows').innerHTML=Array.from({length:el.selectedIndex?2:5},(_,i)=>uploadItem(i+el.selectedIndex,'history')).join('');toast(T('Sample history filter updated','示例历史筛选已更新'))}})} +document.addEventListener('click',e=>{const eye=e.target.closest('[data-eye]');if(eye){const input=$('#'+eye.dataset.eye);input.type=input.type==='password'?'text':'password';return}const target=e.target.closest('[data-go],[data-action]');if(target){if(target.dataset.go)go(target.dataset.go);else action(target.dataset.action)}});document.addEventListener('dblclick',e=>{if(e.target.closest('.large-photo'))$('.large-photo').classList.toggle('zoom')});document.addEventListener('keydown',e=>{const layer=$('.modal-layer');if(layer){if(e.key==='Escape')closeSheet();if(e.key==='Tab'){const els=[...layer.querySelectorAll('button,input,select,textarea')].filter(x=>!x.disabled);if(els.length){if(e.shiftKey&&document.activeElement===els[0]){e.preventDefault();els.at(-1).focus()}else if(!e.shiftKey&&document.activeElement===els.at(-1)){e.preventDefault();els[0].focus()}}}}else if(page===18&&!e.target.matches('input,textarea')){if(e.key==='ArrowLeft')action('photoPrev');if(e.key==='ArrowRight')action('photoNext');if(e.key==='Escape')go(17)}});let touchStart=null;document.addEventListener('pointerdown',e=>{if(e.target.closest('.photo-stage')&&!e.target.closest('button'))touchStart=[e.clientX,e.clientY]});document.addEventListener('pointerup',e=>{if(touchStart&&page===18){const dx=e.clientX-touchStart[0],dy=e.clientY-touchStart[1];if(Math.abs(dx)>60&&Math.abs(dx)>Math.abs(dy))action(dx<0?'photoNext':'photoPrev')}touchStart=null});window.addEventListener('resize',fitPhoto);window.addEventListener('hashchange',render);render(); diff --git a/docs/designs/app-preview-2026-09-22/assets/demo-qr.svg b/docs/designs/app-preview-2026-09-22/assets/demo-qr.svg new file mode 100644 index 0000000..d889fe1 --- /dev/null +++ b/docs/designs/app-preview-2026-09-22/assets/demo-qr.svg @@ -0,0 +1 @@ + diff --git a/docs/designs/app-preview-2026-09-22/assets/reference.png b/docs/designs/app-preview-2026-09-22/assets/reference.png new file mode 100644 index 0000000..cbf3362 Binary files /dev/null and b/docs/designs/app-preview-2026-09-22/assets/reference.png differ diff --git a/docs/designs/app-preview-2026-09-22/build-preview.cjs b/docs/designs/app-preview-2026-09-22/build-preview.cjs new file mode 100644 index 0000000..03ed4ca --- /dev/null +++ b/docs/designs/app-preview-2026-09-22/build-preview.cjs @@ -0,0 +1,10 @@ +const fs=require('fs'),path=require('path'); +const root=__dirname; +const pages=[['login','登录','Login'],['settings','设置','Settings'],['password','修改密码','Change password'],['language','语言选择','App language'],['albums','相册列表','Albums'],['album-detail','相册详情','Album overview'],['album-actions','相册操作','Album actions'],['empty-album','空相册','Empty album'],['create-album','创建相册','New album'],['edit-album','编辑相册','Edit album'],['members','协作成员','Members'],['categories','分类管理','Categories'],['camera','拍摄','Camera'],['uploads','上传中心','Uploads'],['upload-errors','异常任务','Needs attention'],['upload-history','上传历史','Upload history'],['photos','照片管理','Manage photos'],['photo-detail','管理大图','Photo details'],['share','相册分享','Share album'],['hide-photo','隐藏确认','Hide photo']].map(([slug,zh,en],i)=>({id:i+1,file:`${String(i+1).padStart(2,'0')}-${slug}.html`,zh,en})); +fs.mkdirSync(path.join(root,'assets'),{recursive:true}); +fs.copyFileSync(path.join(root,'../viewer-mobile-concept-2026-09-21.png'),path.join(root,'assets/reference.png')); +for(const page of pages)fs.writeFileSync(path.join(root,page.file),`PhotoLive · ${page.en}
`); +fs.writeFileSync(path.join(root,'pages.js'),'window.PREVIEW_PAGES='+JSON.stringify(pages,null,2)+';'); +const QR=require(require.resolve('qrcode',{paths:[path.resolve(root,'../../../apps/admin')]})); +QR.toString('https://example.com/photolive-preview',{type:'svg',margin:2,color:{dark:'#172033',light:'#ffffff'}},(err,svg)=>{if(err)throw err;fs.writeFileSync(path.join(root,'assets/demo-qr.svg'),svg)}); +console.log('Generated 20 page entry points and offline assets.'); diff --git a/docs/designs/app-preview-2026-09-22/index.html b/docs/designs/app-preview-2026-09-22/index.html new file mode 100644 index 0000000..155689b --- /dev/null +++ b/docs/designs/app-preview-2026-09-22/index.html @@ -0,0 +1,10 @@ +PhotoLive · App 全页面交互预览
◈ PhotoLive APP DESIGN PREVIEW

20 个页面与关键状态 · 可交互 HTML 原型 · 离线可用

独立打开 ↗
390 × 844
UI–03 / INTERACTIVE PROTOTYPE

真实 HTML 组件中英文可点击跳转本地摄影素材

在左侧选择页面,或直接在原型内操作。登录页与设置页可切换语言。相机、上传、成员和权限修改均为当前预览会话的模拟状态。

分享页二维码为演示码,指向 example.com;“打开观众页预览”可连接此前生成的观众页文件。

diff --git a/docs/designs/app-preview-2026-09-22/pages.js b/docs/designs/app-preview-2026-09-22/pages.js new file mode 100644 index 0000000..55d9390 --- /dev/null +++ b/docs/designs/app-preview-2026-09-22/pages.js @@ -0,0 +1,122 @@ +window.PREVIEW_PAGES=[ + { + "id": 1, + "file": "01-login.html", + "zh": "登录", + "en": "Login" + }, + { + "id": 2, + "file": "02-settings.html", + "zh": "设置", + "en": "Settings" + }, + { + "id": 3, + "file": "03-password.html", + "zh": "修改密码", + "en": "Change password" + }, + { + "id": 4, + "file": "04-language.html", + "zh": "语言选择", + "en": "App language" + }, + { + "id": 5, + "file": "05-albums.html", + "zh": "相册列表", + "en": "Albums" + }, + { + "id": 6, + "file": "06-album-detail.html", + "zh": "相册详情", + "en": "Album overview" + }, + { + "id": 7, + "file": "07-album-actions.html", + "zh": "相册操作", + "en": "Album actions" + }, + { + "id": 8, + "file": "08-empty-album.html", + "zh": "空相册", + "en": "Empty album" + }, + { + "id": 9, + "file": "09-create-album.html", + "zh": "创建相册", + "en": "New album" + }, + { + "id": 10, + "file": "10-edit-album.html", + "zh": "编辑相册", + "en": "Edit album" + }, + { + "id": 11, + "file": "11-members.html", + "zh": "协作成员", + "en": "Members" + }, + { + "id": 12, + "file": "12-categories.html", + "zh": "分类管理", + "en": "Categories" + }, + { + "id": 13, + "file": "13-camera.html", + "zh": "拍摄", + "en": "Camera" + }, + { + "id": 14, + "file": "14-uploads.html", + "zh": "上传中心", + "en": "Uploads" + }, + { + "id": 15, + "file": "15-upload-errors.html", + "zh": "异常任务", + "en": "Needs attention" + }, + { + "id": 16, + "file": "16-upload-history.html", + "zh": "上传历史", + "en": "Upload history" + }, + { + "id": 17, + "file": "17-photos.html", + "zh": "照片管理", + "en": "Manage photos" + }, + { + "id": 18, + "file": "18-photo-detail.html", + "zh": "管理大图", + "en": "Photo details" + }, + { + "id": 19, + "file": "19-share.html", + "zh": "相册分享", + "en": "Share album" + }, + { + "id": 20, + "file": "20-hide-photo.html", + "zh": "隐藏确认", + "en": "Hide photo" + } +]; \ No newline at end of file diff --git a/docs/designs/app-preview-2026-09-22/styles.css b/docs/designs/app-preview-2026-09-22/styles.css new file mode 100644 index 0000000..3dd2d24 --- /dev/null +++ b/docs/designs/app-preview-2026-09-22/styles.css @@ -0,0 +1 @@ +:root{--blue:#1a73e8;--bg:#f6f8fb;--surface:#fff;--ink:#172033;--muted:#69778e;--line:#e5eaf1;--soft:#edf4ff;--red:#e74e58;--green:#279966}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif}button,input,select,textarea{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{border:0;background:none;color:inherit;cursor:pointer;min-height:44px}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:3px solid #79b4ff;outline-offset:2px}a{color:var(--blue)}svg.icon{width:22px;height:22px;flex:none;vertical-align:middle;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}h1,h2,h3,p{margin:0}h1{font-size:24px;font-weight:700;line-height:1.25;letter-spacing:-.5px}h2{font-size:20px;line-height:1.35;letter-spacing:-.3px}h3{font-size:16px;font-weight:650}small,.small{font-size:12px}.muted{color:var(--muted)}.blue{color:var(--blue)}.red{color:var(--red)}.green{color:var(--green)}.amber{color:#d89020}.app{height:100dvh;max-width:600px;margin:auto;display:flex;flex-direction:column;position:relative;overflow:hidden;background:var(--bg)}.app.dark{background:#10161e;color:#fff}.safe-top{height:24px;flex:none;background:var(--surface)}.dark .safe-top{background:#10161e}.bar{height:56px;flex:none;display:flex;align-items:center;gap:8px;padding:0 12px;background:var(--surface)}.bar .back,.bar .right{width:44px;flex:none;display:grid;place-items:center}.bar .right{margin-left:auto}.bar .bar-title{font-size:18px;font-weight:650;flex:1;line-height:1.25}.bar.root{height:62px;padding:0 20px;background:transparent}.bar.root h1{flex:1}.bar.dark{background:#10161e}.bar .plus{width:34px;height:34px;min-height:34px;border-radius:50%;background:var(--blue);color:white;display:grid;place-items:center}.scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:16px}.scroll.flush{padding:0}.scroll.white{background:var(--surface)}.scroll::-webkit-scrollbar{width:4px}.scroll::-webkit-scrollbar-thumb{background:#b3bfd04d;border-radius:8px}.nav{display:flex;flex:none;height:92px;padding:7px 16px 24px;background:var(--surface);border-top:1px solid var(--line);justify-content:space-around}.nav button{width:33%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:var(--muted);font-size:11px;position:relative}.nav button.active{color:var(--blue)}.nav svg{width:24px;height:24px}.nav .badge{position:absolute;background:#f15b64;color:white;top:0;left:calc(50% + 6px);font-size:10px;padding:0 5px;min-width:16px;border-radius:10px;border:2px solid white}.actions{display:flex;gap:10px;flex:none;padding:12px 16px calc(20px + env(safe-area-inset-bottom));background:var(--surface);border-top:1px solid var(--line)}.actions>*{flex:1}.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 16px;border-radius:12px;font-weight:600;font-size:14px;text-decoration:none}.btn.primary{background:var(--blue);color:white}.btn.secondary{border:1px solid #89b5f0;color:var(--blue);background:var(--surface)}.btn.ghost{background:#eef2f7;color:#42536d}.btn.danger{color:var(--red);background:#fff1f2}.btn.full{width:100%}.stack{display:flex;flex-direction:column;gap:16px}.card{background:var(--surface);border:1px solid #edf0f5;border-radius:16px;padding:16px}.group{background:var(--surface);border-radius:16px;padding:0 16px;border:1px solid #edf0f5}.row{display:flex;align-items:center;gap:12px;min-height:64px;border-bottom:1px solid var(--line);width:100%;text-align:left;padding:12px 0}.row:last-child{border-bottom:0}.row .grow{flex:1;min-width:0}.row .arrow{color:#8694a9}.row p{font-size:12px;color:var(--muted);margin-top:3px}.row .trailing{color:var(--muted);font-size:13px;margin-left:auto}.avatar{width:48px;height:48px;border-radius:50%;display:grid;place-items:center;background:#e3edff;color:#2862ab;font-size:17px;flex:none;font-weight:600}.avatar.lg{width:66px;height:66px;font-size:22px}.avatar.lilac{background:#eeebff;color:#7060bd}.avatar.mint{background:#e6f3ee;color:#398d70}.art{position:relative;overflow:hidden;background:#dce6ef}.art img{position:absolute;max-width:none;pointer-events:none}.tag{border-radius:7px;display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:5px 10px;background:#edf4ff;color:#49658e}.tag svg{width:15px;height:15px}.tag.success{background:#eaf6ef;color:#288358}.status-row{display:flex;gap:10px;margin-top:12px}.hint-card{padding:15px;border-radius:12px;background:#edf3fc;display:flex;gap:12px;color:#526b8d;font-size:13px}.hint-card svg{flex:none}.field{display:flex;flex-direction:column;gap:7px;margin-bottom:18px;font-size:13px}.field:last-child{margin-bottom:0}.field input,.field select,.field textarea{border:1px solid #dbe3ef;border-radius:10px;min-height:48px;padding:12px;color:var(--ink);background:var(--surface);width:100%;min-width:0}.field textarea{min-height:96px;resize:vertical}.input-wrap{display:flex;align-items:center;position:relative}.input-wrap>svg{position:absolute;left:12px;color:#78889e;width:19px}.input-wrap input{padding-left:40px;padding-right:44px}.input-wrap button{position:absolute;right:2px;top:2px;width:42px;min-height:42px;color:#78889e}.input-wrap button svg{width:19px}.error{color:var(--red);font-size:12px;min-height:18px;margin-top:8px}.note{color:var(--muted);font-size:12px;line-height:1.55}.section-label{font-size:14px;font-weight:600;margin:8px 0 12px}.tabs{display:flex;gap:4px;padding:4px;border-radius:9px;background:#edf1f6;margin-bottom:16px}.tabs button{flex:1;min-height:36px;font-size:12px;border-radius:7px;padding:5px 8px}.tabs button.active{background:var(--blue);color:white}.tabs.light button.active{background:#dbeaff;color:#145bb9}.album-card{display:block;width:100%;padding:0;border-radius:16px;position:relative;overflow:hidden;text-align:left;height:200px;background:#263c54}.album-card+.album-card{margin-top:16px}.album-card .art{position:absolute;inset:0}.album-card .scrim{position:absolute;inset:30% 0 0;background:linear-gradient(transparent,#0c1728e8)}.album-card .caption{position:absolute;bottom:14px;left:14px;right:14px;color:white}.album-card h3{font-size:17px;margin-bottom:3px}.album-card p{font-size:12px;opacity:.85}.album-card .caption-tags{display:flex;gap:12px;font-size:11px;margin-top:9px}.album-card .caption-tags span{display:flex;align-items:center;gap:6px}.caption-tags svg{width:16px;height:16px}.album-hero{height:164px;width:100%;flex:none}.album-info{padding:15px 16px 12px;background:var(--surface)}.album-info h2{margin-bottom:9px}.metadata{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:7px;margin-top:5px}.metadata svg{width:16px;height:16px}.quick{display:flex;padding:6px 16px 16px;gap:8px;background:var(--surface)}.quick button{flex:1;min-width:0;border-radius:12px;background:#f3f7fd;padding:11px 3px;display:flex;flex-direction:column;align-items:center;gap:7px;font-size:10px;color:#42618b}.recent{padding:0 16px 16px;background:var(--surface)}.heading-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.heading-row button{color:var(--blue);font-size:12px;min-height:32px}.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:4px}.photo-grid button{padding:0;aspect-ratio:1;position:relative;overflow:hidden;border-radius:3px}.photo-grid .art{width:100%;height:100%}.photo-grid .flag{position:absolute;left:4px;bottom:4px;max-width:calc(100% - 8px);font-size:9px;padding:3px 5px;border-radius:5px;background:#172033c9;color:white;display:flex;gap:3px;align-items:center}.photo-grid .flag svg{width:11px;height:11px}.photo-grid .is-hidden{filter:brightness(.55)}.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:38px 22px;min-height:280px;background:var(--surface)}.empty-illustration{width:105px;height:90px;display:grid;place-items:center;border-radius:12px;background:#edf3fb;border:6px solid #f7faff;transform:rotate(-7deg);margin-bottom:25px;box-shadow:-13px -9px 0 -2px #e8eff8}.empty-illustration svg{width:56px;height:56px;color:#a5b9d4}.empty p{font-size:13px;margin-top:8px;color:var(--muted)}.mosaic{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;height:238px;flex:none}.mosaic .art{height:117px}.login-content{text-align:center;padding:22px 20px 24px}.wordmark{font-size:25px;font-weight:750;display:flex;align-items:center;justify-content:center;gap:8px;letter-spacing:-1px}.wordmark svg{color:var(--blue);width:30px;height:30px}.login-content h1{margin:24px 0 5px;font-size:27px}.login-form{text-align:left;margin-top:25px}.login-form .field{margin-bottom:12px}.login-note{margin-top:24px;text-align:center;font-size:11px;color:#8390a4}.login-top{height:44px;display:flex;justify-content:flex-end;padding:0 20px;background:white}.login-top button{font-size:12px;display:flex;align-items:center;gap:4px}.login-top svg{width:14px}.switch{appearance:none;width:48px;height:28px;border-radius:16px;background:#d9e0e9;position:relative;cursor:pointer;flex:none}.switch:before{content:'';position:absolute;left:3px;top:3px;width:22px;height:22px;background:white;border-radius:50%;box-shadow:0 1px 3px #1925361a;transition:transform .15s}.switch:checked{background:var(--blue)}.switch:checked:before{transform:translateX(20px)}.switch:disabled{opacity:.4;cursor:default}.access-row{min-height:103px}.access-row strong{display:block;font-size:14px}.access-row p{max-width:245px;font-size:12px}.summary{padding:12px 4px 24px}.summary .big{font-size:32px;line-height:1.4;font-weight:750;letter-spacing:-1px;margin:3px 0}.summary .counts{font-size:12px;color:var(--muted)}.upload-row{display:flex;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:10px}.upload-thumb{width:76px;height:88px;border-radius:6px;flex:none}.upload-data{flex:1;min-width:0}.upload-data strong{font-size:13px}.upload-data p{font-size:11px;color:var(--muted);margin:3px 0}.upload-status{display:flex;gap:5px;align-items:center;font-size:11px;margin-top:8px}.upload-status svg{width:16px;height:16px}.progress{height:5px;border-radius:3px;background:#e0ebfc;overflow:hidden;flex:1}.progress span{display:block;width:68%;height:100%;background:var(--blue);border-radius:3px}.upload-row .btn{min-height:34px;padding:5px 10px;font-size:12px;border-radius:8px;width:100%;margin-top:10px}.history-row{border:0;border-bottom:1px solid var(--line);border-radius:0;padding:12px 0;margin:0;background:transparent}.camera-view{flex:1;min-height:0;position:relative;overflow:hidden}.camera-view>.art{height:100%;width:100%}.camera-chrome{height:116px;flex:none;display:flex;align-items:center;justify-content:space-around;padding-bottom:14px}.shutter{width:74px;height:74px;border:3px solid white;border-radius:50%;padding:4px;background:transparent}.shutter:before{content:'';display:block;width:100%;height:100%;background:white;border-radius:50%}.camera-thumb{width:48px;height:56px;overflow:hidden;border-radius:7px;padding:0;border:1px solid #fff9}.camera-thumb .art{height:100%;width:100%}.camera-note{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);background:#10161ecc;padding:8px 14px;border-radius:9px;font-size:11px;white-space:nowrap;border:1px solid #ffffff33}.photo-stage{flex:1;min-height:80px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;touch-action:pan-y}.photo-stage .art{max-width:100%;max-height:100%;cursor:zoom-in}.photo-stage .art.zoom{transform:scale(1.5)}.photo-stage .arrow{position:absolute;top:calc(50% - 22px);width:44px;height:44px;border-radius:50%;background:#0005;display:grid;place-items:center;z-index:2}.photo-stage .arrow.prev{left:6px}.photo-stage .arrow.next{right:6px}.photo-meta{flex:none;padding:18px 16px 12px;color:#c6cdd6;font-size:12px}.photo-meta .topline{display:flex;align-items:center;justify-content:space-between;margin-bottom:5px;color:white;font-size:15px}.dark .actions{background:#10161e;border-color:#293440}.dark .btn.secondary{background:transparent;border-color:#526077;color:white}.dark .btn.danger{background:transparent;color:#ff7784}.share-card{text-align:center;padding:12px}.share-card .cover{height:190px;border-radius:12px}.share-card .share-info{text-align:left;padding:16px 4px}.qr-area{padding:12px;background:#f5f7fa;border-radius:12px;margin-bottom:16px}.qr-area img{width:156px;height:156px;display:block;margin:auto}.qr-area p{font-size:12px;color:#74829a;margin-top:8px}.share-note{text-align:center;font-size:11px;color:var(--muted);margin-top:14px}.modal-layer{position:absolute;inset:0;background:#111b2b78;z-index:10;display:flex;align-items:flex-end}.sheet{background:var(--surface);color:var(--ink);border-radius:22px 22px 0 0;width:100%;max-height:88%;overflow:auto;padding:12px 20px 28px;box-shadow:0 -10px 30px #0c1c3012}.handle-line{width:44px;height:4px;background:#d5dde7;border-radius:3px;margin:2px auto 20px}.sheet h2{font-size:20px;margin-bottom:18px}.sheet .row{min-height:62px}.sheet .row>input[type=radio]{accent-color:var(--blue);width:21px;height:21px;margin-right:10px}.sheet .btn{margin-top:16px;width:100%}.sheet .preview-row{display:flex;align-items:center;gap:16px;margin-bottom:18px;font-size:13px;color:var(--muted)}.sheet .preview-row .art{width:76px;height:80px;border-radius:8px;flex:none}.sheet .cancel{margin-top:8px}.toast{position:absolute;z-index:30;bottom:110px;left:20px;right:20px;background:#203651ed;color:white;border-radius:12px;padding:13px 16px;text-align:center;font-size:12px;box-shadow:0 4px 20px #0f203633}.app .demo-label{font-size:10px;color:#8b98ac;text-align:center;padding:7px 12px}.sheet-close{float:right;min-height:32px;width:32px}.help-copy p{margin:12px 0;color:var(--muted);font-size:13px}.empty-list{text-align:center;padding:40px 16px;color:var(--muted)}[hidden]{display:none!important}@media(max-width:350px){.btn{font-size:12px;padding:10px}.mosaic{height:190px}.mosaic .art{height:93px}.login-content h1{font-size:24px}.upload-thumb{width:62px}.photo-grid{grid-template-columns:repeat(2,1fr)}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}} diff --git a/docs/designs/embed-preview-2026-09-22.html b/docs/designs/embed-preview-2026-09-22.html new file mode 100644 index 0000000..15ae694 --- /dev/null +++ b/docs/designs/embed-preview-2026-09-22.html @@ -0,0 +1,28 @@ +PhotoLive · 嵌入照片窗预览
CONNECT. EXPLORE. BE INSPIRED.

A world of ideas.
A moment to connect.

Meet the people, stories and possibilities shaping tomorrow. Experience the fair, wherever you are.

Explore the event ↗

Inside the experience

GUANGZHOU · AUTUMN 2026

A closer look at the conversations and discoveries that bring us together.

嵌入组件 · 交互效果预览

右下角为实际 195 × 280 px 照片窗,电脑和手机尺寸一致。此页使用示例图片;下方气泡为明确标记的模拟事件,不连接正式服务。为便于看效果,手机预览首次也展开。

悬停、键盘聚焦时暂停;离开后恢复。可触摸拖动或滚轮浏览。点击照片新开观众页预览;右上角 − 收起。

diff --git a/docs/designs/embed-preview-desktop-2026-09-22.png b/docs/designs/embed-preview-desktop-2026-09-22.png new file mode 100644 index 0000000..b432626 Binary files /dev/null and b/docs/designs/embed-preview-desktop-2026-09-22.png differ diff --git a/docs/designs/embed-preview-mobile-2026-09-22.png b/docs/designs/embed-preview-mobile-2026-09-22.png new file mode 100644 index 0000000..8857d6f Binary files /dev/null and b/docs/designs/embed-preview-mobile-2026-09-22.png differ diff --git a/docs/designs/ui04-implementation/actual/desktop/e02-static-desktop.png b/docs/designs/ui04-implementation/actual/desktop/e02-static-desktop.png index 941d71c..129f197 100644 Binary files a/docs/designs/ui04-implementation/actual/desktop/e02-static-desktop.png and b/docs/designs/ui04-implementation/actual/desktop/e02-static-desktop.png differ diff --git a/docs/designs/ui04-implementation/actual/mobile/e02-static-mobile.png b/docs/designs/ui04-implementation/actual/mobile/e02-static-mobile.png index ec3aa6b..69c2343 100644 Binary files a/docs/designs/ui04-implementation/actual/mobile/e02-static-mobile.png and b/docs/designs/ui04-implementation/actual/mobile/e02-static-mobile.png differ diff --git a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm index 246b522..34a7b8d 100644 Binary files a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm and b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm differ diff --git a/docs/designs/viewer-preview-2026-09-22.html b/docs/designs/viewer-preview-2026-09-22.html new file mode 100644 index 0000000..39813b3 --- /dev/null +++ b/docs/designs/viewer-preview-2026-09-22.html @@ -0,0 +1,29 @@ + +PhotoLive · Gallery preview
◈PhotoLive

◉ ▦ ♧

diff --git a/docs/designs/viewer-preview-desktop-2026-09-22.png b/docs/designs/viewer-preview-desktop-2026-09-22.png new file mode 100644 index 0000000..6138195 Binary files /dev/null and b/docs/designs/viewer-preview-desktop-2026-09-22.png differ diff --git a/docs/designs/viewer-preview-lightbox-2026-09-22.png b/docs/designs/viewer-preview-lightbox-2026-09-22.png new file mode 100644 index 0000000..84657d2 Binary files /dev/null and b/docs/designs/viewer-preview-lightbox-2026-09-22.png differ diff --git a/docs/designs/viewer-preview-mobile-2026-09-22.png b/docs/designs/viewer-preview-mobile-2026-09-22.png new file mode 100644 index 0000000..18a4822 Binary files /dev/null and b/docs/designs/viewer-preview-mobile-2026-09-22.png differ diff --git a/docs/plans/2026-09-22-UI03R-App高保真还原完整实施方案.md b/docs/plans/2026-09-22-UI03R-App高保真还原完整实施方案.md new file mode 100644 index 0000000..f843a40 --- /dev/null +++ b/docs/plans/2026-09-22-UI03R-App高保真还原完整实施方案.md @@ -0,0 +1,550 @@ +# UI-03R:App 全页面高保真还原实施方案(替代 UI-03 的视觉验收标准) + +版本:2.0 +日期:2026-09-22 +状态:待执行。本文只规划调整,不代表页面已还原。 +目标:以五张设计板为视觉依据,逐页修复已实现 App 的形态偏差,交付可核验的真实 Flutter 界面。 + +## 0. 给执行者的强制说明 + +这不是自由设计任务,也不是“功能存在即可”的美化任务。先看图、再看本文件、再检查当前代码,按既定规格实现。保留现有业务和上传引擎,重点替换不匹配的布局与组件外观。 + +本文件替代 UI-03 中“接近设计板”“约25%–32%”“常规样式自主决定”等宽泛表述。对于未被本文覆盖的业务规则,继续执行 UI-03/UI-01 与现有权限契约;Web/嵌入组件不在范围。 + +本方案的数值是依据效果图重新制定的**工程还原基准**,不是声称从 AI 图片精确测得的全部原始设计参数。AI 图中的字体、图标、文字和画面存在细微不一致,不可能用自动像素差证明100%一致;必须采用“固定素材和数据 + 固定视口 + 布局测量 + 逐页视觉复核”组合验收。不能口头承诺像素级一致后省略证据。 + +- 不得直接把设计板作为运行界面背景。 +- 不得重新发挥为另一套 Material 默认风格。 +- 不得只更新颜色、标题、注释和验收表。 +- 不得用缺素材/空数据页面证明照片主导页面还原完成。 +- 不得把截图工作推迟到全部页面完成之后。 +- 不得把 widget/集成测试通过等同于视觉验收通过。 +- 不得用“模拟器不是手机”为由跳过能在固定 widget surface 完成的布局检查。 +- 不覆盖用户当前改动,不自动提交、推送、打标签或部署。 + +## 1. 设计依据与已核实偏差 + +### 1.1 唯一视觉参考 + +| 板 | 文件 | 页面 | +|---|---|---| +| 01 | [账号与设置](../designs/app-redesign-2026-09-21/01-account-settings.png) | 01–04 | +| 02 | [相册工作台](../designs/app-redesign-2026-09-21/02-albums-workspace.png) | 05–08 | +| 03 | [创建与协作](../designs/app-redesign-2026-09-21/03-create-collaborate.png) | 09–12 | +| 04 | [拍摄与上传](../designs/app-redesign-2026-09-21/04-capture-uploads.png) | 13–16 | +| 05 | [照片与分享](../designs/app-redesign-2026-09-21/05-photos-share.png) | 17–20 | + +每张四屏,按从左到右读取。外部标题、设备外圆角和画板灰底不画入 App。效果图中的年份、人物账号、二维码、描述字数不构成业务规则。 + +### 1.2 已检查的运行截图与代码问题 + +依据:`docs/plans/progress/ui03-screenshots/after/` 的 01-login、05b-albums-en、06-album-detail、14-uploads,以及当前 theme.dart 和页面代码。 + +| 偏差 | 实际观察 | 本轮修正 | +|---|---|---| +| 登录摄影素材缺失 | 顶部蓝色渐变和半透明矩形 | 必须真实摄影拼贴;占位不能通过主视觉验收 | +| 登录品牌/排版不同 | 品牌与标题左对齐、中文“现场影像”、默认字段外观 | 英文品牌固定、主体居中、字段图标/间距按页面01 | +| 相册封面缺失 | 灰色占位成为主视觉 | 使用可追溯测试摄影素材和有效封面响应验收正常态 | +| 根导航形态不同 | 紫蓝胶囊指示器、较重默认样式 | 无胶囊,蓝色选中图标和文字 | +| 详情首屏信息位置不同 | 标题在AppBar重复,封面占高,工具组过厚,照片首屏几乎不可见 | 顶栏仅返回/更多;标题在封面下;薄工具区;照片在首屏出现 | +| 上传摘要不同 | 白色大卡、完成数字弱、彩色文字分散 | 直接排在背景上,32号主数字、紧凑同一副行 | +| 上传筛选不同 | 带黑边的默认SegmentedButton胶囊 | 轻底色圆角8分段,选中实蓝,无外描边 | +| 上传条目不同 | 相册显示内部ID、素材为测试色块、信息层级松散 | 显示真实名称;固定缩略图和状态排版 | +| 验收证据不足 | 多页面无截图;05b文件名为en而画面仍为中文 | 所有20项保存真实指定语言截图并人工核对文件与内容 | +| 视口不一致 | 历史设备540×960 @240dpi,不能直接与设计板比较 | 固定逻辑画布,另保留设备截图,不按物理像素混比 | + +历史 UI03 验收记录用于了解实现,不作为本轮视觉通过证据。保留历史记录,新建 UI03R 记录,不篡改旧记录。 + +## 2. 开发顺序与决策边界 + +先完整修好 **01登录、05相册列表、14上传中心** 三个样板页并截图对照,再复用组件推广其余17项。不能先机械改完20页再补截图。 + +出现冲突按以下优先级: +1. 权限、数据真实性、账号隔离与上传可靠性。 +2. 本文冻结的布局和组件规格。 +3. 对应设计图的视觉层级与图片比例。 +4. 原UI-03一般性说明。 +5. Flutter组件默认值(优先级最低,不能作为不还原的理由)。 + +常规组件拆分无需询问;素材或环境缺失可以继续其他任务,但相关视觉状态标为“未完成”,不能用文字说明换取通过。真机拍摄、iOS编译等资源关口与UI布局验收分开登记。 + +## 3. 固定比较环境 + +### 3.1 参考画布与坐标 + +- 规范画布:逻辑 **390×844 dp**,英文、浅色、文字缩放1.0、无键盘。 +- 为稳定渲染,测试固定顶部 inset=24、底部 inset=24;测试画布的坐标包含这些区域。 +- 正式App使用真实系统inset,不绘制假状态栏、时间、电池或Home指示线;截图比较时系统栏蒙版排除。 +- 常规内容可用区域 y=24..820;内页顶栏 y=24..80,高56。 +- 根导航视觉主体 y=752..820,高68,底部系统区另计;root body不能延伸到导航下面而无法滚达。 +- 内页固定双按钮区主体高72,底部再加实际安全区。 +- 下述y坐标适用于指定参考画布和短英文内容,只用于约束首屏组成。生产长文字、大字体允许向下自然展开并滚动,禁止为保坐标裁字。 +- 不用整体Transform.scale缩放390画布适配所有设备。以真实约束自适应,保持按钮触控和字号。 + +### 3.2 两套证据必须都有 + +A. 固定画布widget截图:本地确定性素材/数据,复用真实页面及组件,验证布局。明确标记视觉fixture,不能声称相机或网络实装成功。 +B. 模拟器/真机运行截图:真实页面入口与隔离测试API,验证路由、交互、实际inset和资源加载。 + +需要系统检查逻辑尺寸与DPR,不能看到540px截图就当540dp。缺390设备时固定widget画布完成精确比较,再在现有设备补运行检查;不能强制修改用户设备全局分辨率而不恢复。 + +### 3.3 统一截图状态 + +固定测试时间、账号、英文文案、相册封面与图片顺序;禁动画、等待字体和图片加载完成、隐藏debug banner。不要直接复用某张错误态截图充当正常态。 + +## 4. 素材、字体、图标:先到位再调排版 + +### 4.1 素材清单(必做) + +建立 `docs/designs/ui03r-assets-manifest.md`: + +| 素材ID | 用途 | 最低要求 | +|---|---|---| +| brand-mark | 登录/列表品牌标志 | 同一蓝色光圈或已确认正式标志,所有页一致 | +| login-01..06 | 登录六图拼贴 | 清晰活动摄影,竖横图可裁切,风格与设计板同类 | +| event-cover-01..03 | 三张相册封面 | 展馆/活动/团队各一,至少720px宽 | +| event-photo-01..12 | 网格、缩略图、大图 | 真实摄影风格;包含横图、竖图、人物、产品、场地 | +| avatar-owner | 设置/成员 | 有合法素材才用照片,否则首字母;记录视觉偏差 | +| empty-album | 空相册插画 | 低对比叠放照片图形,不能换成大彩色卡通 | + +逐项记录路径、来源、许可或用户提供依据、尺寸、SHA256、用途、是否仅测试使用。 + +**取消旧计划中以渐变占位视为可交付的做法。** 缺摄影素材时优先查仓库已有资源;可复用用户已认可的生成图中干净摄影区域作为测试参考(不得包含UI文字/边框,质量不足不可使用),或在有图像工具与授权条件时生成独立摄影素材。是否可执行裁切/生成按实际工具规范操作。生产素材来源未确定则单独列阻塞,不假称完成。不得从网络随便抓图。 + +将测试照片存入测试fixture,生产登录摄影拼贴使用随App打包的合规资源;生产相册卡仍读取真实API,不给所有用户硬塞测试封面。测试API资产必须可访问且未过期。 + +### 4.2 字体冻结 + +- 拉丁字形目标:Inter 或仓库已经具备的视觉等价字体,400/500/600/700四档,优先本地随包。 +- 先检查字体可用性与许可;不可联网获取时不能假称已使用Inter。登记替代字体和字宽差异,对照校准后再通过。 +- 中文回退到可用中文系统无衬线字体,不强行用拉丁字体显示方块。 +- 不在每页随机指定fontFamily,不运行时下载字体。 +- 设置字体后重新测量文本,因为默认Roboto与Inter会改变换行。 +- 表格中数字采用tabularFigures;禁全局锁死textScaler。 + +### 4.3 文字样式(实际写入TextStyle) + +| token | size/lineHeight(dp) | weight | 用途 | +|---|---|---|---| +| brand | 26/32 | 700 | PhotoLive | +| pageTitle | 24/30 | 700 | Albums/Settings | +| innerTitle | 18/24 | 600 | 返回页标题 | +| heroTitle | 28/34 | 700 | 登录主标题 | +| sectionTitle | 17/24 | 600 | Recent photos | +| body | 15/22 | 400 | 说明 | +| field | 15/20 | 400 | 输入内容 | +| rowTitle | 15/20 | 600 | 列表标题 | +| secondary | 13/18 | 400 | 时间地点 | +| caption | 12/16 | 400 | 辅助标签 | +| button | 15/20 | 600 | CTA | +| metric | 32/40 | 700 | 82 uploaded | + +不要把所有AppBar都设成24:内页居中18,根页左对齐24,摄影页独立。 + +### 4.4 图标冻结 + +同一种线型,常规22dp,线宽视觉约1.8–2。若使用现有Material图标,逐项选择outlined版本;禁止相同入口在不同页换成info/check等不相关图标。 +Public=globe,Uploads on=cloud upload,Members=people,Categories=tag,Share=share nodes,History=clock arrow,Settings=gear。 +底部选中可填充,同类轮廓一致。没有正式Logo时不要每页使用不同的相机/图库图标冒充品牌。 + +## 5. 组件级样式冻结 + +### 5.1 配色与表面 + +```text +primary #1A73E8 +page #F6F8FB +surface #FFFFFF +textPrimary #172033 +textSecondary #647084 +line #E5EAF1 +softBlue #EEF5FF +photoBackground #10161E +successText #16794B +waitingText #996300 +errorText #B42318 +``` + +- 禁止种子派生色自动渗入导航、开关、筛选、操作卡。显式指定每个组件状态的颜色。 +- 普通卡无明显浮起阴影;需要阴影时black 4%、blur12、offset(0,3),不同时粗边框+重阴影。 +- card radius16,form field/button radius10,segment radius8,image tile radius4。 +- 错误、等待色不映射成全局secondary后被导航当选中色使用。 + +### 5.2 根底部导航(必须替换当前胶囊样式) + +- 可用NavigationBar但indicatorColor透明、indicatorShape不显示、selected/unselected颜色显式分开;若默认内部布局无法满足则用语义完整的自定义三等分组件。 +- 白底、顶部1px line、主体高68,图标24,上距12,图标与标签间6,标签11/14。 +- 选中图标+标签primary,未选textSecondary;无圆角底色、无紫色胶囊。 +- 每个项目触控区域整个三等分格。 +- badge直径16,红底白字10,靠上传图标右上;99以上显示99+,不推移布局。 + +### 5.3 顶栏与操作 + +- 根页标题区56高,左右20;内页返回按钮44,居中innerTitle,右侧44操作。 +- 内页不在AppBar再重复活动名;详情活动名只在封面下。 +- surfaceTintColor透明,elevation=0,scrolledUnderElevation=0。 +- 大图/相机顶栏背景深色,白图标。 + +### 5.4 输入框 + +- label在输入框上方,非浮动label;label13/18,与框间6。 +- 输入框高52、白底、1px line、radius10,左右14。 +- 聚焦1.5px primary;错误1px error并在下方显示说明。 +- 登录字段允许以placeholder为主且leading22图标;禁止空框只靠浮动中文标签模仿设计。 +- 密码eye有效宽44;多行描述至少100高;不使用超粗默认outline。 + +### 5.5 按钮与分段 + +- Primary:高50,primary白字,radius10,无elevation;disabled用明确灰蓝,不能与可点击同色。 +- Secondary:白底1px primary线,primary字;danger:红色文字+图标,默认不整条红底。 +- Tabs/segments:高40,浅灰#EEF1F6轨道,padding3,radius8;选中primary实底白字,未选textSecondary;不显示选中check、不画外黑边、不做大胶囊。 +- 图中Details/Access是浅蓝选中风格:白/浅灰轨道,选中#DDEBFF+primary文字。不要和上传分段混用状态颜色。 + +### 5.6 卡片与Sheet + +- 普通分组卡:白底radius16,内边距16,外分组间20;行高56–64,分隔从文字起始位置对齐。 +- Sheet:白底、顶部radius20,拖柄36×4,距顶10;左右20,标题18/24,底部SafeArea。 +- Sheet scrim黑色32%,不能灰得看不清背景层次;顶部内容不浮在遮罩之外。 +- 单选行高60,radio20,row点击生效;不使用Material默认过大的行高。 +- 内页双按钮条:padding(16,12,16,12),gap12,两等宽,高50,白底;系统inset另算。 + +### 5.7 图片封面卡 + +- 宽=可用宽,390时左右20得350;高=width×9/16≈197。 +- 圆角12而非巨圆角,间距16。 +- scrim:顶部透明,0.42开始变暗,底部黑70%;不把整张图覆盖成灰色。 +- 标题18/24 semibold白,左16、底部状态行上方约42;meta12/18,status行12/16。 +- Public左、Uploads on右,中间Spacer;**无黑色独立胶囊背景,无绿色勾徽章**,只白色图标与文字。 +- 默认单行短日期,使用“Oct 15–19 · Guangzhou”一类真实格式,完整时区/时间放详情;禁止列表用完整起止时间和“未定”撑满。 +- 正常态必须有摄影封面,缺图态单独验收。 + +## 6. 逐屏布局:390×844基准 + +所有坐标以第3节画布计。表中“高”指基准英文数据;长文字允许重排,不能硬塞进固定高度。固定按钮区域使用真实SafeArea。 + +### 01 登录(图01第一屏) + +| 区域 | x / y / w / h | +|---|---| +| 白色上部语言区 | 0 / 24 / 390 / 62 | +| 摄影六图拼贴 | 0 / 86 / 390 / 254 | +| 品牌居中 | 65 / 368 / 260 / 32 | +| 主标题居中 | 20 / 424 / 350 / 34 | +| 副标题居中 | 20 / 466 / 350 / 22 | +| 账号框 | 20 / 538 / 350 / 52 | +| 密码框 | 20 / 604 / 350 / 52 | +| Sign in | 20 / 678 / 350 / 50 | +| 管理员说明 | 20 / 758 / 350 / 36 | + +拼贴3列×2行,缝4,图片cover,禁止渐变矩形替代。语言在右上,正文、品牌、标题居中;账号/锁leading图标。短屏和键盘时图片缩小或滚出,表单可达;不把整个390版压扁。现有loginAskAdmin、用户名等走l10n,品牌英文固定不随UI翻译成“现场影像”。 + +### 02 设置(图01第二屏) + +- 根标题x20 y48,24/30。 +- 账号卡x20 y104 w350 h112:头像56,左16;文字x100;姓名17/24,账号13/18,角色可次行。 +- 选项组x20 y240 w350:4行各60;leading22,左右16;分隔仅文字区。 +- 退出卡y504 h64,红色图标与文字,其余白色。 +- 根导航按统一规格。不要把所有设置都做成散落的ListTile,没有分组表面。 +- 账号卡无实际编辑能力不画箭头;图中的人物只用于固定fixture。 + +### 03 修改密码(图01第三屏) + +内页顶栏后y104安全提示卡h76;y204起三个字段组(label18+gap6+框52),组间20;主按钮距最后框24;下方提示组距32。无第二个页面大标题重复AppBar。 +说明只是建议,不创建新的密码强制政策;键盘时整页滚动。 + +### 04 语言Sheet(图01第四屏) + +参考顶边y440、底部844(含safearea),标题区域高64;三行60,底部Done高50,上下留16。白底、radio左、语言名两行说明在有意义时出现。临时选择,Done提交;关闭不提交。高度随文字增大可扩展至85%屏,不能截断选项。 + +### 05 相册列表(图02第一屏) + +- 顶部PhotoLive x20 y42,右加号36圆形但有效44。 +- Albums x20 y104,说明y139;首卡y184,h197;第二卡y397;第三卡y610部分进入首屏,继续滚动。 +- 三张封面色彩/构图接近设计板,短白字叠层。正常列表不要用巨大灰色占位。 +- 根导航y752覆盖区前有列表底部padding,让末卡可完整滚出。 +- 中文标题同层级,不把PhotoLive隐藏成只有“相册”。 + +### 06 相册详情(图02第二屏) + +- 顶栏y24–80仅返回与更多。 +- 封面x0 y80 w390 h165,照片edge-to-edge。 +- 信息x16 y260:标题22/28;日期行y299、地点行y325,分开,不把全部信息拼成三行长字符串。 +- 两个状态单元y357 h32,浅蓝底、普通蓝灰图标;Uploads on不是绿色success大徽章。 +- 四工具单元x16 y407,单元约80×64,gap12;每个独立极浅底radius10,icon22+label11,**无包裹它们的第二层厚白大卡**。 +- Recent photos标题y493,grid首行y525;至少一整行照片在首屏可见。 +- 底部动作固定在可用区域末72,正文让位。 +- 不重复AppBar中的活动标题,不把封面做成接近半屏高。 + +### 07 操作Sheet(图02第三屏) + +复用详情作为背景,Sheet顶边约y450。标题18,4行52,行图标22、字15;Cancel单独轻灰行50。只显示有权限项。不要顶部多一个巨大的默认菜单标题条。 + +### 08 空相册(图02第四屏) + +保留详情封面/活动信息至y390;空态插画约112×88居中y486;标题17/24 y605、说明13/20 y640;底部动作同详情。空态不显示“Recent photos + 空列表”两套重复说明。无上传权限则移除动作,空态说明相应调整。 + +### 09 新建(图03第一屏) + +- 居中内页标题;body左右16,顶部20。 +- 基础白色卡x16 y100 w358;标题输入、日期两行、时区、地点分行。卡内部padding16、行高52–56。 +- 描述语言卡距20:标题17,默认语言选择框52,描述区至少100。 +- Create album固定底栏;表单底部预留空间。 +- 所有标签在框上方或有对齐的行label,不把全部字段变成无分组的默认TextFormField。 +- 真实必填字段完整,设计板首屏没展示的字段允许向下滚动。 + +### 10 编辑Access(图03第二屏) + +Tabs x16 y104 w358 h40,距下卡20。开关卡内4项,每项标题15/20与说明13/18,右开关44×26;行高最低86,说明长允许扩高。iOS样式细长开关或自定义视觉,不直接用明显不同的大Material轨道。 +Content language单独卡距20 h84;Save固定底栏。 +Details共用新建表单,保持草稿。禁止为了还原图修改服务端开关语义。 + +### 11 成员(图03第三屏) + +顶栏返回、标题、加号。Owner分组标题y108,卡y144高88。Collaborators标题y256,白卡y292,两项各88。头像44、名字15 semibold、账号12、权限说明12;文字不溢出右侧overflow按钮。 +说明框用softBlue和小info,padding12。Invite colleague为底部outlined高50。 +内部账号搜索面板复用成员能力;图上邮箱不是必须字段;真实缺省显示account。禁止借口“无头像”把所有行改成没有头像占位的稀疏文本。 + +### 12 分类与编辑Sheet(图03第四屏) + +列表左右16,行高92,图标/可用缩略图56×56;分类名15/20,编辑22、更多22。没有封面用柔和图标,属于允许偏差。 +新增Sheet顶边约y452,标题+关闭、语言tabs、label、52高输入、底部Save。语言tabs浅蓝选中,不能采用上传实蓝样式。 +列表和面板CRUD复用现有实现,不新增照片封面数据表。 + +### 13 相机(图04第一屏) + +顶部黑色区域y24–100:返回左、相册/分类居中、真实上传状态右。取景舞台y100–650,按真实相机比例contain或必要的预览适配,禁止人物拉伸。底部黑色控制区:缩略图48,快门76(外圈2、内实白)、切换摄像头44。 +“Added to upload queue”半透明反馈在取景下边缘上方,不覆盖快门。 +无Square/Pano;相机无资源时只可用注入fixture验证布局,真机预览另列未验收,不造实机截图。 + +### 14 上传中心(图04第二屏) + +- 顶部Uploads x20 y48,History右,不重复品牌占据标题。 +- Today · This device x20 y108,13/18。 +- 82 uploaded x20 y142,32/40;数字和标签同一大字号。 +- 副行x20 y192,13/18,中性次文字,不把所有状态染成一串彩色数字。 +- 分段x16 y238 w358 h40;其他相册/日期筛选放History旁图标/底层面板,不能挤掉三个分段。 +- 第一任务卡y300,左右16,白底radius12,minHeight124,内padding12;下一卡gap12。 +- 缩略图76×88,右文字区域从x116起;文件名15/20粗,相册/分类12/17次色,状态在下方。 +- uploading:进度条高4,状态文字12;uploaded绿色图标+文字,无巨绿胶囊;confirming小spinner。 +- **摘要没有包裹白色大卡;筛选无黑色描边、无紫色选中背景。** +- 正常截图至少4种任务状态,不用唯一完成记录+大面积空白证明还原。 +- 相册名必须显示标题;无匹配名称时本地化“相册信息暂不可用”,不要显示长UUID作为正常副标题。 + +### 15 Needs attention(图04第三屏) + +同上传摘要和分段;下面两张异常卡,至少152高。缩略图76、文件名、相册、红色错误句;错误解释13/18;Retry或Select again在文字列下方,高40但触控44。底部Retry eligible tasks高50,独立于根导航。 +不改变原失败分类,不新增UI控制的假重试。摘要仍明确“Today · This device”,不会错误声称为异常筛选结果总数。 + +### 16 历史(图04第四屏) + +内页标题、subtitle y104、相册/日期两个筛选y140高40;首记录y206。白/页面背景平铺,无每行重阴影;缩略图80×88,行高124,右侧文件名、状态、时间、设备来源分行,细分隔。 +状态由服务端真实字段决定,原接口没提供的缩略图或文件名不能硬造。生产回退与视觉fixture正常态分别记录。 + +### 17 照片网格(图05第一屏) + +内页顶栏左返回,中间/左标题Photos与小相册名;分段y104高40,网格y164。左右8,列缝4,3列每格约122×150(网格可固定裁切,**管理页不是观众瀑布流**)。优先保持均匀行高。 +角标只显示Processing/Hidden,半透明深底,图标12+文字11,靠左下,padding6。 +无根底部导航;列表最后一行可完整滚动,数据仍走管理预览和权限。 + +### 18 管理大图(图05第二屏) + +顶栏深色y24–80;图片舞台y104–604,完整contain;左右控制44半透明黑圆。底部信息x20 y636,上传者15粗,时间13,尺寸13;状态badge靠右,同一行。 +分隔y722,动作条y742–796:Hide photo为深色1px灰线按钮w190,Delete右侧红字图标,无整条鲜红按钮。短屏根据剩余高度缩小舞台,不能遮住动作。 +不添加观众点赞工具栏。保持现有手势、分页、签名刷新、返回位置。 + +### 19 分享(图05第三屏) + +顶栏Share album;分享白卡x16 y104 w358,边线1px、radius16、内padding16。封面w326 h172,radius10;标题18/24,地点日期13分两行。 +QR区域约196×196,居中浅灰底,真实二维码周围留足quiet zone;下方说明12居中。Copy link高50,与QR说明间20。公开说明位于卡内底部。 +不把裸链接+一个大二维码直接铺满整个屏幕;原有浏览器打开入口可放更多或次级文本,不抢主按钮视觉。未公开时修改说明,不伪造可访问。 + +### 20 隐藏确认(图05第四屏) + +管理大图作为背景,黑32%遮罩;Sheet y≈520,顶拖柄,标题居中18/24,缩略图64×80左、说明13/20右;primary50与cancel50各占整行,间12。 +说明按权限:“Authorized album managers can publish it again.”,不要向无恢复权限者承诺本人可恢复。 +photoId打开时捕获,切图/重复点击不作用到别图;真实请求错误保持可理解反馈。 + +## 7. 素材/数据/视口固定的视觉fixture + +建立专用 `test/visual/` 与清晰命名的debug-only设计预览入口,复用**真实生产页面和组件**并注入数据,不复制一套漂亮但不被生产使用的静态页面。 + +固定样本: +- 账号Alex Morgan,头像/首字母,英语。 +- 三相册Canton Fair · Autumn / Product Launch / Team Stories;首相册Oct15–19,2026,Guangzhou。 +- 至少12张摄影图片与正确宽高;封面非null;一张Hidden、一张Processing。 +- 上传范围Today · This device:82完成、3传输中、13等待、2需处理。必须通过可控fixture/provider表达,不写死生产数字。 +- 两名协作者;三分类;两条失败任务;5条服务端历史。 +- 分享二维码使用测试相册URL真实生成。 +- 配套空/断网/不可公开/无预览权限fixture,不能与正常态混为一张。 + +debug入口不能携带真实账号密码、不触生产写操作;正式构建不可进入fixture模式。相机测试预览必须标识为fixture,不能作为原生相机验收证据。 + +## 8. 精确的截图对照流程(每页都执行) + +1. 打开对应原设计板,定位第几屏。可用图像裁切工具制作参考屏裁片;裁切只用于比较,不用来代替运行界面。保留原图,记录裁框。 +2. 将参考屏裁片等比例统一宽度至390(不做非等比拉伸);由于生成图屏幕比例不一致,系统栏/画板边界排除。坐标冲突以本文第6节工程基准为准。 +3. 渲染固定390×844 fixture截图,文件名明确screenId/lang/theme/scale/dataVersion。 +4. 输出参考/实际左右对照图、50%透明叠加图;对系统栏、不同照片内容、日期文字等合理差异设置明确mask并记录。 +5. 测量关键框:标题、首卡、首图、分段、底栏;出一行目标/实测/差值表。 +6. 按差异修复 → 重拍。不得在没查看真实截图时自行写“视觉通过”。 +7. 相同页面的生产服务冒烟截图另存,证明不是仅fixture页面好看。 + +不能用SSIM/整图像素差单一阈值判断通过:照片内容和字体抗锯齿会干扰。可计算结构区差异辅助,但最终必须逐页检查。 + +### 8.1 可量化通过标准 + +参考画布下: +- 主要容器x/y/w/h相对第6节目标偏差≤4dp;涉及自然文本高度累计偏差≤8dp且记录原因。 +- 明确token的字号偏差≤1sp,字重一致(400/500/600/700);行高偏差≤2dp。 +- 圆角偏差≤2dp,常规间距偏差≤2dp。 +- 首屏预期出现的区域必须出现:详情首行照片、上传至少主要任务行、登录主按钮等;不能整体下推到屏外。 +- 色值以token为准,不接受主题种子自动派生为另一色相。 +- 不允许残留占位摄影拼贴、根导航胶囊、默认黑框分段、正常态ID裸露、重复活动标题。 +- 标准英文fixture关键标题换行与规范一致;中文及大字体按可用性允许重排。 +- 触控、语义、长文案通过独立检查,不能为坐标牺牲可访问性。 + +超出阈值必须写出具体数值及业务/平台原因,并标记待修或接受的明确偏差;不能以“效果接近”一笔带过。 + +## 9. 逐任务实施计划 + +### R00 复核基线 +- [ ] Git状态、最新UI03代码/验收、五图全部读取。 +- [ ] 创建 docs/plans/progress/UI03R-验收记录.md。 +- [ ] 建立20屏“参考/当前/偏差/目标”表。 +- [ ] 确认逻辑尺寸、字体、图片来源、可用截图工具。 +出口:不能沿用旧UI03全通过结论。 + +### R01 素材和fixture +- [ ] 生成资产清单、补足摄影素材及品牌一致性。 +- [ ] 固定英文数据、12张照片、正常和异常状态。 +- [ ] fixture注入真实组件;生产开关隔离。 +出口:正常态不是灰色占位,且数据不会污染生产。 + +### R02 公共组件重构 +- [ ] theme/tokens显式定义,不依赖默认Material外观。 +- [ ] 无胶囊根导航、两种segment、输入、按钮、Sheet、封面卡。 +- [ ] 给公共组件截图和语义检查。 +出口:默认风格造成的偏差已消除。 + +### R03 三个样板页 +- [ ] 完成01登录、05相册列表、14上传中心。 +- [ ] 三页必须有390英文正常态、对照图和几何差值表。 +- [ ] 达到第8节标准再推广,不等待用户确认常规像素调整。 +出口:缺素材或仍用默认样式时不能进入“样板已通过”。 + +### R04 账号与工作台 +- [ ] 页面02/03/04/06/07/08。 +- [ ] 逐页对照,特别检查详情首屏照片和工具区高度。 +出口:8页完整证据。 + +### R05 表单与协作 +- [ ] 页面09/10/11/12,邀请与编辑配套弹层。 +- [ ] 表单草稿、权限、双语内容不回归。 +出口:分组/字号/开关对齐,不是默认表单集合。 + +### R06 拍摄与上传 +- [ ] 页面13/15/16,与14视觉一致。 +- [ ] 拍摄fixture与实际设备验证分开,记录真实资源。 +- [ ] 行级进度、恢复重试、账号隔离回归。 +出口:明确本地队列/服务端历史区别。 + +### R07 照片与分享 +- [ ] 页面17/18/19/20。 +- [ ] 大图舞台、资料、底部动作、QR卡和确认Sheet。 +- [ ] 照片权限、签名过期、切图及返回位置回归。 +出口:管理摄影界面与图05结构一致。 + +### R08 全量验证 +- [ ] 20页英文正常态全部截图,不以widget测试替代缺图。 +- [ ] 中文、320短屏、430、平板、横屏、大字体、深色抽查。 +- [ ] 功能和视觉验收分开记录,失败项回到对应任务。 +出口:证据齐全,不能以“留待G2”跳过可模拟的文字/布局检查。 + +### R09 交接 +- [ ] 资产清单、截图索引、差异表、测试结果、外部关口整理。 +- [ ] 总纲标记UI03R真实状态,不覆盖原UI03历史。 +- [ ] 如有未完成的摄影/字体/截图/真机项,最终明确标为部分完成。 +出口:用户可打开每个页面对照,而非只能相信测试数字。 + +## 10. 回归、响应式与命令 + +### 10.1 视口矩阵 + +- 主基准390×844 / en / light /1.0:20项全量。 +- 320×568:登录键盘、详情、编辑Access、上传、分享、Sheet。 +- 430×932:列表、照片网格、大图。 +- 844×390横屏:拍摄布局、大图和分享可达。 +- 768平板:列表/表单/成员;内容maxWidth按版式合理限制,不能把字段拉成全屏一条。 +- textScaler1.3、2.0:设置、表单、上传错误、隐藏确认。 +- zh-CN:20页运行检查,至少8个主要页截图,文件名和画面语言一致。 +- dark:设置、表单、上传、列表;摄影页维持既定深色。 + +### 10.2 功能保护 + +账号登录/密码/退出,语言持久化,表单未保存,成员/分类权限,上传关闭,公开关闭,拍摄入队,等待网络/重试,源文件丢失,凭证续签,队列清理,照片预览权限和下线,分享复制失败都要按既有逻辑回归。 +不修改native_transport/upload_engine原生可靠性语义;如样式工程确实触及控制逻辑,必须补针对性测试并登记。 + +### 10.3 执行入口 + +在apps/mobile中使用仓库固定Flutter: + +```text +flutter gen-l10n +flutter analyze +flutter test +``` + +另行添加可重复运行的visual fixture测试命令/截图脚本,并在README写明。检查工具是否重置数据,不对生产运行。不能自动更新golden来“让测试变绿”:先查看差异、修好界面,再建立新基线。 + +先跑定向后跑全量;只改App样式不必用无关Web构建缓存结果充当主要验收证据。 + +## 11. 双轨验收与交付目录 + +### 11.1 每页记录模板 + +| 字段 | 必填内容 | +|---|---| +| 页面ID/名称 | 01 Login等 | +| 代码入口 | 实际页面和公共组件 | +| 参考屏 | 板号/屏号/裁片路径 | +| 正常态截图 | 390/en/light路径 | +| 当前数据 | fixture版本、素材清单版本 | +| 结构测量 | 目标/实测/差值,至少4个关键框 | +| 视觉差异 | 已修/剩余及原因 | +| 功能结果 | 通过/失败/未执行 | +| 视觉结果 | 通过/失败/未完成 | +| 可访问性 | 触控、语义、字体缩放 | +| 外部限制 | 真机/素材/字体等,具体影响 | + +“功能通过、视觉未完成”必须允许存在;不能合成一个✅遮盖差异。 + +### 11.2 目录 + +```text +docs/designs/ui03r-assets-manifest.md +docs/designs/ui03r-implementation/ + references/ 设计板各屏参考裁片及裁框记录 + actual/en-light/ 20个固定画布截图 + actual/zh-light/ + actual/responsive/ + device/ 模拟器/真机证据,记录平台参数 + comparisons/ 左右对照、透明叠加 + geometry/ 每页测量结果 + README.md 页面链接和复现命令 +docs/plans/progress/UI03R-验收记录.md +``` + +### 11.3 完成门槛 + +- 五张图对应20项全部有正常态固定画布截图和视觉结论。 +- 三个样板页满足尺寸/字体/颜色/素材要求。 +- 无本方案列明的默认样式偏差残留。 +- 真实运行路径复用同一套组件,不是只做设计预览页。 +- 功能回归通过,未执行外部关口如实标注。 +- 存在主视觉素材缺失或关键页面未截图时,本阶段只能标记“部分完成”。 +- 严禁在没有看图、没有测量的情况下以源码注释或测试数量宣布视觉100%还原。 + +## 12. 可直接交给 Claude 的执行指令 + +> 请执行 docs/plans/2026-09-22-UI03R-App高保真还原完整实施方案.md。这次是纠正UI03与效果图差异,不是自由发挥或再次仅做功能。先查看五张原设计板和旧实际截图,准备真实摄影素材与固定390×844英文fixture,显式覆盖Material默认导航/分段/输入/按钮样式。首先完成01登录、05相册列表、14上传中心三页,输出同尺寸对照和关键框测量,达到文档标准后再完成其余17项。每页都要截图、查看、修正,功能和视觉分开验收。不得用渐变占位代替摄影拼贴、不得保留导航胶囊和默认黑框分段、不得用测试通过代替视觉还原。保护当前业务与未提交改动,保留权限和上传引擎,不修改Web与嵌入组件。最终交付真实可运行App、完整20页证据、素材清单、UI03R验收记录及剩余关口;未完成视觉项不得标完成。未经另行授权不推送、不部署、不操作云资源。 +