2026-09-18 12:33:46 +08:00
|
|
|
|
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
|
2026-09-20 21:10:30 +08:00
|
|
|
|
单条操作:隐藏/恢复发布/软删除(行内回显);P4a Task 9 增量:
|
|
|
|
|
|
· 多选(仅 published 可选——批量隐藏的状态机前置)+ 批量隐藏(逐项结果
|
|
|
|
|
|
toast 汇总 成功x/失败y + 失败详情弹窗逐条错误码本地化;成功行原位
|
|
|
|
|
|
置 hidden,失败行不动——A16 部分成功语义)
|
|
|
|
|
|
· likeCount 副本列(P2 PhotoRecord 回传;相册级 statsSyncedAt 在详情
|
|
|
|
|
|
信息卡展示,照片行无单行同步时间)。 -->
|
2026-09-18 12:33:46 +08:00
|
|
|
|
<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>
|
2026-09-20 21:10:30 +08:00
|
|
|
|
<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>
|
2026-09-18 12:33:46 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ElAlert v-if="error && !items.length" type="error" :closable="false">
|
|
|
|
|
|
{{ error.message }}
|
|
|
|
|
|
</ElAlert>
|
|
|
|
|
|
|
2026-09-20 21:10:30 +08:00
|
|
|
|
<ElTable
|
|
|
|
|
|
v-else
|
|
|
|
|
|
v-loading="loading"
|
|
|
|
|
|
:data="items"
|
|
|
|
|
|
data-test="photo-list-table"
|
|
|
|
|
|
@selection-change="onSelectionChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ElTableColumn type="selection" width="44" :selectable="batchSelectable" />
|
2026-09-18 12:33:46 +08:00
|
|
|
|
<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>
|
2026-09-20 21:10:30 +08:00
|
|
|
|
<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>
|
2026-09-18 12:33:46 +08:00
|
|
|
|
<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>
|
2026-09-20 21:10:30 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 批量隐藏失败详情(逐条错误码本地化;成功行已原位回显) -->
|
|
|
|
|
|
<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>
|
2026-09-18 12:33:46 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-09-20 21:10:30 +08:00
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
2026-09-18 12:33:46 +08:00
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
2026-09-20 21:10:30 +08:00
|
|
|
|
import {
|
|
|
|
|
|
fetchAlbumPhotos,
|
|
|
|
|
|
fetchDeletePhoto,
|
|
|
|
|
|
fetchHidePhotosBatch,
|
|
|
|
|
|
fetchUpdatePhotoVisibility
|
|
|
|
|
|
} from '@/api/albums'
|
2026-09-18 12:33:46 +08:00
|
|
|
|
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
|
|
|
|
|
import { formatBytes, formatDateTime } from '@/utils/albums/format'
|
2026-09-20 21:10:30 +08:00
|
|
|
|
import {
|
|
|
|
|
|
indexOfPhotoId,
|
|
|
|
|
|
localizeBatchHideCode,
|
|
|
|
|
|
summarizeBatchHideResults,
|
|
|
|
|
|
type BatchHideSummary
|
|
|
|
|
|
} from '@/utils/albums/batchHide'
|
2026-09-18 12:33:46 +08:00
|
|
|
|
|
|
|
|
|
|
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'))
|
|
|
|
|
|
}
|
2026-09-20 21:10:30 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 批量隐藏(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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-18 12:33:46 +08:00
|
|
|
|
</script>
|