feat(admin): album management pages with members and categories
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user