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:
@@ -30,6 +30,11 @@ export function fetchUpdateAlbum(albumId: string, data: Api.Album.UpdatePayload)
|
|||||||
return request.patch<Api.Album.Detail>({ url: `/v1/albums/${albumId}`, data })
|
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 直传凭证) */
|
/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */
|
||||||
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
|
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
|
||||||
return request.post<Api.Album.AssetInitResponse>({
|
return request.post<Api.Album.AssetInitResponse>({
|
||||||
|
|||||||
@@ -316,7 +316,10 @@
|
|||||||
"viewTable": "Table view",
|
"viewTable": "Table view",
|
||||||
"loadedCount": "{n} albums loaded",
|
"loadedCount": "{n} albums loaded",
|
||||||
"cursorNote": "Sorted by creation time · cursor-based loading",
|
"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": {
|
"form": {
|
||||||
"createTitle": "New Album",
|
"createTitle": "New Album",
|
||||||
@@ -385,7 +388,8 @@
|
|||||||
"fieldOwnerId": "Owner",
|
"fieldOwnerId": "Owner",
|
||||||
"subtitle": "Album workspace · Manage the moments that matter",
|
"subtitle": "Album workspace · Manage the moments that matter",
|
||||||
"share": "Share album",
|
"share": "Share album",
|
||||||
"tabAssets": "Branding assets"
|
"tabAssets": "Branding assets",
|
||||||
|
"delete": "Delete album"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"kind": "Asset Kind",
|
"kind": "Asset Kind",
|
||||||
|
|||||||
@@ -316,7 +316,10 @@
|
|||||||
"viewTable": "表格视图",
|
"viewTable": "表格视图",
|
||||||
"loadedCount": "已加载 {n} 个相册",
|
"loadedCount": "已加载 {n} 个相册",
|
||||||
"cursorNote": "按创建时间排序 · 游标加载",
|
"cursorNote": "按创建时间排序 · 游标加载",
|
||||||
"emptyFiltered": "没有符合筛选条件的相册"
|
"emptyFiltered": "没有符合筛选条件的相册",
|
||||||
|
"actionDelete": "删除",
|
||||||
|
"deleteConfirm": "确定删除相册「{title}」?删除后立即不可见且不可恢复(数据保留,仅运维可恢复)。",
|
||||||
|
"deleted": "相册已删除"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"createTitle": "新建相册",
|
"createTitle": "新建相册",
|
||||||
@@ -385,7 +388,8 @@
|
|||||||
"fieldOwnerId": "负责人",
|
"fieldOwnerId": "负责人",
|
||||||
"subtitle": "相册工作区 · 管理活动中的精彩瞬间",
|
"subtitle": "相册工作区 · 管理活动中的精彩瞬间",
|
||||||
"share": "分享相册",
|
"share": "分享相册",
|
||||||
"tabAssets": "品牌资产"
|
"tabAssets": "品牌资产",
|
||||||
|
"delete": "删除相册"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"kind": "资产类型",
|
"kind": "资产类型",
|
||||||
|
|||||||
@@ -39,6 +39,9 @@
|
|||||||
>
|
>
|
||||||
{{ $t('albumsManage.detail.edit') }}
|
{{ $t('albumsManage.detail.edit') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
|
<ElButton type="danger" plain data-test="album-detail-delete" @click="removeAlbum">
|
||||||
|
{{ $t('albumsManage.detail.delete') }}
|
||||||
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,6 +107,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
import AlbumSummaryHeader from '@/components/business/albums/AlbumSummaryHeader.vue'
|
import AlbumSummaryHeader from '@/components/business/albums/AlbumSummaryHeader.vue'
|
||||||
import AlbumBannerUpload from './AlbumBannerUpload.vue'
|
import AlbumBannerUpload from './AlbumBannerUpload.vue'
|
||||||
@@ -113,12 +117,14 @@
|
|||||||
import AlbumWatermark from './AlbumWatermark.vue'
|
import AlbumWatermark from './AlbumWatermark.vue'
|
||||||
import AlbumShareCard from './AlbumShareCard.vue'
|
import AlbumShareCard from './AlbumShareCard.vue'
|
||||||
import AlbumTransferDialog from './AlbumTransferDialog.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' })
|
defineOptions({ name: 'AlbumsDetail' })
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const albumId = computed(() => String(route.params.albumId ?? ''))
|
const albumId = computed(() => String(route.params.albumId ?? ''))
|
||||||
|
|
||||||
@@ -152,6 +158,26 @@
|
|||||||
return preferred?.title ?? target.translations[0]?.title ?? target.id
|
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 锚点:直达成员页签 */
|
/** 列表「成员」入口带 #members 锚点:直达成员页签 */
|
||||||
watch(
|
watch(
|
||||||
() => route.hash,
|
() => route.hash,
|
||||||
|
|||||||
@@ -155,6 +155,9 @@
|
|||||||
<ElButton link type="primary" data-test="album-detail-btn" @click="gotoDetail(row)">
|
<ElButton link type="primary" data-test="album-detail-btn" @click="gotoDetail(row)">
|
||||||
{{ $t('albumsManage.list.actionDetail') }}
|
{{ $t('albumsManage.list.actionDetail') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
|
<ElButton link type="danger" data-test="album-delete-btn" @click="remove(row)">
|
||||||
|
{{ $t('albumsManage.list.actionDelete') }}
|
||||||
|
</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
|
||||||
@@ -179,7 +182,8 @@
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Search, Menu, Grid } from '@element-plus/icons-vue'
|
import { Search, Menu, Grid } from '@element-plus/icons-vue'
|
||||||
import AlbumCoverCard from '@/components/business/albums/AlbumCoverCard.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 { useCursorList } from '@/views/albums/composables/useCursorList'
|
||||||
import { formatDateTime } from '@/utils/albums/format'
|
import { formatDateTime } from '@/utils/albums/format'
|
||||||
|
|
||||||
@@ -258,6 +262,26 @@
|
|||||||
router.push({ name: 'AlbumsDetail', params: { albumId: album.id }, hash: '#members' })
|
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(() => {
|
onMounted(() => {
|
||||||
loadFirst()
|
loadFirst()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ class ApiClient {
|
|||||||
String baseUrl = ApiConfig.baseUrl,
|
String baseUrl = ApiConfig.baseUrl,
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _session = sessionStore {
|
}) : _session = sessionStore {
|
||||||
_dio = dio ??
|
_dio =
|
||||||
|
dio ??
|
||||||
Dio(
|
Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
@@ -68,8 +69,7 @@ class ApiClient {
|
|||||||
void close({bool force = false}) => _dio.close(force: force);
|
void close({bool force = false}) => _dio.close(force: force);
|
||||||
|
|
||||||
/// 登录端点:不附加令牌(未认证端点)。
|
/// 登录端点:不附加令牌(未认证端点)。
|
||||||
static bool _isLoginPath(String path) =>
|
static bool _isLoginPath(String path) => path.endsWith('/v1/auth/password');
|
||||||
path.endsWith('/v1/auth/password');
|
|
||||||
|
|
||||||
/// 401 驱逐豁免:登录(凭据错误)与修改密码(旧密码错误)的 401 都不是
|
/// 401 驱逐豁免:登录(凭据错误)与修改密码(旧密码错误)的 401 都不是
|
||||||
/// 会话过期语义,不得清会话/触发跳登录;其余端点 401 一律驱逐。
|
/// 会话过期语义,不得清会话/触发跳登录;其余端点 401 一律驱逐。
|
||||||
@@ -175,10 +175,7 @@ class ApiClient {
|
|||||||
message: '网络超时',
|
message: '网络超时',
|
||||||
);
|
);
|
||||||
case DioExceptionType.cancel:
|
case DioExceptionType.cancel:
|
||||||
return const AppException(
|
return const AppException(code: ErrorCodes.cancelled, message: '请求已取消');
|
||||||
code: ErrorCodes.cancelled,
|
|
||||||
message: '请求已取消',
|
|
||||||
);
|
|
||||||
case DioExceptionType.connectionError:
|
case DioExceptionType.connectionError:
|
||||||
return const AppException(
|
return const AppException(
|
||||||
code: ErrorCodes.networkError,
|
code: ErrorCodes.networkError,
|
||||||
@@ -270,44 +267,53 @@ class ApiClient {
|
|||||||
/// 服务端成功后撤销其他会话、**保留当前会话**——本地令牌继续有效,
|
/// 服务端成功后撤销其他会话、**保留当前会话**——本地令牌继续有效,
|
||||||
/// 无需重登录。旧密码错误 → 401 AUTH_INVALID_CREDENTIALS(不驱逐会话,
|
/// 无需重登录。旧密码错误 → 401 AUTH_INVALID_CREDENTIALS(不驱逐会话,
|
||||||
/// 见 [_evictsSessionOn401])。
|
/// 见 [_evictsSessionOn401])。
|
||||||
Future<void> changePassword(String oldPassword, String newPassword) =>
|
Future<void> changePassword(String oldPassword, String newPassword) => _send(
|
||||||
_send(
|
() => _dio.post<void>(
|
||||||
() => _dio.post<void>(
|
'/v1/auth/password-change',
|
||||||
'/v1/auth/password-change',
|
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
|
||||||
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// ── albums / members / categories / admin ──
|
// ── albums / members / categories / admin ──
|
||||||
|
|
||||||
/// operationId `listAlbums`(GET /albums,游标分页)。
|
/// operationId `listAlbums`(GET /albums,游标分页)。
|
||||||
Future<CursorPage<AlbumSummary>> listAlbums({String? cursor, int? limit}) =>
|
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)。
|
/// 资产上传凭证(契约 AssetInitResponse)。
|
||||||
/// kind:cover | banner | logo;凭据结构与照片 presign 相同。
|
/// kind:cover | banner | logo;凭据结构与照片 presign 相同。
|
||||||
static const assetKindCover = 'cover';
|
static const assetKindCover = 'cover';
|
||||||
static const assetKindBanner = 'banner';
|
static const assetKindBanner = 'banner';
|
||||||
|
|
||||||
Future<({String assetId, UploadCredential credential})>
|
Future<({String assetId, UploadCredential credential})> initAlbumAsset(
|
||||||
initAlbumAsset(String albumId, {
|
String albumId, {
|
||||||
required String kind,
|
required String kind,
|
||||||
required String filename,
|
required String filename,
|
||||||
required int bytes,
|
required int bytes,
|
||||||
required String contentType,
|
required String contentType,
|
||||||
}) async {
|
}) async {
|
||||||
final resp = await _send(
|
final resp = await _send(
|
||||||
() => _dio.post<Map<String, dynamic>>('/v1/albums/$albumId/assets', data: {
|
() => _dio.post<Map<String, dynamic>>(
|
||||||
'kind': kind,
|
'/v1/albums/$albumId/assets',
|
||||||
'filename': filename,
|
data: {
|
||||||
'bytes': bytes,
|
'kind': kind,
|
||||||
'contentType': contentType,
|
'filename': filename,
|
||||||
}),
|
'bytes': bytes,
|
||||||
|
'contentType': contentType,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final data = resp.data!;
|
final data = resp.data!;
|
||||||
return (
|
return (
|
||||||
assetId: data['assetId'] as String,
|
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。
|
/// 一步上传相册封面:选图 → presign → PUT → complete。
|
||||||
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
|
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
|
||||||
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
|
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
|
||||||
Future<String> uploadAlbumCoverFromFile(String albumId, String filePath) async {
|
Future<String> uploadAlbumCoverFromFile(
|
||||||
|
String albumId,
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
final bytes = await file.length();
|
final bytes = await file.length();
|
||||||
final filename = filePath.split(Platform.pathSeparator).last;
|
final filename = filePath.split(Platform.pathSeparator).last;
|
||||||
final contentType = _guessContentType(filename);
|
final contentType = _guessContentType(filename);
|
||||||
final init = await initAlbumAsset(albumId,
|
final init = await initAlbumAsset(
|
||||||
kind: assetKindCover,
|
albumId,
|
||||||
filename: filename,
|
kind: assetKindCover,
|
||||||
bytes: bytes,
|
filename: filename,
|
||||||
contentType: contentType);
|
bytes: bytes,
|
||||||
|
contentType: contentType,
|
||||||
|
);
|
||||||
final putRes = await Dio().put(
|
final putRes = await Dio().put(
|
||||||
init.credential.url,
|
init.credential.url,
|
||||||
data: file.openRead(),
|
data: file.openRead(),
|
||||||
options: Options(headers: {
|
options: Options(
|
||||||
...init.credential.headers,
|
headers: {...init.credential.headers, 'Content-Length': bytes},
|
||||||
'Content-Length': bytes,
|
),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
if (putRes.statusCode != 200 && putRes.statusCode != 204) {
|
if (putRes.statusCode != 200 && putRes.statusCode != 204) {
|
||||||
throw Exception('cover PUT failed: ${putRes.statusCode}');
|
throw Exception('cover PUT failed: ${putRes.statusCode}');
|
||||||
@@ -385,6 +395,13 @@ class ApiClient {
|
|||||||
return Album.fromJson(resp.data!);
|
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)。
|
/// operationId `addAlbumMember`(POST /albums/{albumId}/members → 201)。
|
||||||
///
|
///
|
||||||
/// role 省略:契约缺省 collaborator(P1a 唯一可添加角色)。
|
/// role 省略:契约缺省 collaborator(P1a 唯一可添加角色)。
|
||||||
@@ -403,13 +420,12 @@ class ApiClient {
|
|||||||
String albumId, {
|
String albumId, {
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int? limit,
|
int? limit,
|
||||||
}) =>
|
}) => _getPage(
|
||||||
_getPage(
|
'/v1/albums/$albumId/members',
|
||||||
'/v1/albums/$albumId/members',
|
Member.fromJson,
|
||||||
Member.fromJson,
|
cursor: cursor,
|
||||||
cursor: cursor,
|
limit: limit,
|
||||||
limit: limit,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
/// operationId `removeAlbumMember`
|
/// operationId `removeAlbumMember`
|
||||||
/// (DELETE /albums/{albumId}/members/{userId} → 204,目标不存在幂等)。
|
/// (DELETE /albums/{albumId}/members/{userId} → 204,目标不存在幂等)。
|
||||||
@@ -425,18 +441,14 @@ class ApiClient {
|
|||||||
final resp = await _send(
|
final resp = await _send(
|
||||||
() => _dio.get<Map<String, dynamic>>(
|
() => _dio.get<Map<String, dynamic>>(
|
||||||
'/v1/admin/users',
|
'/v1/admin/users',
|
||||||
queryParameters: {
|
queryParameters: {'accountPrefix': accountPrefix, 'limit': ?limit},
|
||||||
'accountPrefix': accountPrefix,
|
|
||||||
'limit': ?limit,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final items = resp.data!['items'] as List<dynamic>;
|
final items = resp.data!['items'] as List<dynamic>;
|
||||||
return items
|
return items
|
||||||
.map(
|
.map(
|
||||||
(dynamic e) => AdminUserLookupItem.fromJson(
|
(dynamic e) =>
|
||||||
e as Map<String, dynamic>,
|
AdminUserLookupItem.fromJson(e as Map<String, dynamic>),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -506,13 +518,12 @@ class ApiClient {
|
|||||||
Future<CursorPage<UploadSessionRecord>> listUploads({
|
Future<CursorPage<UploadSessionRecord>> listUploads({
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int? limit,
|
int? limit,
|
||||||
}) =>
|
}) => _getPage(
|
||||||
_getPage(
|
'/v1/uploads',
|
||||||
'/v1/uploads',
|
UploadSessionRecord.fromJson,
|
||||||
UploadSessionRecord.fromJson,
|
cursor: cursor,
|
||||||
cursor: cursor,
|
limit: limit,
|
||||||
limit: limit,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// ── photos(契约 tag: photos)──
|
// ── photos(契约 tag: photos)──
|
||||||
|
|
||||||
@@ -529,18 +540,17 @@ class ApiClient {
|
|||||||
AlbumPhotoScope? scope,
|
AlbumPhotoScope? scope,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int? limit,
|
int? limit,
|
||||||
}) =>
|
}) => _getPage(
|
||||||
_getPage(
|
'/v1/albums/$albumId/photos',
|
||||||
'/v1/albums/$albumId/photos',
|
AdminPhoto.fromJson,
|
||||||
AdminPhoto.fromJson,
|
cursor: cursor,
|
||||||
cursor: cursor,
|
limit: limit,
|
||||||
limit: limit,
|
extraQuery: {
|
||||||
extraQuery: {
|
if (visibility != null) 'visibility': visibility.wire,
|
||||||
if (visibility != null) 'visibility': visibility.wire,
|
'categoryId': ?categoryId,
|
||||||
'categoryId': ?categoryId,
|
if (scope != null) 'scope': scope.wire,
|
||||||
if (scope != null) 'scope': scope.wire,
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
/// operationId `updatePhoto`(PATCH /photos/{photoId}):仅允许
|
/// operationId `updatePhoto`(PATCH /photos/{photoId}):仅允许
|
||||||
/// hidden ↔ published 切换,非法取值本地即抛 ArgumentError。
|
/// hidden ↔ published 切换,非法取值本地即抛 ArgumentError。
|
||||||
@@ -582,11 +592,7 @@ class ApiClient {
|
|||||||
final resp = await _send(
|
final resp = await _send(
|
||||||
() => _dio.get<Map<String, dynamic>>(
|
() => _dio.get<Map<String, dynamic>>(
|
||||||
path,
|
path,
|
||||||
queryParameters: {
|
queryParameters: {'cursor': ?cursor, 'limit': ?limit, ...?extraQuery},
|
||||||
'cursor': ?cursor,
|
|
||||||
'limit': ?limit,
|
|
||||||
...?extraQuery,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return CursorPage.parse(resp.data!, fromJson);
|
return CursorPage.parse(resp.data!, fromJson);
|
||||||
@@ -594,8 +600,9 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 会话存储 Provider(生产实现:flutter_secure_storage)。
|
/// 会话存储 Provider(生产实现:flutter_secure_storage)。
|
||||||
final sessionStoreProvider =
|
final sessionStoreProvider = Provider<SessionStore>(
|
||||||
Provider<SessionStore>((ref) => SecureSessionStore());
|
(ref) => SecureSessionStore(),
|
||||||
|
);
|
||||||
|
|
||||||
/// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized]
|
/// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized]
|
||||||
/// 跳转 /login(命名路由,见 lib/app/app.dart)。
|
/// 跳转 /login(命名路由,见 lib/app/app.dart)。
|
||||||
|
|||||||
+113
-16
@@ -32,7 +32,13 @@
|
|||||||
"navUploads": "Uploads",
|
"navUploads": "Uploads",
|
||||||
"navSettings": "Settings",
|
"navSettings": "Settings",
|
||||||
"badgeUploading": "{count} tasks",
|
"badgeUploading": "{count} tasks",
|
||||||
"@badgeUploading": { "placeholders": { "count": { "type": "int" } } },
|
"@badgeUploading": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"albumUploadsOn": "Uploads on",
|
"albumUploadsOn": "Uploads on",
|
||||||
"albumUploadsOff": "Uploads off",
|
"albumUploadsOff": "Uploads off",
|
||||||
"albumPublicOff": "Not public",
|
"albumPublicOff": "Not public",
|
||||||
@@ -116,7 +122,13 @@
|
|||||||
"actionEdit": "Edit",
|
"actionEdit": "Edit",
|
||||||
"membersTitle": "Members",
|
"membersTitle": "Members",
|
||||||
"membersTitleCount": "Members ({count})",
|
"membersTitleCount": "Members ({count})",
|
||||||
"@membersTitleCount": { "placeholders": { "count": { "type": "int" } } },
|
"@membersTitleCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ownerGroup": "Owner",
|
"ownerGroup": "Owner",
|
||||||
"collaboratorsGroup": "Collaborators",
|
"collaboratorsGroup": "Collaborators",
|
||||||
"noCollaborators": "No collaborators yet",
|
"noCollaborators": "No collaborators yet",
|
||||||
@@ -127,7 +139,13 @@
|
|||||||
"newCategory": "New category",
|
"newCategory": "New category",
|
||||||
"editCategory": "Edit category",
|
"editCategory": "Edit category",
|
||||||
"deleteCategoryConfirm": "Delete \"{name}\"? Its photos will become uncategorized.",
|
"deleteCategoryConfirm": "Delete \"{name}\"? Its photos will become uncategorized.",
|
||||||
"@deleteCategoryConfirm": { "placeholders": { "name": { "type": "String" } } },
|
"@deleteCategoryConfirm": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"noCategories": "No categories yet",
|
"noCategories": "No categories yet",
|
||||||
"albumCategories": "Categories",
|
"albumCategories": "Categories",
|
||||||
"shareQrTitle": "Share QR code",
|
"shareQrTitle": "Share QR code",
|
||||||
@@ -143,7 +161,13 @@
|
|||||||
"memberInvited": "Invited",
|
"memberInvited": "Invited",
|
||||||
"removeMember": "Remove member",
|
"removeMember": "Remove member",
|
||||||
"removeMemberConfirm": "Remove {name} from this album?",
|
"removeMemberConfirm": "Remove {name} from this album?",
|
||||||
"@removeMemberConfirm": { "placeholders": { "name": { "type": "String" } } },
|
"@removeMemberConfirm": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"roleOwner": "Owner",
|
"roleOwner": "Owner",
|
||||||
"roleCollaborator": "Collaborator",
|
"roleCollaborator": "Collaborator",
|
||||||
"selfMarker": " (you)",
|
"selfMarker": " (you)",
|
||||||
@@ -183,7 +207,13 @@
|
|||||||
"queueSummaryWaiting": "waiting",
|
"queueSummaryWaiting": "waiting",
|
||||||
"queueSummaryNeedAttention": "need attention",
|
"queueSummaryNeedAttention": "need attention",
|
||||||
"queueRowUploadingPercent": "Uploading {percent}%",
|
"queueRowUploadingPercent": "Uploading {percent}%",
|
||||||
"@queueRowUploadingPercent": { "placeholders": { "percent": { "type": "int" } } },
|
"@queueRowUploadingPercent": {
|
||||||
|
"placeholders": {
|
||||||
|
"percent": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"queueRowConfirming": "Confirming upload",
|
"queueRowConfirming": "Confirming upload",
|
||||||
"queueRowWaitingUpload": "Waiting to upload",
|
"queueRowWaitingUpload": "Waiting to upload",
|
||||||
"queueRowPreparing": "Preparing photo",
|
"queueRowPreparing": "Preparing photo",
|
||||||
@@ -202,9 +232,21 @@
|
|||||||
"queueEmpty": "No upload tasks",
|
"queueEmpty": "No upload tasks",
|
||||||
"queueRetryAllFailed": "Retry all failed",
|
"queueRetryAllFailed": "Retry all failed",
|
||||||
"queueAttemptCount": "{count} attempt(s)",
|
"queueAttemptCount": "{count} attempt(s)",
|
||||||
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
|
"@queueAttemptCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureQueued": "{count} queued",
|
"captureQueued": "{count} queued",
|
||||||
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
|
"@captureQueued": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureFlipCamera": "Switch camera",
|
"captureFlipCamera": "Switch camera",
|
||||||
"captureFlash": "Flash",
|
"captureFlash": "Flash",
|
||||||
"uploadsHistory": "History",
|
"uploadsHistory": "History",
|
||||||
@@ -255,17 +297,41 @@
|
|||||||
"captureCategoryDefault": "Default category",
|
"captureCategoryDefault": "Default category",
|
||||||
"captureSwitchHint": "Switching only affects photos captured afterwards",
|
"captureSwitchHint": "Switching only affects photos captured afterwards",
|
||||||
"capturePendingBadge": "Pending {count}",
|
"capturePendingBadge": "Pending {count}",
|
||||||
"@capturePendingBadge": { "placeholders": { "count": { "type": "int" } } },
|
"@capturePendingBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureFailedBadge": "Failed {count}",
|
"captureFailedBadge": "Failed {count}",
|
||||||
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
|
"@captureFailedBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"capturePickFromGallery": "Pick from gallery",
|
"capturePickFromGallery": "Pick from gallery",
|
||||||
"captureShutter": "Capture",
|
"captureShutter": "Capture",
|
||||||
"captureQueuedBadge": "Queued {count}",
|
"captureQueuedBadge": "Queued {count}",
|
||||||
"@captureQueuedBadge": { "placeholders": { "count": { "type": "int" } } },
|
"@captureQueuedBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureEnqueuedFeedback": "Added to queue",
|
"captureEnqueuedFeedback": "Added to queue",
|
||||||
"captureExitTitle": "Leave shooting?",
|
"captureExitTitle": "Leave shooting?",
|
||||||
"captureExitBody": "{count} photo(s) are still uploading. Tasks will continue in the background after you leave.",
|
"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",
|
"captureKeepShooting": "Keep shooting",
|
||||||
"captureExitConfirm": "Leave",
|
"captureExitConfirm": "Leave",
|
||||||
"cameraPermissionDenied": "Camera unavailable. Allow camera access in system settings",
|
"cameraPermissionDenied": "Camera unavailable. Allow camera access in system settings",
|
||||||
@@ -274,7 +340,13 @@
|
|||||||
"uploadActionPick": "Pick from gallery",
|
"uploadActionPick": "Pick from gallery",
|
||||||
"uploadAction": "Upload",
|
"uploadAction": "Upload",
|
||||||
"uploadEnqueuedCount": "Added {count} photo(s) to the upload queue",
|
"uploadEnqueuedCount": "Added {count} photo(s) to the upload queue",
|
||||||
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
|
"@uploadEnqueuedCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"uploadPickNone": "No photos selected",
|
"uploadPickNone": "No photos selected",
|
||||||
"albumManagePhotos": "Manage photos",
|
"albumManagePhotos": "Manage photos",
|
||||||
"albumTakeFirstPhoto": "Take the first photo",
|
"albumTakeFirstPhoto": "Take the first photo",
|
||||||
@@ -309,16 +381,41 @@
|
|||||||
"readonlyMember": "You can browse photos shared by others",
|
"readonlyMember": "You can browse photos shared by others",
|
||||||
"moreActions": "More actions",
|
"moreActions": "More actions",
|
||||||
"photosTitle": "{count} photos",
|
"photosTitle": "{count} photos",
|
||||||
"@photosTitle": { "placeholders": { "count": { "type": "int" } } },
|
"@photosTitle": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"photoUploadedBy": "Uploaded by {name}",
|
"photoUploadedBy": "Uploaded by {name}",
|
||||||
"@photoUploadedBy": { "placeholders": { "name": { "type": "String" } } },
|
"@photoUploadedBy": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"photoDimensions": "{width}×{height}",
|
"photoDimensions": "{width}×{height}",
|
||||||
"@photoDimensions": { "placeholders": { "width": { "type": "int" }, "height": { "type": "int" } } },
|
"@photoDimensions": {
|
||||||
|
"placeholders": {
|
||||||
|
"width": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"photoStatus": "Status",
|
"photoStatus": "Status",
|
||||||
"photoHideAction": "Hide photo",
|
"photoHideAction": "Hide photo",
|
||||||
"photoPublishAction": "Publish again",
|
"photoPublishAction": "Publish again",
|
||||||
"shareScanHint": "Scan to view photos",
|
"shareScanHint": "Scan to view photos",
|
||||||
"shareNotPublic": "This link is not publicly accessible yet",
|
"shareNotPublic": "This link is not publicly accessible yet",
|
||||||
"hidePhotoTitle": "Hide this photo?",
|
"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:
|
/// In zh, this message translates to:
|
||||||
/// **'观众将不再看到这张照片。相册管理者可重新发布。'**
|
/// **'观众将不再看到这张照片。相册管理者可重新发布。'**
|
||||||
String get hidePhotoBody;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -987,4 +987,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get hidePhotoBody =>
|
String get hidePhotoBody =>
|
||||||
'Viewers will no longer see this photo. Authorized album managers can publish it again.';
|
'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
|
@override
|
||||||
String get hidePhotoBody => '观众将不再看到这张照片。相册管理者可重新发布。';
|
String get hidePhotoBody => '观众将不再看到这张照片。相册管理者可重新发布。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteAlbum => '删除相册';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteAlbumConfirmTitle => '删除这个相册?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteAlbumConfirmBody => '删除后立即不可见且不可恢复。数据保留,恢复需运维处理。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumDeleted => '相册已删除';
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-16
@@ -32,7 +32,13 @@
|
|||||||
"navUploads": "上传",
|
"navUploads": "上传",
|
||||||
"navSettings": "设置",
|
"navSettings": "设置",
|
||||||
"badgeUploading": "{count} 个任务",
|
"badgeUploading": "{count} 个任务",
|
||||||
"@badgeUploading": { "placeholders": { "count": { "type": "int" } } },
|
"@badgeUploading": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"albumUploadsOn": "开放上传",
|
"albumUploadsOn": "开放上传",
|
||||||
"albumUploadsOff": "上传已关闭",
|
"albumUploadsOff": "上传已关闭",
|
||||||
"albumPublicOff": "未公开",
|
"albumPublicOff": "未公开",
|
||||||
@@ -116,7 +122,13 @@
|
|||||||
"actionEdit": "编辑",
|
"actionEdit": "编辑",
|
||||||
"membersTitle": "成员",
|
"membersTitle": "成员",
|
||||||
"membersTitleCount": "成员({count})",
|
"membersTitleCount": "成员({count})",
|
||||||
"@membersTitleCount": { "placeholders": { "count": { "type": "int" } } },
|
"@membersTitleCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ownerGroup": "创建者",
|
"ownerGroup": "创建者",
|
||||||
"collaboratorsGroup": "协作者",
|
"collaboratorsGroup": "协作者",
|
||||||
"noCollaborators": "还没有协作者",
|
"noCollaborators": "还没有协作者",
|
||||||
@@ -127,7 +139,13 @@
|
|||||||
"newCategory": "新增分类",
|
"newCategory": "新增分类",
|
||||||
"editCategory": "编辑分类",
|
"editCategory": "编辑分类",
|
||||||
"deleteCategoryConfirm": "删除「{name}」?该分类下的照片将变为未分类。",
|
"deleteCategoryConfirm": "删除「{name}」?该分类下的照片将变为未分类。",
|
||||||
"@deleteCategoryConfirm": { "placeholders": { "name": { "type": "String" } } },
|
"@deleteCategoryConfirm": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"noCategories": "暂无分类",
|
"noCategories": "暂无分类",
|
||||||
"albumCategories": "分类管理",
|
"albumCategories": "分类管理",
|
||||||
"shareQrTitle": "分享二维码",
|
"shareQrTitle": "分享二维码",
|
||||||
@@ -143,7 +161,13 @@
|
|||||||
"memberInvited": "已邀请",
|
"memberInvited": "已邀请",
|
||||||
"removeMember": "移除成员",
|
"removeMember": "移除成员",
|
||||||
"removeMemberConfirm": "确定将 {name} 从该相册移除?",
|
"removeMemberConfirm": "确定将 {name} 从该相册移除?",
|
||||||
"@removeMemberConfirm": { "placeholders": { "name": { "type": "String" } } },
|
"@removeMemberConfirm": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"roleOwner": "创建者",
|
"roleOwner": "创建者",
|
||||||
"roleCollaborator": "协作者",
|
"roleCollaborator": "协作者",
|
||||||
"selfMarker": "(我)",
|
"selfMarker": "(我)",
|
||||||
@@ -183,7 +207,13 @@
|
|||||||
"queueSummaryWaiting": "等待中",
|
"queueSummaryWaiting": "等待中",
|
||||||
"queueSummaryNeedAttention": "需关注",
|
"queueSummaryNeedAttention": "需关注",
|
||||||
"queueRowUploadingPercent": "上传中 {percent}%",
|
"queueRowUploadingPercent": "上传中 {percent}%",
|
||||||
"@queueRowUploadingPercent": { "placeholders": { "percent": { "type": "int" } } },
|
"@queueRowUploadingPercent": {
|
||||||
|
"placeholders": {
|
||||||
|
"percent": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"queueRowConfirming": "正在确认上传",
|
"queueRowConfirming": "正在确认上传",
|
||||||
"queueRowWaitingUpload": "等待上传",
|
"queueRowWaitingUpload": "等待上传",
|
||||||
"queueRowPreparing": "正在准备照片",
|
"queueRowPreparing": "正在准备照片",
|
||||||
@@ -202,9 +232,21 @@
|
|||||||
"queueEmpty": "暂无上传任务",
|
"queueEmpty": "暂无上传任务",
|
||||||
"queueRetryAllFailed": "一键重试全部失败",
|
"queueRetryAllFailed": "一键重试全部失败",
|
||||||
"queueAttemptCount": "已尝试 {count} 次",
|
"queueAttemptCount": "已尝试 {count} 次",
|
||||||
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
|
"@queueAttemptCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureQueued": "{count} 张待传",
|
"captureQueued": "{count} 张待传",
|
||||||
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
|
"@captureQueued": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureFlipCamera": "切换前后摄像头",
|
"captureFlipCamera": "切换前后摄像头",
|
||||||
"captureFlash": "闪光灯",
|
"captureFlash": "闪光灯",
|
||||||
"uploadsHistory": "历史记录",
|
"uploadsHistory": "历史记录",
|
||||||
@@ -255,17 +297,41 @@
|
|||||||
"captureCategoryDefault": "默认分类",
|
"captureCategoryDefault": "默认分类",
|
||||||
"captureSwitchHint": "切换分类仅影响之后拍摄的照片",
|
"captureSwitchHint": "切换分类仅影响之后拍摄的照片",
|
||||||
"capturePendingBadge": "待传 {count}",
|
"capturePendingBadge": "待传 {count}",
|
||||||
"@capturePendingBadge": { "placeholders": { "count": { "type": "int" } } },
|
"@capturePendingBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureFailedBadge": "失败 {count}",
|
"captureFailedBadge": "失败 {count}",
|
||||||
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
|
"@captureFailedBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"capturePickFromGallery": "从相册选择",
|
"capturePickFromGallery": "从相册选择",
|
||||||
"captureShutter": "拍摄",
|
"captureShutter": "拍摄",
|
||||||
"captureQueuedBadge": "待传 {count}",
|
"captureQueuedBadge": "待传 {count}",
|
||||||
"@captureQueuedBadge": { "placeholders": { "count": { "type": "int" } } },
|
"@captureQueuedBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureEnqueuedFeedback": "已加入队列",
|
"captureEnqueuedFeedback": "已加入队列",
|
||||||
"captureExitTitle": "退出拍摄?",
|
"captureExitTitle": "退出拍摄?",
|
||||||
"captureExitBody": "还有 {count} 张照片未上传,退出后任务将在后台继续。",
|
"captureExitBody": "还有 {count} 张照片未上传,退出后任务将在后台继续。",
|
||||||
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
|
"@captureExitBody": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captureKeepShooting": "继续拍摄",
|
"captureKeepShooting": "继续拍摄",
|
||||||
"captureExitConfirm": "退出",
|
"captureExitConfirm": "退出",
|
||||||
"cameraPermissionDenied": "无法访问相机,请在系统设置中允许相机权限",
|
"cameraPermissionDenied": "无法访问相机,请在系统设置中允许相机权限",
|
||||||
@@ -274,7 +340,13 @@
|
|||||||
"uploadActionPick": "从相册选择",
|
"uploadActionPick": "从相册选择",
|
||||||
"uploadAction": "上传",
|
"uploadAction": "上传",
|
||||||
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
|
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
|
||||||
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
|
"@uploadEnqueuedCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"uploadPickNone": "未选择照片",
|
"uploadPickNone": "未选择照片",
|
||||||
"albumManagePhotos": "管理照片",
|
"albumManagePhotos": "管理照片",
|
||||||
"albumTakeFirstPhoto": "拍摄第一张照片",
|
"albumTakeFirstPhoto": "拍摄第一张照片",
|
||||||
@@ -309,16 +381,41 @@
|
|||||||
"readonlyMember": "您可以浏览其他人分享的照片",
|
"readonlyMember": "您可以浏览其他人分享的照片",
|
||||||
"moreActions": "更多操作",
|
"moreActions": "更多操作",
|
||||||
"photosTitle": "{count} 张照片",
|
"photosTitle": "{count} 张照片",
|
||||||
"@photosTitle": { "placeholders": { "count": { "type": "int" } } },
|
"@photosTitle": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"photoUploadedBy": "上传者 {name}",
|
"photoUploadedBy": "上传者 {name}",
|
||||||
"@photoUploadedBy": { "placeholders": { "name": { "type": "String" } } },
|
"@photoUploadedBy": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"photoDimensions": "{width}×{height}",
|
"photoDimensions": "{width}×{height}",
|
||||||
"@photoDimensions": { "placeholders": { "width": { "type": "int" }, "height": { "type": "int" } } },
|
"@photoDimensions": {
|
||||||
|
"placeholders": {
|
||||||
|
"width": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"photoStatus": "状态",
|
"photoStatus": "状态",
|
||||||
"photoHideAction": "隐藏照片",
|
"photoHideAction": "隐藏照片",
|
||||||
"photoPublishAction": "重新发布",
|
"photoPublishAction": "重新发布",
|
||||||
"shareScanHint": "扫码查看照片",
|
"shareScanHint": "扫码查看照片",
|
||||||
"shareNotPublic": "该链接目前无法公开浏览",
|
"shareNotPublic": "该链接目前无法公开浏览",
|
||||||
"hidePhotoTitle": "隐藏这张照片?",
|
"hidePhotoTitle": "隐藏这张照片?",
|
||||||
"hidePhotoBody": "观众将不再看到这张照片。相册管理者可重新发布。"
|
"hidePhotoBody": "观众将不再看到这张照片。相册管理者可重新发布。",
|
||||||
|
"deleteAlbum": "删除相册",
|
||||||
|
"deleteAlbumConfirmTitle": "删除这个相册?",
|
||||||
|
"deleteAlbumConfirmBody": "删除后立即不可见且不可恢复。数据保留,恢复需运维处理。",
|
||||||
|
"albumDeleted": "相册已删除"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import 'share_qr_dialog.dart';
|
|||||||
const int _kRecentGridLimit = 9;
|
const int _kRecentGridLimit = 9;
|
||||||
|
|
||||||
/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。
|
/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。
|
||||||
enum AlbumAction { edit, members, categories, share }
|
enum AlbumAction { edit, members, categories, share, delete }
|
||||||
|
|
||||||
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
|
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
|
||||||
class AlbumDetailPage extends ConsumerStatefulWidget {
|
class AlbumDetailPage extends ConsumerStatefulWidget {
|
||||||
@@ -80,9 +80,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
_errorCode = null;
|
_errorCode = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final album = await ref.read(apiClientProvider).getAlbum(
|
final album = await ref.read(apiClientProvider).getAlbum(widget.albumId);
|
||||||
widget.albumId,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_album = album;
|
_album = album;
|
||||||
@@ -170,9 +168,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
final album = _album;
|
final album = _album;
|
||||||
if (album == null) return;
|
if (album == null) return;
|
||||||
final changed = await Navigator.of(context).push<bool>(
|
final changed = await Navigator.of(context).push<bool>(
|
||||||
MaterialPageRoute<bool>(
|
MaterialPageRoute<bool>(builder: (_) => AlbumFormPage(existing: album)),
|
||||||
builder: (_) => AlbumFormPage(existing: album),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (changed == true) {
|
if (changed == true) {
|
||||||
await _load();
|
await _load();
|
||||||
@@ -184,9 +180,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
final album = _album;
|
final album = _album;
|
||||||
if (album == null) return;
|
if (album == null) return;
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(builder: (_) => AlbumMembersPage(album: album)),
|
||||||
builder: (_) => AlbumMembersPage(album: album),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
// 成员变更不影响详情数据,回来无需刷新。
|
// 成员变更不影响详情数据,回来无需刷新。
|
||||||
}
|
}
|
||||||
@@ -195,9 +189,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
final album = _album;
|
final album = _album;
|
||||||
if (album == null) return;
|
if (album == null) return;
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(builder: (_) => AlbumPhotosPage(album: album)),
|
||||||
builder: (_) => AlbumPhotosPage(album: album),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
// 管理页内可能发生隐藏/删除,回来后刷新预览网格。
|
// 管理页内可能发生隐藏/删除,回来后刷新预览网格。
|
||||||
await _loadPhotoPreview();
|
await _loadPhotoPreview();
|
||||||
@@ -210,7 +202,8 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
if (album == null) return;
|
if (album == null) return;
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => AlbumCategoriesPage(album: album, isManager: _isManager),
|
builder: (_) =>
|
||||||
|
AlbumCategoriesPage(album: album, isManager: _isManager),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await _load();
|
await _load();
|
||||||
@@ -220,9 +213,9 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
final album = _album;
|
final album = _album;
|
||||||
if (album == null) return;
|
if (album == null) return;
|
||||||
// UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。
|
// UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。
|
||||||
Navigator.of(context).push(
|
Navigator.of(
|
||||||
MaterialPageRoute<void>(builder: (_) => ShareQrPage(album: album)),
|
context,
|
||||||
);
|
).push(MaterialPageRoute<void>(builder: (_) => ShareQrPage(album: album)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再
|
/// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再
|
||||||
@@ -243,9 +236,59 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
await _openCategories();
|
await _openCategories();
|
||||||
case AlbumAction.share:
|
case AlbumAction.share:
|
||||||
_shareQr();
|
_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 视为异常)。
|
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
||||||
@@ -267,7 +310,10 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
target: CaptureTarget(
|
target: CaptureTarget(
|
||||||
accountId: account,
|
accountId: account,
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
|
albumTitle: albumDisplayTitle(
|
||||||
|
album,
|
||||||
|
AppLocalizations.of(context).localeName,
|
||||||
|
),
|
||||||
categories: album.categories,
|
categories: album.categories,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -301,7 +347,9 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final album = _album;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
@@ -313,90 +361,94 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: album == null ? null : _UploadBar(
|
bottomNavigationBar: album == null
|
||||||
uploadEnabled: album.uploadEnabled,
|
? null
|
||||||
onCapture: _openCapture,
|
: _UploadBar(
|
||||||
onPick: _pickFromGallery,
|
uploadEnabled: album.uploadEnabled,
|
||||||
),
|
onCapture: _openCapture,
|
||||||
|
onPick: _pickFromGallery,
|
||||||
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: album == null
|
: album == null
|
||||||
? _ErrorRetryView(
|
? _ErrorRetryView(
|
||||||
message: l10n.messageForErrorCode(_errorCode!),
|
message: l10n.messageForErrorCode(_errorCode!),
|
||||||
onRetry: _load,
|
onRetry: _load,
|
||||||
)
|
)
|
||||||
: SingleChildScrollView(
|
: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// 封面:全宽 16:9(无封面同尺寸中性占位)。
|
// 封面:全宽 16:9(无封面同尺寸中性占位)。
|
||||||
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
|
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
kSpace16, kSpace16, kSpace16, 0,
|
kSpace16,
|
||||||
|
kSpace16,
|
||||||
|
kSpace16,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
albumDisplayTitle(album, l10n.localeName),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
child: Column(
|
const SizedBox(height: kSpace4),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(
|
||||||
children: [
|
_metaLine(album, l10n),
|
||||||
Text(
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
albumDisplayTitle(album, l10n.localeName),
|
?.copyWith(
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
color: Theme.of(
|
||||||
),
|
context,
|
||||||
const SizedBox(height: kSpace4),
|
).colorScheme.onSurfaceVariant,
|
||||||
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: kSpace12),
|
||||||
const SizedBox(height: kSpace16),
|
_StatusSection(album: album),
|
||||||
// 次级四入口(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: 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) {
|
static String _metaLine(Album album, AppLocalizations l10n) {
|
||||||
final venue = albumDisplayVenue(album, l10n.localeName);
|
final venue = albumDisplayVenue(album, l10n.localeName);
|
||||||
final start =
|
final start = formatAlbumDateTime(
|
||||||
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
|
album.startAt,
|
||||||
|
album.timezone,
|
||||||
|
l10n.localeName,
|
||||||
|
);
|
||||||
final end = album.endAt == null
|
final end = album.endAt == null
|
||||||
? l10n.notSet
|
? l10n.notSet
|
||||||
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
||||||
@@ -449,6 +501,20 @@ class AlbumActionSheet extends StatelessWidget {
|
|||||||
title: Text(l10n.shareAlbum),
|
title: Text(l10n.shareAlbum),
|
||||||
onTap: () => Navigator.of(context).pop(AlbumAction.share),
|
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),
|
const Divider(indent: kSpace16, endIndent: kSpace16),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Center(
|
title: Center(
|
||||||
@@ -482,16 +548,24 @@ class _StatusSection extends StatelessWidget {
|
|||||||
runSpacing: kSpace8,
|
runSpacing: kSpace8,
|
||||||
children: [
|
children: [
|
||||||
StateBadge(
|
StateBadge(
|
||||||
variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral,
|
variant: album.publicEnabled
|
||||||
|
? BadgeVariant.info
|
||||||
|
: BadgeVariant.neutral,
|
||||||
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
|
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
|
||||||
),
|
),
|
||||||
StateBadge(
|
StateBadge(
|
||||||
variant:
|
variant: album.uploadEnabled
|
||||||
album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral,
|
? BadgeVariant.success
|
||||||
label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff,
|
: BadgeVariant.neutral,
|
||||||
|
label: album.uploadEnabled
|
||||||
|
? l10n.albumUploadsOn
|
||||||
|
: l10n.albumUploadsOff,
|
||||||
),
|
),
|
||||||
if (album.status == AlbumStatus.offline)
|
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,
|
color: theme.colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(icon, size: 22, color: theme.colorScheme.primary),
|
||||||
icon,
|
|
||||||
size: 22,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: kSpace8),
|
const SizedBox(height: kSpace8),
|
||||||
Text(
|
Text(
|
||||||
@@ -693,8 +763,7 @@ class _RecentPhotosSection extends StatelessWidget {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
] else
|
||||||
else
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: kSpace24),
|
padding: const EdgeInsets.symmetric(vertical: kSpace24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -726,9 +795,8 @@ class _RecentPhotosSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。
|
/// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。
|
||||||
bool get _hasProcessing => photos.any(
|
bool get _hasProcessing =>
|
||||||
(p) => p.processingState == PhotoProcessingState.processing,
|
photos.any((p) => p.processingState == PhotoProcessingState.processing);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
|
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
|
||||||
@@ -839,4 +907,3 @@ class _ErrorRetryView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,34 @@ paths:
|
|||||||
/albums/{albumId}:
|
/albums/{albumId}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/AlbumId'
|
- $ref: '#/components/parameters/AlbumId'
|
||||||
|
delete:
|
||||||
|
operationId: deleteAlbum
|
||||||
|
tags: [albums]
|
||||||
|
summary: 软删除相册
|
||||||
|
description: >-
|
||||||
|
标记 status=deleted(软删除):管理端列表与公开端即刻不可见,成员/上传/
|
||||||
|
资产等全部子资源随之 404;数据保留(物理删除走独立运维流程),不提供
|
||||||
|
恢复端点。权限同「配置相册」(公司管理员或相册创建者)。事件走
|
||||||
|
album.updated(priority=high)同步 ov 副本下线。
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: 已删除(幂等:重复删除同样 204)。
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
description: 无「配置相册」权限(仅创建者或管理员,AUTH_FORBIDDEN)。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'404':
|
||||||
|
description: 相册不存在(ALBUM_NOT_FOUND,含已删除)。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
get:
|
get:
|
||||||
operationId: getAlbum
|
operationId: getAlbum
|
||||||
tags: [albums]
|
tags: [albums]
|
||||||
@@ -2781,6 +2809,7 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- active
|
- active
|
||||||
- offline
|
- offline
|
||||||
|
- deleted
|
||||||
defaultLocale:
|
defaultLocale:
|
||||||
$ref: '#/components/schemas/Locale'
|
$ref: '#/components/schemas/Locale'
|
||||||
timezone:
|
timezone:
|
||||||
@@ -2839,8 +2868,9 @@ components:
|
|||||||
description: |
|
description: |
|
||||||
相册封面摘要(UI-01,readOnly 可选扩展):当前生效封面资产
|
相册封面摘要(UI-01,readOnly 可选扩展):当前生效封面资产
|
||||||
(album_assets kind=cover、state=effective 的最新一行)的短期
|
(album_assets kind=cover、state=effective 的最新一行)的短期
|
||||||
签名 URL;未配置封面为 null。列表端点按页批量组装(非逐相册
|
签名 URL;未配置封面为 null。宣传图/封面共用(2026-09-23 决策):
|
||||||
查询);旧客户端忽略本字段不受影响。
|
未配置封面时回退最新生效宣传图(传一张即可两用)。列表端点按页
|
||||||
|
批量组装(非逐相册查询);旧客户端忽略本字段不受影响。
|
||||||
readOnly: true
|
readOnly: true
|
||||||
anyOf:
|
anyOf:
|
||||||
- $ref: '#/components/schemas/SignedUrl'
|
- $ref: '#/components/schemas/SignedUrl'
|
||||||
@@ -4397,7 +4427,9 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/PublicCategory'
|
$ref: '#/components/schemas/PublicCategory'
|
||||||
banner:
|
banner:
|
||||||
description: 封面图签名 URL;未设置为 null。
|
description: |
|
||||||
|
宣传图签名 URL;未设置为 null。宣传图/封面共用(2026-09-23 决策):
|
||||||
|
未配置宣传图时回退最新生效封面(传一张即可两用)。
|
||||||
anyOf:
|
anyOf:
|
||||||
- $ref: '#/components/schemas/SignedUrl'
|
- $ref: '#/components/schemas/SignedUrl'
|
||||||
- type: 'null'
|
- type: 'null'
|
||||||
|
|||||||
+53
-5
@@ -129,7 +129,11 @@ export interface paths {
|
|||||||
get: operations["getAlbum"];
|
get: operations["getAlbum"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
/**
|
||||||
|
* 软删除相册
|
||||||
|
* @description 标记 status=deleted(软删除):管理端列表与公开端即刻不可见,成员/上传/ 资产等全部子资源随之 404;数据保留(物理删除走独立运维流程),不提供 恢复端点。权限同「配置相册」(公司管理员或相册创建者)。事件走 album.updated(priority=high)同步 ov 副本下线。
|
||||||
|
*/
|
||||||
|
delete: operations["deleteAlbum"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
/**
|
/**
|
||||||
@@ -1340,7 +1344,7 @@ export interface components {
|
|||||||
* @default active
|
* @default active
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
readonly status: "active" | "offline";
|
readonly status: "active" | "offline" | "deleted";
|
||||||
defaultLocale: components["schemas"]["Locale"];
|
defaultLocale: components["schemas"]["Locale"];
|
||||||
/** @description 活动 IANA 时区。 */
|
/** @description 活动 IANA 时区。 */
|
||||||
timezone: string;
|
timezone: string;
|
||||||
@@ -1389,8 +1393,9 @@ export interface components {
|
|||||||
/**
|
/**
|
||||||
* @description 相册封面摘要(UI-01,readOnly 可选扩展):当前生效封面资产
|
* @description 相册封面摘要(UI-01,readOnly 可选扩展):当前生效封面资产
|
||||||
* (album_assets kind=cover、state=effective 的最新一行)的短期
|
* (album_assets kind=cover、state=effective 的最新一行)的短期
|
||||||
* 签名 URL;未配置封面为 null。列表端点按页批量组装(非逐相册
|
* 签名 URL;未配置封面为 null。宣传图/封面共用(2026-09-23 决策):
|
||||||
* 查询);旧客户端忽略本字段不受影响。
|
* 未配置封面时回退最新生效宣传图(传一张即可两用)。列表端点按页
|
||||||
|
* 批量组装(非逐相册查询);旧客户端忽略本字段不受影响。
|
||||||
*/
|
*/
|
||||||
readonly cover?: components["schemas"]["SignedUrl"] | null;
|
readonly cover?: components["schemas"]["SignedUrl"] | null;
|
||||||
};
|
};
|
||||||
@@ -2267,7 +2272,10 @@ export interface components {
|
|||||||
translation: components["schemas"]["PublicAlbumTranslation"];
|
translation: components["schemas"]["PublicAlbumTranslation"];
|
||||||
/** @description 分类列表(按 sortOrder 升序)。 */
|
/** @description 分类列表(按 sortOrder 升序)。 */
|
||||||
categories: components["schemas"]["PublicCategory"][];
|
categories: components["schemas"]["PublicCategory"][];
|
||||||
/** @description 封面图签名 URL;未设置为 null。 */
|
/**
|
||||||
|
* @description 宣传图签名 URL;未设置为 null。宣传图/封面共用(2026-09-23 决策):
|
||||||
|
* 未配置宣传图时回退最新生效封面(传一张即可两用)。
|
||||||
|
*/
|
||||||
banner: components["schemas"]["SignedUrl"] | null;
|
banner: components["schemas"]["SignedUrl"] | null;
|
||||||
/**
|
/**
|
||||||
* Format: int64
|
* Format: int64
|
||||||
@@ -2922,6 +2930,46 @@ export interface operations {
|
|||||||
500: components["responses"]["InternalError"];
|
500: components["responses"]["InternalError"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteAlbum: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description 相册 ID。 */
|
||||||
|
albumId: components["parameters"]["AlbumId"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description 已删除(幂等:重复删除同样 204)。 */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
/** @description 无「配置相册」权限(仅创建者或管理员,AUTH_FORBIDDEN)。 */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description 相册不存在(ALBUM_NOT_FOUND,含已删除)。 */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
updateAlbum: {
|
updateAlbum: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
-- 宣传图/封面共用(用户决策 2026-09-23):albums_ov 增 coverAssetKey 投影列
|
||||||
|
-- (asset.updated kind=cover 分流写入;banner/cover 互为展示层回退)。
|
||||||
|
-- 存量行回填:迁移后由对账 bootstrap(asset.updated 当前态重放)或下次
|
||||||
|
-- cover 资产事件自然补齐,不手工 UPDATE。
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `albums_ov` ADD COLUMN `coverAssetKey` VARCHAR(255) NULL;
|
||||||
@@ -47,7 +47,11 @@ model albums_ov {
|
|||||||
emergencyDisabled Boolean @default(false)
|
emergencyDisabled Boolean @default(false)
|
||||||
translations Json // [{locale,title,description,venue,address}](cn 原文数组)
|
translations Json // [{locale,title,description,venue,address}](cn 原文数组)
|
||||||
categories Json // [{id,sortOrder,translations:[{locale,name}]}]
|
categories Json // [{id,sortOrder,translations:[{locale,name}]}]
|
||||||
|
// 宣传图/封面投影键(asset.updated 事件按 kind 分列存储)。展示层双向回退
|
||||||
|
// (用户决策 2026-09-23「宣传图和封面用一个」):banner 缺失时公开侧回退
|
||||||
|
// cover 键——需求 §4.2「封面可复用宣传图」基线的反向补充。
|
||||||
bannerAssetKey String? @db.VarChar(255)
|
bannerAssetKey String? @db.VarChar(255)
|
||||||
|
coverAssetKey String? @db.VarChar(255)
|
||||||
defaultLocale String @db.VarChar(16)
|
defaultLocale String @db.VarChar(16)
|
||||||
timezone String @default("Asia/Shanghai") @db.VarChar(64)
|
timezone String @default("Asia/Shanghai") @db.VarChar(64)
|
||||||
startAt DateTime? @db.DateTime(6)
|
startAt DateTime? @db.DateTime(6)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -79,6 +81,15 @@ export class AlbumsController {
|
|||||||
return this.albumsService.get(userOf(req), albumId);
|
return this.albumsService.get(userOf(req), albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':albumId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
deleteAlbum(
|
||||||
|
@Param('albumId') albumId: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.albumsService.softDelete(userOf(req), albumId);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':albumId')
|
@Patch(':albumId')
|
||||||
updateAlbum(
|
updateAlbum(
|
||||||
@Param('albumId') albumId: string,
|
@Param('albumId') albumId: string,
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export class AlbumsService {
|
|||||||
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
|
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
|
||||||
outcome = await this.prisma.$transaction(async (tx) => {
|
outcome = await this.prisma.$transaction(async (tx) => {
|
||||||
const album = await tx.albums.findUnique({ where: { id: albumId } });
|
const album = await tx.albums.findUnique({ where: { id: albumId } });
|
||||||
if (album === null) {
|
if (album === null || album.status === 'deleted') {
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +627,10 @@ export class AlbumsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.albumsWhereInput = {};
|
const where: Prisma.albumsWhereInput = {
|
||||||
|
// 软删除(deleteAlbum):列表不可见(数据保留,运维可恢复)。
|
||||||
|
status: { not: 'deleted' },
|
||||||
|
};
|
||||||
if (user.role !== 'admin') {
|
if (user.role !== 'admin') {
|
||||||
const memberships = await this.prisma.album_members.findMany({
|
const memberships = await this.prisma.album_members.findMany({
|
||||||
where: { userId: user.id, status: 'active' },
|
where: { userId: user.id, status: 'active' },
|
||||||
@@ -664,24 +667,31 @@ export class AlbumsService {
|
|||||||
// UI-01 封面摘要:本页相册一次批量查询(kind=cover、state=effective,
|
// UI-01 封面摘要:本页相册一次批量查询(kind=cover、state=effective,
|
||||||
// createdAt/id DESC 后每相册首行即最新 effective 行——同 banner 替换语义,
|
// createdAt/id DESC 后每相册首行即最新 effective 行——同 banner 替换语义,
|
||||||
// cover 无 locale 偏好)。presign 为本地计算,循环不产生网络/查询放大。
|
// cover 无 locale 偏好)。presign 为本地计算,循环不产生网络/查询放大。
|
||||||
|
// 宣传图/封面共用(用户决策 2026-09-23):cover 缺失回退最新 effective
|
||||||
|
// banner 行(传一张即可两用)——两类合并查询后按 kind 优先 cover。
|
||||||
const covers = await this.prisma.album_assets.findMany({
|
const covers = await this.prisma.album_assets.findMany({
|
||||||
where: {
|
where: {
|
||||||
albumId: { in: page.map((a) => a.id) },
|
albumId: { in: page.map((a) => a.id) },
|
||||||
kind: 'cover',
|
kind: { in: ['cover', 'banner'] },
|
||||||
state: 'effective',
|
state: 'effective',
|
||||||
},
|
},
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
select: { albumId: true, objectKey: true },
|
select: { albumId: true, objectKey: true, kind: true },
|
||||||
});
|
});
|
||||||
const coverKeyByAlbum = new Map<string, string>();
|
const coverKeyByAlbum = new Map<string, string>();
|
||||||
|
const bannerKeyByAlbum = new Map<string, string>();
|
||||||
for (const row of covers) {
|
for (const row of covers) {
|
||||||
if (!coverKeyByAlbum.has(row.albumId)) {
|
// 各自首见即最新(DESC);合并时 cover 恒优先(两类并存时封面位用 cover)。
|
||||||
coverKeyByAlbum.set(row.albumId, row.objectKey);
|
if (row.kind === 'cover') {
|
||||||
|
if (!coverKeyByAlbum.has(row.albumId)) coverKeyByAlbum.set(row.albumId, row.objectKey);
|
||||||
|
} else if (!bannerKeyByAlbum.has(row.albumId)) {
|
||||||
|
bannerKeyByAlbum.set(row.albumId, row.objectKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const coverByAlbum = new Map<string, SignedUrlResponse>();
|
const coverByAlbum = new Map<string, SignedUrlResponse>();
|
||||||
for (const [albumId, objectKey] of coverKeyByAlbum) {
|
for (const albumId of page.map((a) => a.id)) {
|
||||||
coverByAlbum.set(albumId, await this.presign(objectKey));
|
const objectKey = coverKeyByAlbum.get(albumId) ?? bannerKeyByAlbum.get(albumId);
|
||||||
|
if (objectKey !== undefined) coverByAlbum.set(albumId, await this.presign(objectKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
const last = page.at(-1);
|
const last = page.at(-1);
|
||||||
@@ -713,11 +723,82 @@ export class AlbumsService {
|
|||||||
* (sortOrder 升序;写入入口为 CategoriesService,Task 17 起)。相册域无
|
* (sortOrder 升序;写入入口为 CategoriesService,Task 17 起)。相册域无
|
||||||
* Prisma relation,全部二次查询应用层组装。
|
* Prisma relation,全部二次查询应用层组装。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 软删除(契约 deleteAlbum):status=deleted,管理列表/公开端即刻不可见,
|
||||||
|
* 数据保留(物理删除走独立运维流程),无恢复端点。
|
||||||
|
* 权限与 update 一致(canView 404 → canManage 403,防枚举)。
|
||||||
|
* 事务:乐观锁 claim(entityVersion 自增)+ outbox album.updated
|
||||||
|
* (priority=high——公开可见性类变更,ov 观众侧须尽快下线)+ audit。
|
||||||
|
* 幂等:已删除重复删除同样 204。
|
||||||
|
*/
|
||||||
|
async softDelete(user: AuthUser, albumId: string): Promise<void> {
|
||||||
|
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
||||||
|
throw albumForbidden('仅创建者或管理员可删除相册');
|
||||||
|
}
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const done = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const album = await tx.albums.findUnique({ where: { id: albumId } });
|
||||||
|
if (album === null) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (album.status === 'deleted') {
|
||||||
|
return true as const; // 幂等:重复删除直接成功
|
||||||
|
}
|
||||||
|
const claimed = await tx.albums.updateMany({
|
||||||
|
where: { id: albumId, entityVersion: album.entityVersion },
|
||||||
|
data: { status: 'deleted', entityVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (claimed.count === 0) {
|
||||||
|
return false as const; // 并发更新:整事务重跑
|
||||||
|
}
|
||||||
|
const updated = await tx.albums.findUniqueOrThrow({
|
||||||
|
where: { id: albumId },
|
||||||
|
});
|
||||||
|
const translations = await tx.album_translations.findMany({
|
||||||
|
where: { albumId },
|
||||||
|
orderBy: { locale: 'asc' },
|
||||||
|
});
|
||||||
|
// 公开可见性类变更 → high(与公开关闭迁移同优先级)。
|
||||||
|
await tx.outbox_events.create({
|
||||||
|
data: {
|
||||||
|
type: 'album.updated',
|
||||||
|
albumId,
|
||||||
|
entityVersion: album.entityVersion + 1,
|
||||||
|
priority: 'high',
|
||||||
|
payload: buildAlbumUpdatedPayload(
|
||||||
|
updated,
|
||||||
|
translations,
|
||||||
|
) as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
actorId: user.id,
|
||||||
|
action: 'album.delete',
|
||||||
|
resourceId: albumId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true as const;
|
||||||
|
});
|
||||||
|
if (done) return;
|
||||||
|
// 败方重试(并发窗口极小;3 次后 5xx 兜底与 update 同策略)
|
||||||
|
}
|
||||||
|
throw new ApiException(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
'Internal server error',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async loadAlbum(albumId: string): Promise<AlbumResponse | null> {
|
private async loadAlbum(albumId: string): Promise<AlbumResponse | null> {
|
||||||
const album = await this.prisma.albums.findUnique({
|
const album = await this.prisma.albums.findUnique({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
});
|
});
|
||||||
if (album === null) {
|
if (album === null || album.status === 'deleted') {
|
||||||
|
// 已删除与不存在同 null(调用方映射 404;软删除语义)。
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const translations = await this.prisma.album_translations.findMany({
|
const translations = await this.prisma.album_translations.findMany({
|
||||||
@@ -770,11 +851,20 @@ export class AlbumsService {
|
|||||||
private async latestCoverOf(
|
private async latestCoverOf(
|
||||||
albumId: string,
|
albumId: string,
|
||||||
): Promise<SignedUrlResponse | null> {
|
): Promise<SignedUrlResponse | null> {
|
||||||
const row = await this.prisma.album_assets.findFirst({
|
// 宣传图/封面共用(用户决策 2026-09-23):cover 缺失回退最新 effective
|
||||||
|
// banner 行(与列表摘要同语义)。
|
||||||
|
const cover = await this.prisma.album_assets.findFirst({
|
||||||
where: { albumId, kind: 'cover', state: 'effective' },
|
where: { albumId, kind: 'cover', state: 'effective' },
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
select: { objectKey: true },
|
select: { objectKey: true },
|
||||||
});
|
});
|
||||||
|
const row =
|
||||||
|
cover ??
|
||||||
|
(await this.prisma.album_assets.findFirst({
|
||||||
|
where: { albumId, kind: 'banner', state: 'effective' },
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
select: { objectKey: true },
|
||||||
|
}));
|
||||||
return row === null ? null : this.presign(row.objectKey);
|
return row === null ? null : this.presign(row.objectKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ export class AssetsService {
|
|||||||
// 注意不查 uploadEnabled——开关只管照片直传(§4.9),不管资产配置。
|
// 注意不查 uploadEnabled——开关只管照片直传(§4.9),不管资产配置。
|
||||||
const album = await this.prisma.albums.findUnique({
|
const album = await this.prisma.albums.findUnique({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
});
|
});
|
||||||
if (album === null) {
|
if (album === null || album.status === 'deleted') {
|
||||||
|
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
// ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。
|
// ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。
|
||||||
|
|||||||
@@ -288,10 +288,10 @@ export class CategoriesService {
|
|||||||
}
|
}
|
||||||
const album = await this.prisma.albums.findUnique({
|
const album = await this.prisma.albums.findUnique({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
});
|
});
|
||||||
if (album === null) {
|
if (album === null || album.status === 'deleted') {
|
||||||
// admin 视角 canView 恒真,空值兜底同码 404。
|
// admin 视角 canView 恒真,空值兜底同码 404;已删除同码(软删除)。
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,9 +123,10 @@ export class MembersService {
|
|||||||
// 空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
|
// 空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
|
||||||
const albumExists = await this.prisma.albums.findUnique({
|
const albumExists = await this.prisma.albums.findUnique({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
});
|
});
|
||||||
if (albumExists === null) {
|
if (albumExists === null || albumExists.status === 'deleted') {
|
||||||
|
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||||
|
|||||||
@@ -198,9 +198,10 @@ export class WatermarkService {
|
|||||||
}
|
}
|
||||||
const album = await this.prisma.albums.findUnique({
|
const album = await this.prisma.albums.findUnique({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
});
|
});
|
||||||
if (album === null) {
|
if (album === null || album.status === 'deleted') {
|
||||||
|
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
const latestConfig = await this.prisma.watermark_configs.findFirst({
|
const latestConfig = await this.prisma.watermark_configs.findFirst({
|
||||||
|
|||||||
@@ -312,9 +312,10 @@ export class EmbedSlotsService {
|
|||||||
if (nextAlbumId !== null) {
|
if (nextAlbumId !== null) {
|
||||||
const album = await tx.albums.findUnique({
|
const album = await tx.albums.findUnique({
|
||||||
where: { id: nextAlbumId },
|
where: { id: nextAlbumId },
|
||||||
select: { publicToken: true },
|
select: { publicToken: true, status: true },
|
||||||
});
|
});
|
||||||
if (album === null) {
|
if (album === null || album.status === 'deleted') {
|
||||||
|
// 已删除相册不可绑定(软删除语义;既有绑定由拉取端容错)。
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
nextAlbumPublicToken = album.publicToken;
|
nextAlbumPublicToken = album.publicToken;
|
||||||
|
|||||||
@@ -142,9 +142,10 @@ export class PhotosService {
|
|||||||
// 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
|
// 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
|
||||||
const albumExists = await this.prisma.albums.findUnique({
|
const albumExists = await this.prisma.albums.findUnique({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
});
|
});
|
||||||
if (albumExists === null) {
|
if (albumExists === null || albumExists.status === 'deleted') {
|
||||||
|
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,9 +320,10 @@ export class PhotosService {
|
|||||||
// listAlbumMembers/listAlbumPhotos 先例)。
|
// listAlbumMembers/listAlbumPhotos 先例)。
|
||||||
const albumExists = await this.prisma.albums.findUnique({
|
const albumExists = await this.prisma.albums.findUnique({
|
||||||
where: { id: albumId },
|
where: { id: albumId },
|
||||||
select: { id: true },
|
select: { id: true, status: true },
|
||||||
});
|
});
|
||||||
if (albumExists === null) {
|
if (albumExists === null || albumExists.status === 'deleted') {
|
||||||
|
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
|
||||||
throw albumNotFound();
|
throw albumNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ export class PublicOvService {
|
|||||||
const categories = this.categoryEntries(album.categories)
|
const categories = this.categoryEntries(album.categories)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder || (a.id < b.id ? -1 : 1));
|
.sort((a, b) => a.sortOrder - b.sortOrder || (a.id < b.id ? -1 : 1));
|
||||||
const banner =
|
// 宣传图/封面共用(用户决策 2026-09-23):banner 投影键缺失回退 cover 键
|
||||||
album.bannerAssetKey === null
|
// (需求 §4.2「封面可复用宣传图」基线的反向补充——传一张即可两用)。
|
||||||
? null
|
const bannerKey = album.bannerAssetKey ?? album.coverAssetKey;
|
||||||
: await this.presign(album.bannerAssetKey);
|
const banner = bannerKey === null ? null : await this.presign(bannerKey);
|
||||||
|
|
||||||
const stats = await this.prisma.album_stats.findUnique({
|
const stats = await this.prisma.album_stats.findUnique({
|
||||||
where: { albumId: album.albumId },
|
where: { albumId: album.albumId },
|
||||||
|
|||||||
@@ -112,10 +112,18 @@ export class PublicService {
|
|||||||
where: { albumId: album.id, kind: 'banner', state: 'effective' },
|
where: { albumId: album.id, kind: 'banner', state: 'effective' },
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
});
|
});
|
||||||
const bannerRow =
|
let bannerRow =
|
||||||
bannerAssets.find((row) => row.locale === resolved.locale) ??
|
bannerAssets.find((row) => row.locale === resolved.locale) ??
|
||||||
bannerAssets.find((row) => row.locale === null) ??
|
bannerAssets.find((row) => row.locale === null) ??
|
||||||
null;
|
null;
|
||||||
|
// 宣传图/封面共用(用户决策 2026-09-23):banner 缺失回退最新 effective
|
||||||
|
// cover 行(传一张即可两用;与 ov 公开侧同语义)。
|
||||||
|
if (bannerRow === null) {
|
||||||
|
bannerRow = await this.prisma.album_assets.findFirst({
|
||||||
|
where: { albumId: album.id, kind: 'cover', state: 'effective' },
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
const banner =
|
const banner =
|
||||||
bannerRow === null ? null : await this.presign(bannerRow.objectKey);
|
bannerRow === null ? null : await this.presign(bannerRow.objectKey);
|
||||||
|
|
||||||
|
|||||||
@@ -395,13 +395,20 @@ export class SyncInboxService {
|
|||||||
}
|
}
|
||||||
if (event.entityType === 'asset') {
|
if (event.entityType === 'asset') {
|
||||||
const p = event.payload as unknown as AssetUpdatedPayload;
|
const p = event.payload as unknown as AssetUpdatedPayload;
|
||||||
|
// banner/cover 分列投影(宣传图/封面共用决策 2026-09-23:展示层双向
|
||||||
|
// 回退,见 public-ov banner 组装);logo 仍无投影位(YAGNI),仅推进
|
||||||
|
// 版本账本。替换语义同 banner:新事件覆盖旧键。
|
||||||
if (p.kind === 'banner') {
|
if (p.kind === 'banner') {
|
||||||
await tx.albums_ov.update({
|
await tx.albums_ov.update({
|
||||||
where: { albumId: event.albumId },
|
where: { albumId: event.albumId },
|
||||||
data: { bannerAssetKey: p.objectKey },
|
data: { bannerAssetKey: p.objectKey },
|
||||||
});
|
});
|
||||||
|
} else if (p.kind === 'cover') {
|
||||||
|
await tx.albums_ov.update({
|
||||||
|
where: { albumId: event.albumId },
|
||||||
|
data: { coverAssetKey: p.objectKey },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
// slot-state(P4b Task 2):全量快照直投 embed_slots_ov(版本单调已由 ②
|
// slot-state(P4b Task 2):全量快照直投 embed_slots_ov(版本单调已由 ②
|
||||||
|
|||||||
@@ -264,6 +264,14 @@ describe('embed slots (e2e)', () => {
|
|||||||
intervalMs: 5000,
|
intervalMs: 5000,
|
||||||
lang: 'auto',
|
lang: 'auto',
|
||||||
collapsedDefault: false,
|
collapsedDefault: false,
|
||||||
|
// UI-04 新字段(旧形状创建 → 归一化默认:carousel/16/true)。
|
||||||
|
imageFit: 'contain',
|
||||||
|
offsetX: 16,
|
||||||
|
offsetBottom: 24,
|
||||||
|
zIndex: 1000,
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
},
|
},
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
@@ -294,6 +302,9 @@ describe('embed slots (e2e)', () => {
|
|||||||
offsetX: 16,
|
offsetX: 16,
|
||||||
offsetBottom: 24,
|
offsetBottom: 24,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。
|
// 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。
|
||||||
@@ -794,6 +805,9 @@ describe('embed slots (e2e)', () => {
|
|||||||
offsetX: 40,
|
offsetX: 40,
|
||||||
offsetBottom: 80,
|
offsetBottom: 80,
|
||||||
zIndex: 5000,
|
zIndex: 5000,
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 新字段校验:非法枚举/越界/非整数 → 400(老字段约束不回归)。
|
// 新字段校验:非法枚举/越界/非整数 → 400(老字段约束不回归)。
|
||||||
@@ -845,6 +859,9 @@ describe('embed slots (e2e)', () => {
|
|||||||
offsetX: 16,
|
offsetX: 16,
|
||||||
offsetBottom: 24,
|
offsetBottom: 24,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
};
|
};
|
||||||
expect(patched.body.settings).toEqual(normalizedFull);
|
expect(patched.body.settings).toEqual(normalizedFull);
|
||||||
|
|
||||||
@@ -868,6 +885,9 @@ describe('embed slots (e2e)', () => {
|
|||||||
offsetX: 8,
|
offsetX: 8,
|
||||||
offsetBottom: 120,
|
offsetBottom: 120,
|
||||||
zIndex: 999,
|
zIndex: 999,
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
};
|
};
|
||||||
expect(explicit.body.settings).toEqual(explicitFull);
|
expect(explicit.body.settings).toEqual(explicitFull);
|
||||||
|
|
||||||
@@ -911,6 +931,9 @@ describe('embed slots (e2e)', () => {
|
|||||||
offsetX: 16,
|
offsetX: 16,
|
||||||
offsetBottom: 24,
|
offsetBottom: 24,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -509,4 +509,56 @@ describe('public-ov (e2e)', () => {
|
|||||||
expect(enable.status).toBe(200);
|
expect(enable.status).toBe(200);
|
||||||
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('⑥ banner 回退 cover(asset.updated kind=cover 投影 coverAssetKey + ov 读侧回退)', async () => {
|
||||||
|
// cn 真实链路:建相册 → cover 资产 init/PUT/complete(真实 asset.updated 事件)。
|
||||||
|
const album = await request(cnBase)
|
||||||
|
.post('/v1/albums')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
translations: [{ locale: 'zh-CN', title: '仅封面 ov 相册' }],
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
startAt: '2026-10-02T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(album.status).toBe(201);
|
||||||
|
const token = album.body.publicToken as string;
|
||||||
|
|
||||||
|
const coverJpeg = await makeJpeg(960, 320, { r: 90, g: 130, b: 180 });
|
||||||
|
const init = await request(cnBase)
|
||||||
|
.post(`/v1/albums/${album.body.id as string}/assets`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
kind: 'cover',
|
||||||
|
filename: 'cover.jpg',
|
||||||
|
bytes: coverJpeg.length,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(init.status).toBe(201);
|
||||||
|
const credential = init.body.credential as { url: string; method: string; headers: Record<string, string> };
|
||||||
|
await fetch(credential.url, {
|
||||||
|
method: credential.method,
|
||||||
|
headers: credential.headers,
|
||||||
|
body: new Uint8Array(coverJpeg),
|
||||||
|
});
|
||||||
|
const done = await request(cnBase)
|
||||||
|
.post(`/v1/albums/${album.body.id as string}/assets/${init.body.assetId as string}/complete`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
expect(done.status).toBe(200);
|
||||||
|
|
||||||
|
// 真实发送管线:asset.updated(cover) → ov coverAssetKey 投影。
|
||||||
|
await drainSender();
|
||||||
|
|
||||||
|
// ov 读侧回退:bannerAssetKey 空 → coverAssetKey 签发(字节=cover 对象)。
|
||||||
|
const before = await prismaOv.albums_ov.findUnique({ where: { publicToken: token } });
|
||||||
|
expect(before?.bannerAssetKey).toBeNull();
|
||||||
|
expect(before?.coverAssetKey).toBeTruthy();
|
||||||
|
|
||||||
|
const ov = await getJson(ovBase, `/v1/public/albums/${token}`);
|
||||||
|
expect(ov.status).toBe(200);
|
||||||
|
const banner = ov.body.banner as { url: string };
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
const fetched = await fetch(banner.url);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ describe('public (e2e)', () => {
|
|||||||
let disabledToken: string;
|
let disabledToken: string;
|
||||||
let bannerToken: string;
|
let bannerToken: string;
|
||||||
let noBannerToken: string;
|
let noBannerToken: string;
|
||||||
|
let coverOnlyToken: string;
|
||||||
let catAId: string;
|
let catAId: string;
|
||||||
let catBId: string;
|
let catBId: string;
|
||||||
let catZhOnlyId: string;
|
let catZhOnlyId: string;
|
||||||
@@ -342,6 +343,24 @@ describe('public (e2e)', () => {
|
|||||||
translations: [{ locale: 'zh-CN', title: '无封面相册' }],
|
translations: [{ locale: 'zh-CN', title: '无封面相册' }],
|
||||||
});
|
});
|
||||||
noBannerToken = noBanner.publicToken;
|
noBannerToken = noBanner.publicToken;
|
||||||
|
|
||||||
|
// 宣传图/封面共用(2026-09-23 决策):仅 cover 相册 → banner 回退 cover。
|
||||||
|
const coverOnly = await seedAlbum({
|
||||||
|
alias: 'pub-coveronly',
|
||||||
|
translations: [{ locale: 'zh-CN', title: '仅封面相册' }],
|
||||||
|
});
|
||||||
|
coverOnlyToken = coverOnly.publicToken;
|
||||||
|
const coverOnlyKey = `${KEY_ROOT}/albums/${coverOnly.id}/assets/cover.jpg`;
|
||||||
|
await storage.put(coverOnlyKey, await makeJpeg(900, 300), 'image/jpeg');
|
||||||
|
await prisma.album_assets.create({
|
||||||
|
data: {
|
||||||
|
albumId: coverOnly.id,
|
||||||
|
kind: 'cover',
|
||||||
|
locale: null,
|
||||||
|
objectKey: coverOnlyKey,
|
||||||
|
state: 'effective',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('locale=en → 200 translation=en 内容且 resolvedLocale=en;categories 同语言解析且按 sortOrder 升序', async () => {
|
it('locale=en → 200 translation=en 内容且 resolvedLocale=en;categories 同语言解析且按 sortOrder 升序', async () => {
|
||||||
@@ -446,6 +465,16 @@ describe('public (e2e)', () => {
|
|||||||
expect(without.body.banner).toBeNull();
|
expect(without.body.banner).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('banner 回退 cover(宣传图/封面共用:仅 cover 相册 → banner=cover 对象)', async () => {
|
||||||
|
const res = await getPublic(coverOnlyToken);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const banner = res.body.banner as { url: string; expiresAt: string };
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
const fetched = await fetch(banner.url);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
it('viewCount P1a 占位 null;startAt/endAt ISO + timezone 返回', async () => {
|
it('viewCount P1a 占位 null;startAt/endAt ISO + timezone 返回', async () => {
|
||||||
const res = await getPublic(mainToken);
|
const res = await getPublic(mainToken);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -624,4 +624,45 @@ describe('UI-01 契约扩展:cover / preview / scope=own (e2e)', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cover 回退 banner(宣传图/封面共用:仅 banner 相册 → cover=banner 对象)', async () => {
|
||||||
|
// 直种仅 banner 相册(复用本文件资产直种模式)。
|
||||||
|
const album = await prisma.albums.create({
|
||||||
|
data: {
|
||||||
|
publicToken: makePublicToken(),
|
||||||
|
ownerId: 'u-ui01-coverfb',
|
||||||
|
startAt: new Date('2026-10-03T02:00:00.000Z'),
|
||||||
|
endAt: new Date('2026-10-03T12:00:00.000Z'),
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.album_translations.create({
|
||||||
|
data: { albumId: album.id, locale: 'zh-CN', title: '仅宣传图相册' },
|
||||||
|
});
|
||||||
|
const key = `photo-live/dev/albums/${album.id}/assets/u01assetbannerfb00000000.jpg`;
|
||||||
|
await putObject(key, Buffer.from([0x51, 0x52, 0x53]));
|
||||||
|
await prisma.album_assets.create({
|
||||||
|
data: {
|
||||||
|
id: 'u01assetbannerfb00000000',
|
||||||
|
albumId: album.id,
|
||||||
|
kind: 'banner',
|
||||||
|
locale: null,
|
||||||
|
objectKey: key,
|
||||||
|
state: 'effective',
|
||||||
|
expectedBytes: 3,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await listAlbums(adminToken);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const item = (res.body.items as Array<{ id: string; cover: { url: string } | null }>).find(
|
||||||
|
(row) => row.id === album.id,
|
||||||
|
);
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item?.cover).not.toBeNull();
|
||||||
|
const fetched = await fetch(item!.cover!.url);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(Buffer.from(await fetched.arrayBuffer()).length).toBe(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user