feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
outbox album.updated priority=high + audit album.delete);列表/详情/更新/
成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
(status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,7 @@ import 'share_qr_dialog.dart';
|
||||
const int _kRecentGridLimit = 9;
|
||||
|
||||
/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。
|
||||
enum AlbumAction { edit, members, categories, share }
|
||||
enum AlbumAction { edit, members, categories, share, delete }
|
||||
|
||||
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
|
||||
class AlbumDetailPage extends ConsumerStatefulWidget {
|
||||
@@ -80,9 +80,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
_errorCode = null;
|
||||
});
|
||||
try {
|
||||
final album = await ref.read(apiClientProvider).getAlbum(
|
||||
widget.albumId,
|
||||
);
|
||||
final album = await ref.read(apiClientProvider).getAlbum(widget.albumId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_album = album;
|
||||
@@ -170,9 +168,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final changed = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) => AlbumFormPage(existing: album),
|
||||
),
|
||||
MaterialPageRoute<bool>(builder: (_) => AlbumFormPage(existing: album)),
|
||||
);
|
||||
if (changed == true) {
|
||||
await _load();
|
||||
@@ -184,9 +180,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumMembersPage(album: album),
|
||||
),
|
||||
MaterialPageRoute<void>(builder: (_) => AlbumMembersPage(album: album)),
|
||||
);
|
||||
// 成员变更不影响详情数据,回来无需刷新。
|
||||
}
|
||||
@@ -195,9 +189,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumPhotosPage(album: album),
|
||||
),
|
||||
MaterialPageRoute<void>(builder: (_) => AlbumPhotosPage(album: album)),
|
||||
);
|
||||
// 管理页内可能发生隐藏/删除,回来后刷新预览网格。
|
||||
await _loadPhotoPreview();
|
||||
@@ -210,7 +202,8 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
if (album == null) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumCategoriesPage(album: album, isManager: _isManager),
|
||||
builder: (_) =>
|
||||
AlbumCategoriesPage(album: album, isManager: _isManager),
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
@@ -220,9 +213,9 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
// UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => ShareQrPage(album: album)),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => ShareQrPage(album: album)));
|
||||
}
|
||||
|
||||
/// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再
|
||||
@@ -243,9 +236,59 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
await _openCategories();
|
||||
case AlbumAction.share:
|
||||
_shareQr();
|
||||
case AlbumAction.delete:
|
||||
await _deleteAlbum();
|
||||
}
|
||||
}
|
||||
|
||||
/// 软删除相册(admin/owner;契约 deleteAlbum):二次确认 → DELETE →
|
||||
/// 成功提示并整栈退回相册列表(详情/编辑等路由一并出栈),列表刷新。
|
||||
Future<void> _deleteAlbum() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(l10n.deleteAlbumConfirmTitle),
|
||||
content: Text(l10n.deleteAlbumConfirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(l10n.deleteAlbum),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(apiClientProvider).deleteAlbum(album.id);
|
||||
} on AppException catch (error) {
|
||||
// 删除失败:留在详情页,本地化错误码提示。
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(error))),
|
||||
);
|
||||
return;
|
||||
} on Object {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.albumDeleted)));
|
||||
// 整栈退回列表根(编辑页等推栈路径一并清理),并刷新列表数据源。
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
ref.read(albumListProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
|
||||
|
||||
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
||||
@@ -267,7 +310,10 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
target: CaptureTarget(
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
|
||||
albumTitle: albumDisplayTitle(
|
||||
album,
|
||||
AppLocalizations.of(context).localeName,
|
||||
),
|
||||
categories: album.categories,
|
||||
),
|
||||
),
|
||||
@@ -301,7 +347,9 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final album = _album;
|
||||
final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName);
|
||||
final title = album == null
|
||||
? l10n.albumDetail
|
||||
: albumDisplayTitle(album, l10n.localeName);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
@@ -313,90 +361,94 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: album == null ? null : _UploadBar(
|
||||
uploadEnabled: album.uploadEnabled,
|
||||
onCapture: _openCapture,
|
||||
onPick: _pickFromGallery,
|
||||
),
|
||||
bottomNavigationBar: album == null
|
||||
? null
|
||||
: _UploadBar(
|
||||
uploadEnabled: album.uploadEnabled,
|
||||
onCapture: _openCapture,
|
||||
onPick: _pickFromGallery,
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: album == null
|
||||
? _ErrorRetryView(
|
||||
message: l10n.messageForErrorCode(_errorCode!),
|
||||
onRetry: _load,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 封面:全宽 16:9(无封面同尺寸中性占位)。
|
||||
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, 0,
|
||||
? _ErrorRetryView(
|
||||
message: l10n.messageForErrorCode(_errorCode!),
|
||||
onRetry: _load,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 封面:全宽 16:9(无封面同尺寸中性占位)。
|
||||
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16,
|
||||
kSpace16,
|
||||
kSpace16,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
albumDisplayTitle(album, l10n.localeName),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
albumDisplayTitle(album, l10n.localeName),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
_metaLine(album, l10n),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace12),
|
||||
_StatusSection(album: album),
|
||||
],
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
_metaLine(album, l10n),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace16),
|
||||
// 次级四入口(Photos/Members/Categories/Share)。
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace16,
|
||||
),
|
||||
child: _ActionTiles(
|
||||
isManager: _isManager,
|
||||
onPhotos: _openPhotos,
|
||||
onMembers: _openMembers,
|
||||
onCategories: _openCategories,
|
||||
onShare: _shareQr,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace24),
|
||||
// Recent photos:三列网格(≤9)/ 空相册安静占位 /
|
||||
// 处理中指示。
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace16,
|
||||
),
|
||||
child: _RecentPhotosSection(
|
||||
photos: _recentPhotos,
|
||||
loading: _photosLoading,
|
||||
isManager: _isManager,
|
||||
onOpenPhotos: _openPhotos,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace32),
|
||||
],
|
||||
const SizedBox(height: kSpace12),
|
||||
_StatusSection(album: album),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace16),
|
||||
// 次级四入口(Photos/Members/Categories/Share)。
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace16),
|
||||
child: _ActionTiles(
|
||||
isManager: _isManager,
|
||||
onPhotos: _openPhotos,
|
||||
onMembers: _openMembers,
|
||||
onCategories: _openCategories,
|
||||
onShare: _shareQr,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace24),
|
||||
// Recent photos:三列网格(≤9)/ 空相册安静占位 /
|
||||
// 处理中指示。
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace16),
|
||||
child: _RecentPhotosSection(
|
||||
photos: _recentPhotos,
|
||||
loading: _photosLoading,
|
||||
isManager: _isManager,
|
||||
onOpenPhotos: _openPhotos,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。
|
||||
static String _metaLine(Album album, AppLocalizations l10n) {
|
||||
final venue = albumDisplayVenue(album, l10n.localeName);
|
||||
final start =
|
||||
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
|
||||
final start = formatAlbumDateTime(
|
||||
album.startAt,
|
||||
album.timezone,
|
||||
l10n.localeName,
|
||||
);
|
||||
final end = album.endAt == null
|
||||
? l10n.notSet
|
||||
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
||||
@@ -449,6 +501,20 @@ class AlbumActionSheet extends StatelessWidget {
|
||||
title: Text(l10n.shareAlbum),
|
||||
onTap: () => Navigator.of(context).pop(AlbumAction.share),
|
||||
),
|
||||
if (isManager)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
l10n.deleteAlbum,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
onTap: () => Navigator.of(context).pop(AlbumAction.delete),
|
||||
),
|
||||
const Divider(indent: kSpace16, endIndent: kSpace16),
|
||||
ListTile(
|
||||
title: Center(
|
||||
@@ -482,16 +548,24 @@ class _StatusSection extends StatelessWidget {
|
||||
runSpacing: kSpace8,
|
||||
children: [
|
||||
StateBadge(
|
||||
variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral,
|
||||
variant: album.publicEnabled
|
||||
? BadgeVariant.info
|
||||
: BadgeVariant.neutral,
|
||||
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
|
||||
),
|
||||
StateBadge(
|
||||
variant:
|
||||
album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral,
|
||||
label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff,
|
||||
variant: album.uploadEnabled
|
||||
? BadgeVariant.success
|
||||
: BadgeVariant.neutral,
|
||||
label: album.uploadEnabled
|
||||
? l10n.albumUploadsOn
|
||||
: l10n.albumUploadsOff,
|
||||
),
|
||||
if (album.status == AlbumStatus.offline)
|
||||
StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline),
|
||||
StateBadge(
|
||||
variant: BadgeVariant.error,
|
||||
label: l10n.albumStatusOffline,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -592,11 +666,7 @@ class _ActionTile extends StatelessWidget {
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
child: Icon(icon, size: 22, color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: kSpace8),
|
||||
Text(
|
||||
@@ -693,8 +763,7 @@ class _RecentPhotosSection extends StatelessWidget {
|
||||
aspectRatio: 1,
|
||||
),
|
||||
),
|
||||
]
|
||||
else
|
||||
] else
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kSpace24),
|
||||
child: Column(
|
||||
@@ -726,9 +795,8 @@ class _RecentPhotosSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。
|
||||
bool get _hasProcessing => photos.any(
|
||||
(p) => p.processingState == PhotoProcessingState.processing,
|
||||
);
|
||||
bool get _hasProcessing =>
|
||||
photos.any((p) => p.processingState == PhotoProcessingState.processing);
|
||||
}
|
||||
|
||||
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
|
||||
@@ -839,4 +907,3 @@ class _ErrorRetryView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user