Files
photo-live/apps/admin/src/views/albums/detail/AlbumPhotos.vue
T
2026-09-20 21:10:30 +08:00

384 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.
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
单条操作:隐藏/恢复发布/软删除(行内回显);P4a Task 9 增量:
· 多选(仅 published 可选——批量隐藏的状态机前置)+ 批量隐藏(逐项结果
toast 汇总 成功x/失败y + 失败详情弹窗逐条错误码本地化;成功行原位
置 hidden,失败行不动——A16 部分成功语义)
· likeCount 副本列(P2 PhotoRecord 回传;相册级 statsSyncedAt 在详情
信息卡展示,照片行无单行同步时间)。 -->
<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>
<ElButton
type="warning"
plain
:disabled="selectedPublished.length === 0"
:loading="batchHiding"
data-test="photo-batch-hide-btn"
@click="batchHide"
>
{{ t('albumsManage.photos.batchHide') }}
<span v-if="selectedPublished.length" class="ml-1">({{ selectedPublished.length }})</span>
</ElButton>
</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"
@selection-change="onSelectionChange"
>
<ElTableColumn type="selection" width="44" :selectable="batchSelectable" />
<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.colLikeCount')" width="90">
<template #default="{ row }">
<span class="text-gray-500" data-test="photo-like-count">{{ row.likeCount }}</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>
<!-- 批量隐藏失败详情(逐条错误码本地化;成功行已原位回显) -->
<ElDialog
v-model="failuresVisible"
:title="t('albumsManage.photos.batchFailuresTitle')"
width="480px"
data-test="photo-batch-failures-dialog"
>
<ElTable :data="batchSummary?.failures ?? []" size="small">
<ElTableColumn :label="t('albumsManage.photos.batchColPhoto')" min-width="160">
<template #default="{ row }">
<span class="font-mono text-xs break-all">{{ row.photoId }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.batchColReason')" min-width="160">
<template #default="{ row }">
<ElTag type="danger" effect="plain" size="small">
{{ localizeBatchHideCode(t, row.code) }}
</ElTag>
</template>
</ElTableColumn>
</ElTable>
<template #footer>
<ElButton
type="primary"
data-test="photo-batch-failures-close"
@click="failuresVisible = false"
>
{{ t('common.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchAlbumPhotos,
fetchDeletePhoto,
fetchHidePhotosBatch,
fetchUpdatePhotoVisibility
} from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatBytes, formatDateTime } from '@/utils/albums/format'
import {
indexOfPhotoId,
localizeBatchHideCode,
summarizeBatchHideResults,
type BatchHideSummary
} from '@/utils/albums/batchHide'
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'))
}
// ── 批量隐藏(P4a A16:逐项结果部分成功) ──
/** 仅 published 可选(批量隐藏的目标态前置;其余状态行勾选禁用) */
function batchSelectable(row: Api.Album.AdminPhoto): boolean {
return row.visibility === 'published'
}
const selection = ref<Api.Album.AdminPhoto[]>([])
function onSelectionChange(rows: Api.Album.AdminPhoto[]) {
selection.value = rows
}
/** selection 经 selectable 约束本就全为 published;过滤一层兜底 */
const selectedPublished = computed(() =>
selection.value.filter((row) => row.visibility === 'published')
)
const batchHiding = ref(false)
const failuresVisible = ref(false)
const batchSummary = ref<BatchHideSummary | null>(null)
async function batchHide() {
const targets = selectedPublished.value
if (targets.length === 0) return
if (!(await confirmBox(t('albumsManage.photos.batchHideConfirm', { count: targets.length })))) {
return
}
batchHiding.value = true
try {
const result = await fetchHidePhotosBatch(
props.album.id,
targets.map((row) => row.photoId)
)
const summary = summarizeBatchHideResults(result.results)
batchSummary.value = summary
// 成功项原位置 hidden(结果与请求同序;失败行保持原样);过滤条件下
// 与目标态不符则移除
for (const item of result.results) {
if (!item.ok) continue
const index = indexOfPhotoId(items.value, item.photoId)
if (index < 0) continue
const row = items.value[index]!
if (visibilityFilter.value !== '' && visibilityFilter.value !== 'hidden') {
list.removeItem(index)
} else {
list.replaceItem(index, { ...row, visibility: 'hidden' as const })
}
}
if (summary.failCount > 0) {
failuresVisible.value = true
ElMessage.warning(
t('albumsManage.photos.batchResult', {
ok: summary.okCount,
fail: summary.failCount
})
)
} else {
ElMessage.success(t('albumsManage.photos.batchResultAllOk', { ok: summary.okCount }))
}
} finally {
batchHiding.value = false
}
}
</script>