feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)

契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
  outbox album.updated priority=high + audit album.delete);列表/详情/更新/
  成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
  (status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
  album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 10:13:08 +08:00
parent 9cb960c27a
commit 8e674a902e
31 changed files with 995 additions and 258 deletions
+5
View File
@@ -30,6 +30,11 @@ export function fetchUpdateAlbum(albumId: string, data: Api.Album.UpdatePayload)
return request.patch<Api.Album.Detail>({ url: `/v1/albums/${albumId}`, data })
}
/** 软删除相册(status=deleted:列表/公开端即刻不可见;数据保留,无恢复端点;幂等 204) */
export function fetchDeleteAlbum(albumId: string) {
return request.del<void>({ url: `/v1/albums/${albumId}` })
}
/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
return request.post<Api.Album.AssetInitResponse>({
+6 -2
View File
@@ -316,7 +316,10 @@
"viewTable": "Table view",
"loadedCount": "{n} albums loaded",
"cursorNote": "Sorted by creation time · cursor-based loading",
"emptyFiltered": "No albums match these filters"
"emptyFiltered": "No albums match these filters",
"actionDelete": "Delete",
"deleteConfirm": "Delete album \"{title}\"? It becomes invisible immediately and cannot be restored (data retained; ops-only recovery).",
"deleted": "Album deleted"
},
"form": {
"createTitle": "New Album",
@@ -385,7 +388,8 @@
"fieldOwnerId": "Owner",
"subtitle": "Album workspace · Manage the moments that matter",
"share": "Share album",
"tabAssets": "Branding assets"
"tabAssets": "Branding assets",
"delete": "Delete album"
},
"banner": {
"kind": "Asset Kind",
+6 -2
View File
@@ -316,7 +316,10 @@
"viewTable": "表格视图",
"loadedCount": "已加载 {n} 个相册",
"cursorNote": "按创建时间排序 · 游标加载",
"emptyFiltered": "没有符合筛选条件的相册"
"emptyFiltered": "没有符合筛选条件的相册",
"actionDelete": "删除",
"deleteConfirm": "确定删除相册「{title}」?删除后立即不可见且不可恢复(数据保留,仅运维可恢复)。",
"deleted": "相册已删除"
},
"form": {
"createTitle": "新建相册",
@@ -385,7 +388,8 @@
"fieldOwnerId": "负责人",
"subtitle": "相册工作区 · 管理活动中的精彩瞬间",
"share": "分享相册",
"tabAssets": "品牌资产"
"tabAssets": "品牌资产",
"delete": "删除相册"
},
"banner": {
"kind": "资产类型",
+27 -1
View File
@@ -39,6 +39,9 @@
>
{{ $t('albumsManage.detail.edit') }}
</ElButton>
<ElButton type="danger" plain data-test="album-detail-delete" @click="removeAlbum">
{{ $t('albumsManage.detail.delete') }}
</ElButton>
</div>
</div>
@@ -104,6 +107,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft } from '@element-plus/icons-vue'
import AlbumSummaryHeader from '@/components/business/albums/AlbumSummaryHeader.vue'
import AlbumBannerUpload from './AlbumBannerUpload.vue'
@@ -113,12 +117,14 @@
import AlbumWatermark from './AlbumWatermark.vue'
import AlbumShareCard from './AlbumShareCard.vue'
import AlbumTransferDialog from './AlbumTransferDialog.vue'
import { fetchAlbum } from '@/api/albums'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbum, fetchDeleteAlbum } from '@/api/albums'
defineOptions({ name: 'AlbumsDetail' })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const albumId = computed(() => String(route.params.albumId ?? ''))
@@ -152,6 +158,26 @@
return preferred?.title ?? target.translations[0]?.title ?? target.id
}
/** 软删除相册(admin/owner):确认 → DELETE → 回列表 */
async function removeAlbum() {
try {
await ElMessageBox.confirm(
t('albumsManage.list.deleteConfirm', { title: titleOf(album.value) }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteAlbum(albumId.value)
ElMessage.success(t('albumsManage.list.deleted'))
void router.replace({ name: 'AlbumsManage' })
}
/** 列表「成员」入口带 #members 锚点:直达成员页签 */
watch(
() => route.hash,
+25 -1
View File
@@ -155,6 +155,9 @@
<ElButton link type="primary" data-test="album-detail-btn" @click="gotoDetail(row)">
{{ $t('albumsManage.list.actionDetail') }}
</ElButton>
<ElButton link type="danger" data-test="album-delete-btn" @click="remove(row)">
{{ $t('albumsManage.list.actionDelete') }}
</ElButton>
</template>
</ElTableColumn>
@@ -179,7 +182,8 @@
import { useI18n } from 'vue-i18n'
import { Search, Menu, Grid } from '@element-plus/icons-vue'
import AlbumCoverCard from '@/components/business/albums/AlbumCoverCard.vue'
import { fetchAlbumList } from '@/api/albums'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbumList, fetchDeleteAlbum } from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatDateTime } from '@/utils/albums/format'
@@ -258,6 +262,26 @@
router.push({ name: 'AlbumsDetail', params: { albumId: album.id }, hash: '#members' })
}
/** 软删除:确认(不可恢复说明)→ DELETE → 重载列表 */
async function remove(album: Api.Album.Summary) {
try {
await ElMessageBox.confirm(
t('albumsManage.list.deleteConfirm', { title: titleOf(album) }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteAlbum(album.id)
ElMessage.success(t('albumsManage.list.deleted'))
loadFirst()
}
onMounted(() => {
loadFirst()
})