2026-09-18 12:33:46 +08:00
|
|
|
|
<!-- 相册列表(P1b Task 10):游标分页「页内累积 + 加载更多」(模板 useTable 按
|
|
|
|
|
|
offset 建模,与契约 nextCursor 键集分页不兼容,见 composables/useCursorList) -->
|
2026-09-18 11:47:32 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="p-5">
|
2026-09-18 12:33:46 +08:00
|
|
|
|
<ElCard data-test="album-list-card">
|
2026-09-18 11:47:32 +08:00
|
|
|
|
<template #header>
|
2026-09-18 12:33:46 +08:00
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
|
<span>{{ $t('albumsManage.list.title') }}</span>
|
|
|
|
|
|
<ElButton type="primary" data-test="album-create-btn" @click="openCreate">
|
|
|
|
|
|
{{ $t('albumsManage.list.create') }}
|
|
|
|
|
|
</ElButton>
|
|
|
|
|
|
</div>
|
2026-09-18 11:47:32 +08:00
|
|
|
|
</template>
|
2026-09-18 12:33:46 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<ElTable v-else v-loading="loading" :data="items" data-test="album-list-table">
|
|
|
|
|
|
<ElTableColumn :label="$t('albumsManage.list.colTitle')" min-width="220">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
<ElTableColumn :label="$t('albumsManage.list.colToken')" min-width="200">
|
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<span class="text-gray-500" data-test="album-token">{{ row.publicToken }}</span>
|
|
|
|
|
|
<ElButton
|
|
|
|
|
|
link
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
:title="$t('albumsManage.list.copyToken')"
|
|
|
|
|
|
@click="copyToken(row.publicToken)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ElIcon><CopyDocument /></ElIcon>
|
|
|
|
|
|
</ElButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
<ElTableColumn :label="$t('albumsManage.list.colStatus')" width="90">
|
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
|
<ElTag :type="row.status === 'active' ? 'success' : 'info'">
|
|
|
|
|
|
{{
|
|
|
|
|
|
row.status === 'active'
|
|
|
|
|
|
? $t('albumsManage.list.statusActive')
|
|
|
|
|
|
: $t('albumsManage.list.statusOffline')
|
|
|
|
|
|
}}
|
|
|
|
|
|
</ElTag>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
<ElTableColumn :label="$t('albumsManage.list.colSwitches')" width="240">
|
|
|
|
|
|
<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.colStartAt')" width="160">
|
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
|
<span>{{ formatDateTime(row.startAt) }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
<ElTableColumn :label="$t('albumsManage.list.colEndAt')" width="160">
|
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
|
<span>{{
|
|
|
|
|
|
row.endAt ? formatDateTime(row.endAt) : $t('albumsManage.list.endAtUndecided')
|
|
|
|
|
|
}}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
<ElTableColumn :label="$t('albumsManage.list.colActions')" width="200" 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>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTableColumn>
|
|
|
|
|
|
|
|
|
|
|
|
<template #empty>
|
|
|
|
|
|
<ElEmpty v-if="!loading" :description="$t('albumsManage.list.empty')" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElTable>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="items.length" class="mt-4 text-center">
|
|
|
|
|
|
<ElButton v-if="hasMore" :loading="loading" data-test="album-load-more" @click="loadMore()">
|
|
|
|
|
|
{{ $t('albumsManage.list.loadMore') }}
|
|
|
|
|
|
</ElButton>
|
|
|
|
|
|
<span v-else class="text-gray-400 text-sm">{{ $t('albumsManage.list.noMore') }}</span>
|
|
|
|
|
|
</div>
|
2026-09-18 11:47:32 +08:00
|
|
|
|
</ElCard>
|
2026-09-18 12:33:46 +08:00
|
|
|
|
|
|
|
|
|
|
<AlbumFormDialog v-model="formVisible" :album="editing" @saved="onSaved" />
|
2026-09-18 11:47:32 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-09-18 12:33:46 +08:00
|
|
|
|
import { onMounted, ref } from 'vue'
|
|
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
|
import { CopyDocument } from '@element-plus/icons-vue'
|
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
|
import AlbumFormDialog from './AlbumFormDialog.vue'
|
|
|
|
|
|
import { fetchAlbumList } from '@/api/albums'
|
|
|
|
|
|
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
|
|
|
|
|
import { formatDateTime } from '@/utils/albums/format'
|
|
|
|
|
|
|
2026-09-18 11:47:32 +08:00
|
|
|
|
defineOptions({ name: 'AlbumsManage' })
|
2026-09-18 12:33:46 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
/** 列表标题按 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 四开关 → 标签展示(开=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')
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyToken(token: string) {
|
|
|
|
|
|
await navigator.clipboard.writeText(token)
|
|
|
|
|
|
ElMessage.success(t('albumsManage.list.copied'))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 创建 / 编辑 ──
|
|
|
|
|
|
const formVisible = ref(false)
|
|
|
|
|
|
const editing = ref<Api.Album.Summary | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
function openCreate() {
|
|
|
|
|
|
editing.value = null
|
|
|
|
|
|
formVisible.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openEdit(album: Api.Album.Summary) {
|
|
|
|
|
|
editing.value = album
|
|
|
|
|
|
formVisible.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onSaved() {
|
|
|
|
|
|
loadFirst()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 跳转 ──
|
|
|
|
|
|
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' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
loadFirst()
|
|
|
|
|
|
})
|
2026-09-18 11:47:32 +08:00
|
|
|
|
</script>
|