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
+192 -4
View File
@@ -1,15 +1,203 @@
<!-- 相册管理(P1b Task 9 占位 stub:登录后落地的受保护页面,后续任务填充列表/详情) -->
<!-- 相册列表(P1b Task 10):游标分页「页内累积 + 加载更多」(模板 useTable 按
offset 建模,与契约 nextCursor 键集分页不兼容,见 composables/useCursorList) -->
<template>
<div class="p-5">
<ElCard>
<ElCard data-test="album-list-card">
<template #header>
<span>{{ $t('menus.albums.manage') }}</span>
<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>
</template>
<ElEmpty :description="$t('albumsManage.placeholder')" />
<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>
</ElCard>
<AlbumFormDialog v-model="formVisible" :album="editing" @saved="onSaved" />
</div>
</template>
<script setup lang="ts">
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'
defineOptions({ name: 'AlbumsManage' })
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()
})
</script>