feat(admin): album management pages with members and categories

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 12:33:46 +08:00
parent a6cc190751
commit 50c59ca9d4
27 changed files with 2977 additions and 16 deletions
@@ -0,0 +1,248 @@
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
单条操作:隐藏(published→hidden)/恢复发布(hidden→published)/软删除;
批量隐藏属 P4a,此处仅单条。操作结果按条回显(行内状态即时更新 + 消息)。 -->
<template>
<div data-test="photos-panel">
<div class="flex flex-wrap items-center gap-3 mb-4">
<ElSelect
v-model="visibilityFilter"
clearable
:placeholder="t('albumsManage.photos.filterVisibility')"
style="width: 160px"
data-test="photo-visibility-filter"
@change="reload"
>
<ElOption value="unpublished" :label="t('albumsManage.photos.visibilityUnpublished')" />
<ElOption value="published" :label="t('albumsManage.photos.visibilityPublished')" />
<ElOption value="hidden" :label="t('albumsManage.photos.visibilityHidden')" />
<ElOption value="deleted" :label="t('albumsManage.photos.visibilityDeleted')" />
</ElSelect>
<ElSelect
v-model="categoryFilter"
clearable
:placeholder="t('albumsManage.photos.filterCategory')"
style="width: 200px"
data-test="photo-category-filter"
@change="reload"
>
<ElOption
v-for="category in props.album.categories"
:key="category.id"
:value="category.id"
:label="categoryLabel(category)"
/>
</ElSelect>
</div>
<ElAlert v-if="error && !items.length" type="error" :closable="false">
{{ error.message }}
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="photo-list-table">
<ElTableColumn :label="t('albumsManage.photos.colState')" width="120">
<template #default="{ row }">
<ElTag :type="stateTagType(row.processingState)" data-test="photo-state-tag">
{{ t(`albumsManage.photos.state${pascal(row.processingState)}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colVisibility')" width="110">
<template #default="{ row }">
<ElTag
:type="visibilityTagType(row.visibility)"
:effect="row.visibility === 'deleted' ? 'plain' : 'light'"
data-test="photo-visibility-tag"
>
{{ t(`albumsManage.photos.visibility${pascal(row.visibility)}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colSize')" min-width="150">
<template #default="{ row }">
<span class="text-gray-500">
{{ row.width && row.height ? `${row.width}×${row.height}` : '—' }} ·
{{ formatBytes(row.bytes) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colUploader')" min-width="140">
<template #default="{ row }">
<span>{{ row.uploader.displayName || row.uploader.account }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colUploadedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.uploadedAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colActions')" width="200" fixed="right">
<template #default="{ row, $index }">
<ElButton
v-if="row.visibility === 'published'"
link
type="warning"
data-test="photo-hide-btn"
@click="setVisibility(row, $index, 'hidden')"
>
{{ t('albumsManage.photos.hide') }}
</ElButton>
<ElButton
v-if="row.visibility === 'hidden'"
link
type="success"
data-test="photo-restore-btn"
@click="setVisibility(row, $index, 'published')"
>
{{ t('albumsManage.photos.restore') }}
</ElButton>
<ElButton
v-if="row.visibility !== 'deleted'"
link
type="danger"
data-test="photo-delete-btn"
@click="softDelete(row, $index)"
>
{{ t('albumsManage.photos.delete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.photos.empty')" />
</template>
</ElTable>
<div v-if="items.length && hasMore" class="mt-3 text-center">
<ElButton :loading="loading" data-test="photo-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbumPhotos, fetchDeletePhoto, fetchUpdatePhotoVisibility } from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatBytes, formatDateTime } from '@/utils/albums/format'
const props = defineProps<{
album: Api.Album.Detail
}>()
const { t } = useI18n()
const visibilityFilter = ref<Api.Album.PhotoVisibility | ''>('')
const categoryFilter = ref('')
const list = useCursorList<Api.Album.AdminPhoto>(
(cursor?: string) =>
fetchAlbumPhotos(props.album.id, {
visibility: visibilityFilter.value === '' ? undefined : visibilityFilter.value,
categoryId: categoryFilter.value === '' ? undefined : categoryFilter.value,
cursor,
limit: 20
}),
(item) => item.photoId
)
const { items, hasMore, loading, error, loadFirst, loadMore } = list
function reload() {
loadFirst()
}
onMounted(() => {
loadFirst()
})
/** processing_failed → ProcessingFailed(拼 i18n 键) */
function pascal(value: string): string {
return value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
function stateTagType(state: Api.Album.PhotoProcessingState) {
switch (state) {
case 'ready':
return 'success'
case 'processing':
return 'warning'
case 'processing_failed':
return 'danger'
default:
return 'info'
}
}
function visibilityTagType(visibility: Api.Album.PhotoVisibility) {
switch (visibility) {
case 'published':
return 'success'
case 'hidden':
return 'warning'
case 'deleted':
return 'danger'
default:
return 'info'
}
}
function categoryLabel(category: Api.Album.Category): string {
const preferred = category.translations.find(
(item) => item.locale === props.album.defaultLocale
)
return preferred?.name ?? category.translations[0]?.name ?? category.id
}
async function confirmBox(message: string): Promise<boolean> {
try {
await ElMessageBox.confirm(message, t('common.tips'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
})
return true
} catch {
return false
}
}
/** 隐藏/恢复:PATCH 回包按行回显(状态机仅允许 hidden ↔ published)。
* shallowRef 列表对原位 splice 不响应,统一走 replaceItem/removeItem。 */
async function setVisibility(
row: Api.Album.AdminPhoto,
index: number,
target: 'hidden' | 'published'
) {
const message =
target === 'hidden'
? t('albumsManage.photos.hideConfirm')
: t('albumsManage.photos.restoreConfirm')
if (!(await confirmBox(message))) return
const updated = await fetchUpdatePhotoVisibility(row.photoId, target)
// 过滤条件下回显:当前过滤与目标态不符时从列表移除,否则原位更新
if (visibilityFilter.value !== '' && visibilityFilter.value !== updated.visibility) {
list.removeItem(index)
} else {
list.replaceItem(index, { ...row, ...updated })
}
ElMessage.success(
target === 'hidden' ? t('albumsManage.photos.hidden') : t('albumsManage.photos.restored')
)
}
/** 软删除:204 无回包,本地标记 deleted(deleted 为员工侧终态) */
async function softDelete(row: Api.Album.AdminPhoto, index: number) {
if (!(await confirmBox(t('albumsManage.photos.deleteConfirm')))) return
await fetchDeletePhoto(row.photoId)
if (visibilityFilter.value !== '' && visibilityFilter.value !== 'deleted') {
list.removeItem(index)
} else {
list.replaceItem(index, { ...row, visibility: 'deleted' as const })
}
ElMessage.success(t('albumsManage.photos.deleted'))
}
</script>