Files
photo-live/apps/admin/src/views/albums/manage/index.vue
T
浅念 8e674a902e 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>
2026-09-23 10:13:08 +08:00

431 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 相册列表(UI06 对齐示例 02:封面卡片默认 + 表格切换 + 搜索/状态筛选)
数据层不变:useCursorList 游标累积;搜索按当前已加载过滤(契约无全局搜索) -->
<template>
<div class="ui06-page">
<!-- 页头(示例 .page-heading) -->
<div class="page-heading">
<div>
<h1>{{ $t('albumsManage.list.title') }}</h1>
<p>{{ $t('albumsManage.list.subtitle') }}</p>
</div>
<div class="actions">
<ElButton type="primary" data-test="album-create-btn" @click="openCreate">
{{ $t('albumsManage.list.create') }}
</ElButton>
</div>
</div>
<!-- 工具栏:搜索 + 状态筛选 + 视图切换 -->
<div class="toolbar">
<ElInput
v-model="search"
class="search"
:prefix-icon="Search"
clearable
:placeholder="$t('albumsManage.list.searchPlaceholder')"
data-test="album-search"
/>
<ElSelect v-model="statusFilter" class="status-filter" data-test="album-status-filter">
<ElOption value="all" :label="$t('albumsManage.list.statusAll')" />
<ElOption value="public" :label="$t('albumsManage.list.statusPublicOnly')" />
<ElOption value="private" :label="$t('albumsManage.list.statusPrivateOnly')" />
</ElSelect>
<div class="push view-seg">
<ElTooltip :content="$t('albumsManage.list.viewGrid')" placement="top">
<button
type="button"
class="seg-btn"
:class="{ active: viewMode === 'grid' }"
data-test="album-view-grid"
@click="viewMode = 'grid'"
>
<ElIcon><Menu /></ElIcon>
</button>
</ElTooltip>
<ElTooltip :content="$t('albumsManage.list.viewTable')" placement="top">
<button
type="button"
class="seg-btn"
:class="{ active: viewMode === 'table' }"
data-test="album-view-table"
@click="viewMode = 'table'"
>
<ElIcon><Grid /></ElIcon>
</button>
</ElTooltip>
</div>
</div>
<!-- 摘要行(示例 .summary-line:不做全局统计,仅描述已加载范围) -->
<div class="summary-line">
<span>
{{ $t('albumsManage.list.loadedCount', { n: filtered.length }) }}
</span>
<span>{{ $t('albumsManage.list.cursorNote') }}</span>
</div>
<ElAlert
v-if="error && !items.length"
type="error"
:closable="false"
data-test="album-list-error"
>
<div class="flex items-center gap-2">
<span>{{ $t('albumsManage.list.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="album-list-retry" @click="loadFirst()">
{{ $t('albumsManage.list.retry') }}
</ElButton>
</div>
</ElAlert>
<!-- 封面卡片网格(默认) -->
<div v-else-if="viewMode === 'grid'" class="album-grid" data-test="album-grid">
<AlbumCoverCard
v-for="album in filtered"
:key="album.id"
:album="album"
:title-of="titleOf"
:meta-of="metaOf"
@open="gotoDetail"
/>
<div v-if="!loading && !filtered.length" class="empty-load-more">
{{ $t('albumsManage.list.emptyFiltered') }}
</div>
</div>
<!-- 表格模式(token 与开关收敛进相册列;保留关键 data-test) -->
<ElCard v-else v-loading="loading" class="table-card" data-test="album-list-card">
<ElTable :data="filtered" data-test="album-list-table">
<ElTableColumn :label="$t('albumsManage.list.colTitle')" min-width="240">
<template #default="{ row }">
<div class="flex items-center gap-2">
<span class="font-medium">{{ titleOf(row) }}</span>
<ElTag size="small" type="info">{{ row.defaultLocale }}</ElTag>
<ElTag
size="small"
:type="row.publicEnabled ? 'success' : 'info'"
:effect="row.publicEnabled ? 'light' : 'plain'"
>
{{
row.publicEnabled
? $t('albumsManage.card.public')
: $t('albumsManage.card.private')
}}
</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colStartAt')" width="170">
<template #default="{ row }">
<span>{{ formatDateTime(row.startAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colEndAt')" width="170">
<template #default="{ row }">
<span>{{
row.endAt ? formatDateTime(row.endAt) : $t('albumsManage.list.endAtUndecided')
}}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colSwitches')" width="230">
<template #default="{ row }">
<div class="flex flex-wrap gap-1" data-test="album-switch-tags">
<ElTag
v-for="sw in switchTags(row)"
:key="sw.key"
:type="sw.on ? 'success' : 'info'"
:effect="sw.on ? 'light' : 'plain'"
size="small"
:data-test="`album-switch-${sw.key}`"
>
{{ sw.label }}
{{ sw.on ? $t('albumsManage.list.on') : $t('albumsManage.list.off') }}
</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colActions')" width="220" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" data-test="album-edit-btn" @click="openEdit(row)">
{{ $t('albumsManage.list.actionEdit') }}
</ElButton>
<ElButton link type="primary" data-test="album-members-btn" @click="gotoMembers(row)">
{{ $t('albumsManage.list.actionMembers') }}
</ElButton>
<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>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.list.empty')" />
</template>
</ElTable>
</ElCard>
<div v-if="items.length" class="load-more">
<ElButton v-if="hasMore" :loading="loading" data-test="album-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
<span v-else>{{ $t('albumsManage.list.noMore') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Search, Menu, Grid } from '@element-plus/icons-vue'
import AlbumCoverCard from '@/components/business/albums/AlbumCoverCard.vue'
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'
defineOptions({ name: 'AlbumsManage' })
const router = useRouter()
const { t } = useI18n()
const PAGE_SIZE = 20
const { items, hasMore, loading, error, loadFirst, loadMore } = useCursorList<Api.Album.Summary>(
(cursor?: string) => fetchAlbumList({ cursor, limit: PAGE_SIZE }),
(item) => item.id
)
// ── 视图与筛选(UI06:默认封面卡;搜索仅过滤当前已加载——契约无全局搜索) ──
const viewMode = ref<'grid' | 'table'>('grid')
const search = ref('')
const statusFilter = ref<'all' | 'public' | 'private'>('all')
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
return items.value.filter((album) => {
if (statusFilter.value === 'public' && !album.publicEnabled) return false
if (statusFilter.value === 'private' && album.publicEnabled) return false
if (!q) return true
return (
album.translations.some((tr) => tr.title.toLowerCase().includes(q)) ||
album.publicToken.toLowerCase().includes(q)
)
})
})
/** 列表标题按 defaultLocale 解析(回退第一条翻译) */
function titleOf(album: Api.Album.Summary): string {
const preferred = album.translations.find((t) => t.locale === album.defaultLocale)
return preferred?.title ?? album.translations[0]?.title ?? album.id
}
/** 日期 · 地点(venue 走 defaultLocale 翻译) */
function metaOf(album: Api.Album.Summary): string {
const venue = album.translations.find((x) => x.locale === album.defaultLocale)?.venue
const date = album.startAt ? album.startAt.slice(0, 10) : ''
return [date, venue].filter(Boolean).join(' · ')
}
/** 表格开关标签(开=success、关=plain info) */
function switchTags(album: Api.Album.Summary) {
return [
{ key: 'upload', on: album.uploadEnabled, label: t('albumsManage.list.switchUpload') },
{ key: 'public', on: album.publicEnabled, label: t('albumsManage.list.switchPublic') },
{ key: 'download', on: album.downloadEnabled, label: t('albumsManage.list.switchDownload') },
{
key: 'original',
on: album.originalDownloadEnabled,
label: t('albumsManage.list.switchOriginal')
}
]
}
// ── 创建 / 编辑(UI06:整页表单路由) ──
function openCreate() {
router.push({ name: 'AlbumsFormCreate' })
}
function openEdit(album: Api.Album.Summary) {
router.push({ name: 'AlbumsFormEdit', params: { albumId: album.id } })
}
// ── 跳转 ──
function gotoDetail(album: Api.Album.Summary) {
router.push({ name: 'AlbumsDetail', params: { albumId: album.id } })
}
function gotoMembers(album: Api.Album.Summary) {
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()
})
</script>
<style lang="scss" scoped>
@reference '@styles/core/tailwind.css';
/* 对齐示例 .workspace / .page-heading / .toolbar / .summary-line / .album-grid / .load-more */
// .ui06-page {
// max-width: 1660px;
// padding: 28px 30px 44px;
// margin: auto;
// }
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
h1 {
font-size: 27px;
letter-spacing: -0.7px;
line-height: 1.4;
}
p {
margin-top: 6px;
font-size: 12px;
color: #7b899f;
}
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 14px;
.search {
width: 240px;
}
.status-filter {
width: 160px;
}
.push {
margin-left: auto;
}
}
.view-seg {
display: flex;
gap: 2px;
padding: 3px;
background: #edf1f7;
border-radius: 8px;
.seg-btn {
display: grid;
place-items: center;
min-height: 32px;
min-width: 38px;
padding: 7px 10px;
font-size: 11px;
color: #697d9b;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;
&.active {
color: var(--el-color-primary);
background: #ffffff;
box-shadow: 0 1px 4px #18385810;
}
}
}
.summary-line {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
font-size: 11px;
color: #8d9bb0;
}
.album-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 22px;
}
.table-card {
border: 0;
}
.load-more {
margin-top: 24px;
font-size: 11px;
color: #8b98ac;
text-align: center;
}
.empty-load-more {
grid-column: 1 / -1;
padding: 50px 0;
font-size: 12px;
color: #7b899f;
text-align: center;
}
@media (min-width: 1700px) {
.album-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 1200px) {
.album-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.album-grid {
grid-template-columns: 1fr;
}
.toolbar {
.search,
.status-filter {
width: 100%;
}
.push {
margin-left: 0;
}
}
}
</style>