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:
浅念
2026-09-23 10:13:08 +08:00
parent 9cb960c27a
commit 8e674a902e
31 changed files with 995 additions and 258 deletions
+5
View File
@@ -30,6 +30,11 @@ export function fetchUpdateAlbum(albumId: string, data: Api.Album.UpdatePayload)
return request.patch<Api.Album.Detail>({ url: `/v1/albums/${albumId}`, data })
}
/** 软删除相册(status=deleted:列表/公开端即刻不可见;数据保留,无恢复端点;幂等 204) */
export function fetchDeleteAlbum(albumId: string) {
return request.del<void>({ url: `/v1/albums/${albumId}` })
}
/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
return request.post<Api.Album.AssetInitResponse>({
+6 -2
View File
@@ -316,7 +316,10 @@
"viewTable": "Table view",
"loadedCount": "{n} albums loaded",
"cursorNote": "Sorted by creation time · cursor-based loading",
"emptyFiltered": "No albums match these filters"
"emptyFiltered": "No albums match these filters",
"actionDelete": "Delete",
"deleteConfirm": "Delete album \"{title}\"? It becomes invisible immediately and cannot be restored (data retained; ops-only recovery).",
"deleted": "Album deleted"
},
"form": {
"createTitle": "New Album",
@@ -385,7 +388,8 @@
"fieldOwnerId": "Owner",
"subtitle": "Album workspace · Manage the moments that matter",
"share": "Share album",
"tabAssets": "Branding assets"
"tabAssets": "Branding assets",
"delete": "Delete album"
},
"banner": {
"kind": "Asset Kind",
+6 -2
View File
@@ -316,7 +316,10 @@
"viewTable": "表格视图",
"loadedCount": "已加载 {n} 个相册",
"cursorNote": "按创建时间排序 · 游标加载",
"emptyFiltered": "没有符合筛选条件的相册"
"emptyFiltered": "没有符合筛选条件的相册",
"actionDelete": "删除",
"deleteConfirm": "确定删除相册「{title}」?删除后立即不可见且不可恢复(数据保留,仅运维可恢复)。",
"deleted": "相册已删除"
},
"form": {
"createTitle": "新建相册",
@@ -385,7 +388,8 @@
"fieldOwnerId": "负责人",
"subtitle": "相册工作区 · 管理活动中的精彩瞬间",
"share": "分享相册",
"tabAssets": "品牌资产"
"tabAssets": "品牌资产",
"delete": "删除相册"
},
"banner": {
"kind": "资产类型",
+27 -1
View File
@@ -39,6 +39,9 @@
>
{{ $t('albumsManage.detail.edit') }}
</ElButton>
<ElButton type="danger" plain data-test="album-detail-delete" @click="removeAlbum">
{{ $t('albumsManage.detail.delete') }}
</ElButton>
</div>
</div>
@@ -104,6 +107,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft } from '@element-plus/icons-vue'
import AlbumSummaryHeader from '@/components/business/albums/AlbumSummaryHeader.vue'
import AlbumBannerUpload from './AlbumBannerUpload.vue'
@@ -113,12 +117,14 @@
import AlbumWatermark from './AlbumWatermark.vue'
import AlbumShareCard from './AlbumShareCard.vue'
import AlbumTransferDialog from './AlbumTransferDialog.vue'
import { fetchAlbum } from '@/api/albums'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbum, fetchDeleteAlbum } from '@/api/albums'
defineOptions({ name: 'AlbumsDetail' })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const albumId = computed(() => String(route.params.albumId ?? ''))
@@ -152,6 +158,26 @@
return preferred?.title ?? target.translations[0]?.title ?? target.id
}
/** 软删除相册(admin/owner):确认 → DELETE → 回列表 */
async function removeAlbum() {
try {
await ElMessageBox.confirm(
t('albumsManage.list.deleteConfirm', { title: titleOf(album.value) }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteAlbum(albumId.value)
ElMessage.success(t('albumsManage.list.deleted'))
void router.replace({ name: 'AlbumsManage' })
}
/** 列表「成员」入口带 #members 锚点:直达成员页签 */
watch(
() => route.hash,
+25 -1
View File
@@ -155,6 +155,9 @@
<ElButton link type="primary" data-test="album-detail-btn" @click="gotoDetail(row)">
{{ $t('albumsManage.list.actionDetail') }}
</ElButton>
<ElButton link type="danger" data-test="album-delete-btn" @click="remove(row)">
{{ $t('albumsManage.list.actionDelete') }}
</ElButton>
</template>
</ElTableColumn>
@@ -179,7 +182,8 @@
import { useI18n } from 'vue-i18n'
import { Search, Menu, Grid } from '@element-plus/icons-vue'
import AlbumCoverCard from '@/components/business/albums/AlbumCoverCard.vue'
import { fetchAlbumList } from '@/api/albums'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbumList, fetchDeleteAlbum } from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatDateTime } from '@/utils/albums/format'
@@ -258,6 +262,26 @@
router.push({ name: 'AlbumsDetail', params: { albumId: album.id }, hash: '#members' })
}
/** 软删除:确认(不可恢复说明)→ DELETE → 重载列表 */
async function remove(album: Api.Album.Summary) {
try {
await ElMessageBox.confirm(
t('albumsManage.list.deleteConfirm', { title: titleOf(album) }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteAlbum(album.id)
ElMessage.success(t('albumsManage.list.deleted'))
loadFirst()
}
onMounted(() => {
loadFirst()
})
+81 -74
View File
@@ -33,7 +33,8 @@ class ApiClient {
String baseUrl = ApiConfig.baseUrl,
Dio? dio,
}) : _session = sessionStore {
_dio = dio ??
_dio =
dio ??
Dio(
BaseOptions(
baseUrl: baseUrl,
@@ -68,8 +69,7 @@ class ApiClient {
void close({bool force = false}) => _dio.close(force: force);
/// 登录端点:不附加令牌(未认证端点)。
static bool _isLoginPath(String path) =>
path.endsWith('/v1/auth/password');
static bool _isLoginPath(String path) => path.endsWith('/v1/auth/password');
/// 401 驱逐豁免:登录(凭据错误)与修改密码(旧密码错误)的 401 都不是
/// 会话过期语义,不得清会话/触发跳登录;其余端点 401 一律驱逐。
@@ -175,10 +175,7 @@ class ApiClient {
message: '网络超时',
);
case DioExceptionType.cancel:
return const AppException(
code: ErrorCodes.cancelled,
message: '请求已取消',
);
return const AppException(code: ErrorCodes.cancelled, message: '请求已取消');
case DioExceptionType.connectionError:
return const AppException(
code: ErrorCodes.networkError,
@@ -270,44 +267,53 @@ class ApiClient {
/// 服务端成功后撤销其他会话、**保留当前会话**——本地令牌继续有效,
/// 无需重登录。旧密码错误 → 401 AUTH_INVALID_CREDENTIALS(不驱逐会话,
/// 见 [_evictsSessionOn401])。
Future<void> changePassword(String oldPassword, String newPassword) =>
_send(
() => _dio.post<void>(
'/v1/auth/password-change',
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
),
);
Future<void> changePassword(String oldPassword, String newPassword) => _send(
() => _dio.post<void>(
'/v1/auth/password-change',
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
),
);
// ── albums / members / categories / admin ──
/// operationId `listAlbums`(GET /albums,游标分页)。
Future<CursorPage<AlbumSummary>> listAlbums({String? cursor, int? limit}) =>
_getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: 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, {
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<Map<String, dynamic>>('/v1/albums/$albumId/assets', data: {
'kind': kind,
'filename': filename,
'bytes': bytes,
'contentType': contentType,
}),
() => _dio.post<Map<String, dynamic>>(
'/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<String, dynamic>),
credential: UploadCredential.fromJson(
data['credential'] as Map<String, dynamic>,
),
);
}
@@ -324,23 +330,27 @@ class ApiClient {
/// 一步上传相册封面:选图 → presign → PUT → complete。
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
Future<String> uploadAlbumCoverFromFile(String albumId, String filePath) async {
Future<String> 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 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,
}),
options: Options(
headers: {...init.credential.headers, 'Content-Length': bytes},
),
);
if (putRes.statusCode != 200 && putRes.statusCode != 204) {
throw Exception('cover PUT failed: ${putRes.statusCode}');
@@ -385,6 +395,13 @@ class ApiClient {
return Album.fromJson(resp.data!);
}
/// operationId `deleteAlbum`(DELETE /albums/{albumId} → 204 软删除)。
///
/// status=deleted:列表/公开端即刻不可见;数据保留,无恢复端点。
Future<void> deleteAlbum(String albumId) async {
await _send(() => _dio.delete<dynamic>('/v1/albums/$albumId'));
}
/// operationId `addAlbumMember`(POST /albums/{albumId}/members → 201)。
///
/// role 省略:契约缺省 collaborator(P1a 唯一可添加角色)。
@@ -403,13 +420,12 @@ class ApiClient {
String albumId, {
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/albums/$albumId/members',
Member.fromJson,
cursor: cursor,
limit: limit,
);
}) => _getPage(
'/v1/albums/$albumId/members',
Member.fromJson,
cursor: cursor,
limit: limit,
);
/// operationId `removeAlbumMember`
/// (DELETE /albums/{albumId}/members/{userId} → 204,目标不存在幂等)。
@@ -425,18 +441,14 @@ class ApiClient {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>(
'/v1/admin/users',
queryParameters: {
'accountPrefix': accountPrefix,
'limit': ?limit,
},
queryParameters: {'accountPrefix': accountPrefix, 'limit': ?limit},
),
);
final items = resp.data!['items'] as List<dynamic>;
return items
.map(
(dynamic e) => AdminUserLookupItem.fromJson(
e as Map<String, dynamic>,
),
(dynamic e) =>
AdminUserLookupItem.fromJson(e as Map<String, dynamic>),
)
.toList();
}
@@ -506,13 +518,12 @@ class ApiClient {
Future<CursorPage<UploadSessionRecord>> listUploads({
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/uploads',
UploadSessionRecord.fromJson,
cursor: cursor,
limit: limit,
);
}) => _getPage(
'/v1/uploads',
UploadSessionRecord.fromJson,
cursor: cursor,
limit: limit,
);
// ── photos(契约 tag: photos)──
@@ -529,18 +540,17 @@ class ApiClient {
AlbumPhotoScope? scope,
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/albums/$albumId/photos',
AdminPhoto.fromJson,
cursor: cursor,
limit: limit,
extraQuery: {
if (visibility != null) 'visibility': visibility.wire,
'categoryId': ?categoryId,
if (scope != null) 'scope': scope.wire,
},
);
}) => _getPage(
'/v1/albums/$albumId/photos',
AdminPhoto.fromJson,
cursor: cursor,
limit: limit,
extraQuery: {
if (visibility != null) 'visibility': visibility.wire,
'categoryId': ?categoryId,
if (scope != null) 'scope': scope.wire,
},
);
/// operationId `updatePhoto`(PATCH /photos/{photoId}):仅允许
/// hidden ↔ published 切换,非法取值本地即抛 ArgumentError。
@@ -582,11 +592,7 @@ class ApiClient {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>(
path,
queryParameters: {
'cursor': ?cursor,
'limit': ?limit,
...?extraQuery,
},
queryParameters: {'cursor': ?cursor, 'limit': ?limit, ...?extraQuery},
),
);
return CursorPage.parse(resp.data!, fromJson);
@@ -594,8 +600,9 @@ class ApiClient {
}
/// 会话存储 Provider(生产实现:flutter_secure_storage)。
final sessionStoreProvider =
Provider<SessionStore>((ref) => SecureSessionStore());
final sessionStoreProvider = Provider<SessionStore>(
(ref) => SecureSessionStore(),
);
/// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized]
/// 跳转 /login(命名路由,见 lib/app/app.dart)。
+113 -16
View File
@@ -32,7 +32,13 @@
"navUploads": "Uploads",
"navSettings": "Settings",
"badgeUploading": "{count} tasks",
"@badgeUploading": { "placeholders": { "count": { "type": "int" } } },
"@badgeUploading": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"albumUploadsOn": "Uploads on",
"albumUploadsOff": "Uploads off",
"albumPublicOff": "Not public",
@@ -116,7 +122,13 @@
"actionEdit": "Edit",
"membersTitle": "Members",
"membersTitleCount": "Members ({count})",
"@membersTitleCount": { "placeholders": { "count": { "type": "int" } } },
"@membersTitleCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"ownerGroup": "Owner",
"collaboratorsGroup": "Collaborators",
"noCollaborators": "No collaborators yet",
@@ -127,7 +139,13 @@
"newCategory": "New category",
"editCategory": "Edit category",
"deleteCategoryConfirm": "Delete \"{name}\"? Its photos will become uncategorized.",
"@deleteCategoryConfirm": { "placeholders": { "name": { "type": "String" } } },
"@deleteCategoryConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"noCategories": "No categories yet",
"albumCategories": "Categories",
"shareQrTitle": "Share QR code",
@@ -143,7 +161,13 @@
"memberInvited": "Invited",
"removeMember": "Remove member",
"removeMemberConfirm": "Remove {name} from this album?",
"@removeMemberConfirm": { "placeholders": { "name": { "type": "String" } } },
"@removeMemberConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"roleOwner": "Owner",
"roleCollaborator": "Collaborator",
"selfMarker": " (you)",
@@ -183,7 +207,13 @@
"queueSummaryWaiting": "waiting",
"queueSummaryNeedAttention": "need attention",
"queueRowUploadingPercent": "Uploading {percent}%",
"@queueRowUploadingPercent": { "placeholders": { "percent": { "type": "int" } } },
"@queueRowUploadingPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"queueRowConfirming": "Confirming upload",
"queueRowWaitingUpload": "Waiting to upload",
"queueRowPreparing": "Preparing photo",
@@ -202,9 +232,21 @@
"queueEmpty": "No upload tasks",
"queueRetryAllFailed": "Retry all failed",
"queueAttemptCount": "{count} attempt(s)",
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
"@queueAttemptCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureQueued": "{count} queued",
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
"@captureQueued": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureFlipCamera": "Switch camera",
"captureFlash": "Flash",
"uploadsHistory": "History",
@@ -255,17 +297,41 @@
"captureCategoryDefault": "Default category",
"captureSwitchHint": "Switching only affects photos captured afterwards",
"capturePendingBadge": "Pending {count}",
"@capturePendingBadge": { "placeholders": { "count": { "type": "int" } } },
"@capturePendingBadge": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureFailedBadge": "Failed {count}",
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
"@captureFailedBadge": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"capturePickFromGallery": "Pick from gallery",
"captureShutter": "Capture",
"captureQueuedBadge": "Queued {count}",
"@captureQueuedBadge": { "placeholders": { "count": { "type": "int" } } },
"@captureQueuedBadge": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureEnqueuedFeedback": "Added to queue",
"captureExitTitle": "Leave shooting?",
"captureExitBody": "{count} photo(s) are still uploading. Tasks will continue in the background after you leave.",
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
"@captureExitBody": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureKeepShooting": "Keep shooting",
"captureExitConfirm": "Leave",
"cameraPermissionDenied": "Camera unavailable. Allow camera access in system settings",
@@ -274,7 +340,13 @@
"uploadActionPick": "Pick from gallery",
"uploadAction": "Upload",
"uploadEnqueuedCount": "Added {count} photo(s) to the upload queue",
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
"@uploadEnqueuedCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"uploadPickNone": "No photos selected",
"albumManagePhotos": "Manage photos",
"albumTakeFirstPhoto": "Take the first photo",
@@ -309,16 +381,41 @@
"readonlyMember": "You can browse photos shared by others",
"moreActions": "More actions",
"photosTitle": "{count} photos",
"@photosTitle": { "placeholders": { "count": { "type": "int" } } },
"@photosTitle": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"photoUploadedBy": "Uploaded by {name}",
"@photoUploadedBy": { "placeholders": { "name": { "type": "String" } } },
"@photoUploadedBy": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"photoDimensions": "{width}×{height}",
"@photoDimensions": { "placeholders": { "width": { "type": "int" }, "height": { "type": "int" } } },
"@photoDimensions": {
"placeholders": {
"width": {
"type": "int"
},
"height": {
"type": "int"
}
}
},
"photoStatus": "Status",
"photoHideAction": "Hide photo",
"photoPublishAction": "Publish again",
"shareScanHint": "Scan to view photos",
"shareNotPublic": "This link is not publicly accessible yet",
"hidePhotoTitle": "Hide this photo?",
"hidePhotoBody": "Viewers will no longer see this photo. Authorized album managers can publish it again."
"hidePhotoBody": "Viewers will no longer see this photo. Authorized album managers can publish it again.",
"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"
}
@@ -1933,6 +1933,30 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'观众将不再看到这张照片。相册管理者可重新发布。'**
String get hidePhotoBody;
/// No description provided for @deleteAlbum.
///
/// In zh, this message translates to:
/// **'删除相册'**
String get deleteAlbum;
/// No description provided for @deleteAlbumConfirmTitle.
///
/// In zh, this message translates to:
/// **'删除这个相册?'**
String get deleteAlbumConfirmTitle;
/// No description provided for @deleteAlbumConfirmBody.
///
/// In zh, this message translates to:
/// **'删除后立即不可见且不可恢复。数据保留,恢复需运维处理。'**
String get deleteAlbumConfirmBody;
/// No description provided for @albumDeleted.
///
/// In zh, this message translates to:
/// **'相册已删除'**
String get albumDeleted;
}
class _AppLocalizationsDelegate
@@ -987,4 +987,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get hidePhotoBody =>
'Viewers will no longer see this photo. Authorized album managers can publish it again.';
@override
String get deleteAlbum => 'Delete album';
@override
String get deleteAlbumConfirmTitle => 'Delete this album?';
@override
String get deleteAlbumConfirmBody =>
'The album becomes invisible immediately and cannot be restored. Data is retained; recovery requires operations.';
@override
String get albumDeleted => 'Album deleted';
}
@@ -956,4 +956,16 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get hidePhotoBody => '观众将不再看到这张照片。相册管理者可重新发布。';
@override
String get deleteAlbum => '删除相册';
@override
String get deleteAlbumConfirmTitle => '删除这个相册?';
@override
String get deleteAlbumConfirmBody => '删除后立即不可见且不可恢复。数据保留,恢复需运维处理。';
@override
String get albumDeleted => '相册已删除';
}
+113 -16
View File
@@ -32,7 +32,13 @@
"navUploads": "上传",
"navSettings": "设置",
"badgeUploading": "{count} 个任务",
"@badgeUploading": { "placeholders": { "count": { "type": "int" } } },
"@badgeUploading": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"albumUploadsOn": "开放上传",
"albumUploadsOff": "上传已关闭",
"albumPublicOff": "未公开",
@@ -116,7 +122,13 @@
"actionEdit": "编辑",
"membersTitle": "成员",
"membersTitleCount": "成员({count})",
"@membersTitleCount": { "placeholders": { "count": { "type": "int" } } },
"@membersTitleCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"ownerGroup": "创建者",
"collaboratorsGroup": "协作者",
"noCollaborators": "还没有协作者",
@@ -127,7 +139,13 @@
"newCategory": "新增分类",
"editCategory": "编辑分类",
"deleteCategoryConfirm": "删除「{name}」?该分类下的照片将变为未分类。",
"@deleteCategoryConfirm": { "placeholders": { "name": { "type": "String" } } },
"@deleteCategoryConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"noCategories": "暂无分类",
"albumCategories": "分类管理",
"shareQrTitle": "分享二维码",
@@ -143,7 +161,13 @@
"memberInvited": "已邀请",
"removeMember": "移除成员",
"removeMemberConfirm": "确定将 {name} 从该相册移除?",
"@removeMemberConfirm": { "placeholders": { "name": { "type": "String" } } },
"@removeMemberConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"roleOwner": "创建者",
"roleCollaborator": "协作者",
"selfMarker": "(我)",
@@ -183,7 +207,13 @@
"queueSummaryWaiting": "等待中",
"queueSummaryNeedAttention": "需关注",
"queueRowUploadingPercent": "上传中 {percent}%",
"@queueRowUploadingPercent": { "placeholders": { "percent": { "type": "int" } } },
"@queueRowUploadingPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"queueRowConfirming": "正在确认上传",
"queueRowWaitingUpload": "等待上传",
"queueRowPreparing": "正在准备照片",
@@ -202,9 +232,21 @@
"queueEmpty": "暂无上传任务",
"queueRetryAllFailed": "一键重试全部失败",
"queueAttemptCount": "已尝试 {count} 次",
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
"@queueAttemptCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureQueued": "{count} 张待传",
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
"@captureQueued": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureFlipCamera": "切换前后摄像头",
"captureFlash": "闪光灯",
"uploadsHistory": "历史记录",
@@ -255,17 +297,41 @@
"captureCategoryDefault": "默认分类",
"captureSwitchHint": "切换分类仅影响之后拍摄的照片",
"capturePendingBadge": "待传 {count}",
"@capturePendingBadge": { "placeholders": { "count": { "type": "int" } } },
"@capturePendingBadge": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureFailedBadge": "失败 {count}",
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
"@captureFailedBadge": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"capturePickFromGallery": "从相册选择",
"captureShutter": "拍摄",
"captureQueuedBadge": "待传 {count}",
"@captureQueuedBadge": { "placeholders": { "count": { "type": "int" } } },
"@captureQueuedBadge": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureEnqueuedFeedback": "已加入队列",
"captureExitTitle": "退出拍摄?",
"captureExitBody": "还有 {count} 张照片未上传,退出后任务将在后台继续。",
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
"@captureExitBody": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"captureKeepShooting": "继续拍摄",
"captureExitConfirm": "退出",
"cameraPermissionDenied": "无法访问相机,请在系统设置中允许相机权限",
@@ -274,7 +340,13 @@
"uploadActionPick": "从相册选择",
"uploadAction": "上传",
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
"@uploadEnqueuedCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"uploadPickNone": "未选择照片",
"albumManagePhotos": "管理照片",
"albumTakeFirstPhoto": "拍摄第一张照片",
@@ -309,16 +381,41 @@
"readonlyMember": "您可以浏览其他人分享的照片",
"moreActions": "更多操作",
"photosTitle": "{count} 张照片",
"@photosTitle": { "placeholders": { "count": { "type": "int" } } },
"@photosTitle": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"photoUploadedBy": "上传者 {name}",
"@photoUploadedBy": { "placeholders": { "name": { "type": "String" } } },
"@photoUploadedBy": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"photoDimensions": "{width}×{height}",
"@photoDimensions": { "placeholders": { "width": { "type": "int" }, "height": { "type": "int" } } },
"@photoDimensions": {
"placeholders": {
"width": {
"type": "int"
},
"height": {
"type": "int"
}
}
},
"photoStatus": "状态",
"photoHideAction": "隐藏照片",
"photoPublishAction": "重新发布",
"shareScanHint": "扫码查看照片",
"shareNotPublic": "该链接目前无法公开浏览",
"hidePhotoTitle": "隐藏这张照片?",
"hidePhotoBody": "观众将不再看到这张照片。相册管理者可重新发布。"
"hidePhotoBody": "观众将不再看到这张照片。相册管理者可重新发布。",
"deleteAlbum": "删除相册",
"deleteAlbumConfirmTitle": "删除这个相册?",
"deleteAlbumConfirmBody": "删除后立即不可见且不可恢复。数据保留,恢复需运维处理。",
"albumDeleted": "相册已删除"
}
+174 -107
View File
@@ -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 {
);
}
}