Files
photo-live/apps/admin/src/views/albums/detail/AlbumPhotos.vue
T
2026-09-18 12:33:46 +08:00

249 lines
8.6 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.
<!-- 照片管理(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>