feat(admin): album management pages with members and categories
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* useCursorList —— 游标分页列表(页内累积 + 加载更多)
|
||||
*
|
||||
* 模板 useTable 按 offset 分页建模(current/size/total 整页替换),与契约
|
||||
* 的 nextCursor 键集分页语义不兼容,此处做轻量适配:首次加载整页替换、
|
||||
* 「加载更多」按 nextCursor 追加。loading/error/empty 状态遵循模板约定
|
||||
* (v-loading + ElEmpty + 失败重试)。纯累积逻辑见 utils/albums/cursorList。
|
||||
*
|
||||
* 返回值建议在 setup 顶层解构使用(items/loading/error 为 ref,模板自动解包)。
|
||||
*/
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import {
|
||||
accumulatePage,
|
||||
firstPage,
|
||||
type CursorPage,
|
||||
type CursorState
|
||||
} from '@/utils/albums/cursorList'
|
||||
|
||||
export function useCursorList<T>(
|
||||
fetchPage: (cursor?: string) => Promise<CursorPage<T>>,
|
||||
keyOf?: (item: T) => string
|
||||
) {
|
||||
// shallowRef:状态整体替换(items 数组不深层响应,行内字段更新由调用方整行替换)
|
||||
const state = shallowRef<CursorState<T>>({ items: [], nextCursor: null, hasMore: false })
|
||||
const loading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
/** 首屏是否已加载过(区分「空数据」与「未加载」) */
|
||||
const loaded = ref(false)
|
||||
|
||||
const items = computed(() => state.value.items)
|
||||
const hasMore = computed(() => state.value.hasMore)
|
||||
|
||||
async function loadFirst(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
state.value = firstPage(await fetchPage())
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
error.value = e as Error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loading.value || !state.value.hasMore) return
|
||||
const cursor = state.value.nextCursor ?? undefined
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
state.value = accumulatePage(state.value, await fetchPage(cursor), keyOf)
|
||||
} catch (e) {
|
||||
error.value = e as Error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 行内整行替换(如可见性 PATCH 回包回显)。shallowRef 状态对数组原位
|
||||
* splice 不触发响应,必须整体替换 state 才会重渲染。 */
|
||||
function replaceItem(index: number, item: T): void {
|
||||
const next = state.value.items.slice()
|
||||
next.splice(index, 1, item)
|
||||
state.value = { ...state.value, items: next }
|
||||
}
|
||||
|
||||
/** 行内移除(如过滤条件下状态迁移后从当前页消失) */
|
||||
function removeItem(index: number): void {
|
||||
const next = state.value.items.slice()
|
||||
next.splice(index, 1)
|
||||
state.value = { ...state.value, items: next }
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
hasMore,
|
||||
loading,
|
||||
error,
|
||||
loaded,
|
||||
loadFirst,
|
||||
loadMore,
|
||||
replaceItem,
|
||||
removeItem
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<!-- Banner/封面/Logo 上传:assets init(预签名)→ PUT staging → complete 核验 →
|
||||
签名 URL 预览。状态机见 utils/albums/assetUpload;PUT 用裸 fetch(预签名
|
||||
URL 已含凭证,不带会话头),Content-Type 必须与 init 申明一致(核验比对)。 -->
|
||||
<template>
|
||||
<ElCard :header="t('albumsManage.detail.banner')" data-test="album-banner-card">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<ElSelect v-model="kind" style="width: 200px" data-test="album-banner-kind">
|
||||
<ElOption value="banner" :label="t('albumsManage.banner.kindBanner')" />
|
||||
<ElOption value="cover" :label="t('albumsManage.banner.kindCover')" />
|
||||
<ElOption value="logo" :label="t('albumsManage.banner.kindLogo')" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="locale" style="width: 200px" data-test="album-banner-locale">
|
||||
<ElOption :value="''" :label="t('albumsManage.banner.localeNone')" />
|
||||
<ElOption value="zh-CN" label="zh-CN" />
|
||||
<ElOption value="en" label="en" />
|
||||
</ElSelect>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="
|
||||
uploadState.stage !== 'idle' &&
|
||||
uploadState.stage !== 'done' &&
|
||||
uploadState.stage !== 'error'
|
||||
"
|
||||
:loading="busy"
|
||||
data-test="album-banner-pick"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
{{
|
||||
uploadState.stage === 'done' || uploadState.stage === 'error'
|
||||
? t('albumsManage.banner.reupload')
|
||||
: t('albumsManage.banner.pick')
|
||||
}}
|
||||
</ElButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/heic"
|
||||
class="hidden"
|
||||
data-test="album-banner-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<span class="text-gray-400 text-sm">{{ t('albumsManage.banner.pickHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadState.stage !== 'idle'" data-test="album-banner-status">
|
||||
<ElTag :type="statusTagType" :data-test="`album-banner-status-${uploadState.stage}`">
|
||||
{{ statusText }}
|
||||
</ElTag>
|
||||
<span v-if="uploadState.stage === 'error'" class="ml-2 text-red-500">
|
||||
{{ uploadState.error }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 最新生效预览:complete 返回的短期签名 URL(latest-effective 行) -->
|
||||
<div
|
||||
v-if="uploadState.stage === 'done' && uploadState.previewUrl"
|
||||
data-test="album-banner-preview"
|
||||
>
|
||||
<img
|
||||
:src="uploadState.previewUrl"
|
||||
alt="banner"
|
||||
class="max-h-40 rounded border border-gray-200"
|
||||
data-test="album-banner-preview-img"
|
||||
/>
|
||||
<div class="text-gray-400 text-sm mt-1">
|
||||
{{
|
||||
t('albumsManage.banner.preview', {
|
||||
expiresAt: formatDateTime(uploadState.previewExpiresAt)
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchCompleteAlbumAsset, fetchInitAlbumAsset } from '@/api/albums'
|
||||
import {
|
||||
INITIAL_ASSET_UPLOAD_STATE,
|
||||
nextAssetUploadState,
|
||||
type AssetUploadState
|
||||
} from '@/utils/albums/assetUpload'
|
||||
import { formatDateTime } from '@/utils/albums/format'
|
||||
|
||||
const props = defineProps<{
|
||||
albumId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const kind = ref<Api.Album.AssetKind>('banner')
|
||||
const locale = ref<'' | Api.Album.Locale>('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadState = ref<AssetUploadState>({ ...INITIAL_ASSET_UPLOAD_STATE })
|
||||
const busy = computed(() =>
|
||||
['initializing', 'uploading', 'completing'].includes(uploadState.value.stage)
|
||||
)
|
||||
|
||||
const statusTagType = computed(() => {
|
||||
switch (uploadState.value.stage) {
|
||||
case 'done':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (uploadState.value.stage) {
|
||||
case 'initializing':
|
||||
return t('albumsManage.banner.stageInitializing')
|
||||
case 'uploading':
|
||||
return t('albumsManage.banner.stageUploading')
|
||||
case 'completing':
|
||||
return t('albumsManage.banner.stageCompleting')
|
||||
case 'done':
|
||||
return t('albumsManage.banner.stageDone')
|
||||
case 'error':
|
||||
return t('albumsManage.banner.stageError')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/heic'])
|
||||
|
||||
async function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// 允许同一文件连续重传:change 处理完即复位
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (!CONTENT_TYPES.has(file.type)) {
|
||||
uploadState.value = {
|
||||
stage: 'error',
|
||||
failedStage: 'init',
|
||||
error: t('albumsManage.banner.invalidType')
|
||||
}
|
||||
return
|
||||
}
|
||||
await upload(file)
|
||||
}
|
||||
|
||||
async function upload(file: File) {
|
||||
const dispatch = (event: Parameters<typeof nextAssetUploadState>[1]) => {
|
||||
uploadState.value = nextAssetUploadState(uploadState.value, event)
|
||||
}
|
||||
|
||||
// ① init:预签名直传凭证(assetId 即一次性会话,失败重试重新 init)
|
||||
dispatch({ type: 'START' })
|
||||
let assetId: string
|
||||
let credential: Api.Album.UploadCredential
|
||||
try {
|
||||
const initResponse = await fetchInitAlbumAsset(props.albumId, {
|
||||
kind: kind.value,
|
||||
locale: locale.value === '' ? undefined : locale.value,
|
||||
filename: file.name,
|
||||
bytes: file.size,
|
||||
contentType: file.type as Api.Album.AssetInitPayload['contentType']
|
||||
})
|
||||
assetId = initResponse.assetId
|
||||
credential = initResponse.credential
|
||||
} catch (e) {
|
||||
dispatch({ type: 'FAIL', stage: 'init', message: (e as Error).message })
|
||||
return
|
||||
}
|
||||
|
||||
// ② PUT staging:裸 fetch(预签名 URL 自带凭证;headers 必须原样携带)
|
||||
dispatch({ type: 'INIT_OK' })
|
||||
try {
|
||||
const response = await fetch(credential.url, {
|
||||
method: credential.method,
|
||||
headers: credential.headers,
|
||||
body: file
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`PUT ${response.status}`)
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch({ type: 'FAIL', stage: 'put', message: (e as Error).message })
|
||||
return
|
||||
}
|
||||
|
||||
// ③ complete:核验生效并换取短期签名 URL(幂等)
|
||||
dispatch({ type: 'PUT_OK' })
|
||||
try {
|
||||
const complete = await fetchCompleteAlbumAsset(props.albumId, assetId)
|
||||
dispatch({ type: 'COMPLETE_OK', url: complete.url, expiresAt: complete.expiresAt })
|
||||
} catch (e) {
|
||||
dispatch({ type: 'FAIL', stage: 'complete', message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!-- 分类管理:列表 + 新建/编辑(sortOrder + zh/en 名称)+ 删除确认。
|
||||
分类数据来自相册详情(album.categories,按 sortOrder 升序),变更后通知
|
||||
父级重载详情。translations 全量替换语义(至少一个语言名称)。 -->
|
||||
<template>
|
||||
<div data-test="categories-panel">
|
||||
<div class="mb-4">
|
||||
<ElButton type="primary" data-test="category-create-btn" @click="openCreate">
|
||||
{{ $t('albumsManage.categories.create') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable :data="props.album.categories" data-test="category-list-table">
|
||||
<ElTableColumn :label="$t('albumsManage.categories.colSort')" prop="sortOrder" width="100" />
|
||||
<ElTableColumn :label="$t('albumsManage.categories.colZh')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span data-test="category-name-zh">{{ nameOf(row, 'zh-CN') }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('albumsManage.categories.colEn')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span data-test="category-name-en">{{ nameOf(row, 'en') }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('albumsManage.categories.colActions')" width="140">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" data-test="category-edit-btn" @click="openEdit(row)">
|
||||
{{ $t('albumsManage.categories.edit') }}
|
||||
</ElButton>
|
||||
<ElButton link type="danger" data-test="category-delete-btn" @click="remove(row)">
|
||||
{{ $t('albumsManage.categories.delete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<template #empty>
|
||||
<ElEmpty :description="$t('albumsManage.categories.empty')" />
|
||||
</template>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="editingId ? t('albumsManage.categories.edit') : t('albumsManage.categories.create')"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
data-test="category-form-dialog"
|
||||
>
|
||||
<ElForm :model="form" label-width="150px">
|
||||
<ElFormItem :label="t('albumsManage.categories.fieldSort')">
|
||||
<ElInputNumber
|
||||
v-model="form.sortOrder"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
data-test="category-form-sort"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('albumsManage.categories.fieldNameZh')" required>
|
||||
<ElInput v-model="form.nameZh" maxlength="128" data-test="category-form-name-zh" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('albumsManage.categories.fieldNameEn')" required>
|
||||
<ElInput v-model="form.nameEn" maxlength="128" data-test="category-form-name-en" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton data-test="category-form-cancel" @click="dialogVisible = false">
|
||||
{{ t('albumsManage.categories.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
data-test="category-form-submit"
|
||||
@click="submit"
|
||||
>
|
||||
{{ t('albumsManage.categories.submit') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchCreateCategory, fetchDeleteCategory, fetchUpdateCategory } from '@/api/albums'
|
||||
|
||||
const props = defineProps<{
|
||||
album: Api.Album.Detail
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
sortOrder: 0,
|
||||
nameZh: '',
|
||||
nameEn: ''
|
||||
})
|
||||
|
||||
function nameOf(category: Api.Album.Category, locale: Api.Album.Locale): string {
|
||||
return category.translations.find((item) => item.locale === locale)?.name ?? '-'
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.sortOrder = 0
|
||||
form.nameZh = ''
|
||||
form.nameEn = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(category: Api.Album.Category) {
|
||||
editingId.value = category.id
|
||||
form.sortOrder = category.sortOrder
|
||||
form.nameZh = nameOf(category, 'zh-CN') === '-' ? '' : nameOf(category, 'zh-CN')
|
||||
form.nameEn = nameOf(category, 'en') === '-' ? '' : nameOf(category, 'en')
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 至少一个语言名称(契约 translations minItems 1 + name 必填) */
|
||||
function buildTranslations(): Api.Album.CategoryTranslation[] | null {
|
||||
const translations: Api.Album.CategoryTranslation[] = []
|
||||
if (form.nameZh.trim()) translations.push({ locale: 'zh-CN', name: form.nameZh.trim() })
|
||||
if (form.nameEn.trim()) translations.push({ locale: 'en', name: form.nameEn.trim() })
|
||||
return translations.length > 0 ? translations : null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const translations = buildTranslations()
|
||||
if (!translations) {
|
||||
ElMessage.error(t('albumsManage.categories.nameRequired'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await fetchUpdateCategory(editingId.value, { sortOrder: form.sortOrder, translations })
|
||||
ElMessage.success(t('albumsManage.categories.updateSuccess'))
|
||||
} else {
|
||||
await fetchCreateCategory(props.album.id, { sortOrder: form.sortOrder, translations })
|
||||
ElMessage.success(t('albumsManage.categories.createSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
emit('changed')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(category: Api.Album.Category) {
|
||||
const fallbackName =
|
||||
category.translations.find((item) => item.locale === props.album.defaultLocale)?.name ??
|
||||
category.id
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('albumsManage.categories.deleteConfirm', { name: fallbackName }),
|
||||
t('common.tips'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel')
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
await fetchDeleteCategory(category.id)
|
||||
ElMessage.success(t('albumsManage.categories.deleteSuccess'))
|
||||
emit('changed')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
<!-- 成员管理:列表(角色标签)+ 账号前缀搜索邀请(lookupUsersByAccount,
|
||||
≥2 字符)+ 移除确认。owner 不可移除(服务端 403,前端预置禁用)。 -->
|
||||
<template>
|
||||
<div data-test="members-panel">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<ElSelect
|
||||
v-model="selectedUser"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="searchUsers"
|
||||
:loading="searching"
|
||||
:placeholder="t('albumsManage.members.searchPlaceholder')"
|
||||
style="width: 320px"
|
||||
data-test="member-search-select"
|
||||
>
|
||||
<ElOption
|
||||
v-for="user in options"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
:label="`${user.account}(${user.displayName})`"
|
||||
:data-test="`member-option-${user.account}`"
|
||||
/>
|
||||
<template #empty>
|
||||
<span class="px-4 py-2 text-gray-400 text-sm">
|
||||
{{
|
||||
query.length < 2
|
||||
? t('albumsManage.members.searchPlaceholder')
|
||||
: searching
|
||||
? t('albumsManage.members.searching')
|
||||
: t('albumsManage.members.noMatch')
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</ElSelect>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!selectedUser"
|
||||
:loading="inviting"
|
||||
data-test="member-invite-btn"
|
||||
@click="invite"
|
||||
>
|
||||
{{ t('albumsManage.members.invite') }}
|
||||
</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="member-list-table">
|
||||
<ElTableColumn :label="t('albumsManage.members.colAccount')" prop="account" min-width="140" />
|
||||
<ElTableColumn
|
||||
:label="t('albumsManage.members.colDisplayName')"
|
||||
prop="displayName"
|
||||
min-width="140"
|
||||
/>
|
||||
<ElTableColumn :label="t('albumsManage.members.colRole')" width="120">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.role === 'owner' ? 'warning' : 'info'" data-test="member-role-tag">
|
||||
{{
|
||||
row.role === 'owner'
|
||||
? t('albumsManage.members.roleOwner')
|
||||
: t('albumsManage.members.roleCollaborator')
|
||||
}}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('albumsManage.members.colActions')" width="100">
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
v-if="row.role !== 'owner'"
|
||||
link
|
||||
type="danger"
|
||||
data-test="member-remove-btn"
|
||||
@click="remove(row)"
|
||||
>
|
||||
{{ t('albumsManage.members.remove') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<template #empty>
|
||||
<ElEmpty v-if="!loading" :description="$t('albumsManage.members.empty')" />
|
||||
</template>
|
||||
</ElTable>
|
||||
|
||||
<div v-if="items.length && hasMore" class="mt-3 text-center">
|
||||
<ElButton :loading="loading" data-test="member-load-more" @click="loadMore()">
|
||||
{{ $t('albumsManage.list.loadMore') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
fetchAddAlbumMember,
|
||||
fetchAlbumMembers,
|
||||
fetchLookupUsers,
|
||||
fetchRemoveAlbumMember
|
||||
} from '@/api/albums'
|
||||
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
||||
|
||||
const props = defineProps<{
|
||||
album: Api.Album.Detail
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { items, hasMore, loading, error, loadFirst, loadMore } = useCursorList<Api.Album.Member>(
|
||||
(cursor?: string) => fetchAlbumMembers(props.album.id, { cursor, limit: 20 }),
|
||||
(item) => item.userId
|
||||
)
|
||||
|
||||
// ── 账号前缀搜索(≥2 字符才发起;短前缀清空候选) ──
|
||||
const query = ref('')
|
||||
const options = ref<Api.Album.UserLookupItem[]>([])
|
||||
const searching = ref(false)
|
||||
const selectedUser = ref('')
|
||||
const inviting = ref(false)
|
||||
|
||||
async function searchUsers(prefix: string) {
|
||||
query.value = prefix
|
||||
options.value = []
|
||||
if (prefix.trim().length < 2) return
|
||||
searching.value = true
|
||||
try {
|
||||
const result = await fetchLookupUsers(prefix.trim())
|
||||
options.value = result.items
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function invite() {
|
||||
if (!selectedUser.value) return
|
||||
inviting.value = true
|
||||
try {
|
||||
await fetchAddAlbumMember(props.album.id, selectedUser.value)
|
||||
ElMessage.success(t('albumsManage.members.invited'))
|
||||
selectedUser.value = ''
|
||||
options.value = []
|
||||
await loadFirst()
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(member: Api.Album.Member) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('albumsManage.members.removeConfirm', { account: member.account }),
|
||||
t('common.tips'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel')
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
await fetchRemoveAlbumMember(props.album.id, member.userId)
|
||||
ElMessage.success(t('albumsManage.members.removed'))
|
||||
await loadFirst()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFirst()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,248 @@
|
||||
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
|
||||
单条操作:隐藏(published→hidden)/恢复发布(hidden→published)/软删除;
|
||||
批量隐藏属 P4a,此处仅单条。操作结果按条回显(行内状态即时更新 + 消息)。 -->
|
||||
<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>
|
||||
</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">
|
||||
<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.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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchAlbumPhotos, fetchDeletePhoto, fetchUpdatePhotoVisibility } from '@/api/albums'
|
||||
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
||||
import { formatBytes, formatDateTime } from '@/utils/albums/format'
|
||||
|
||||
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'))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,206 @@
|
||||
<!-- 相册详情:基础信息 + banner 上传 + 照片/成员/分类管理页签(P1b Task 10)。
|
||||
路由 /albums/detail/:albumId(isHide,不进菜单)。 -->
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<ElAlert v-if="error" type="error" :closable="false" data-test="album-detail-error">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ $t('albumsManage.detail.loadFailed') }}:{{ error.message }}</span>
|
||||
<ElButton size="small" data-test="album-detail-retry" @click="load">
|
||||
{{ $t('albumsManage.detail.retry') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElAlert>
|
||||
|
||||
<ElCard v-else v-loading="loading" :data-test="'album-detail-card'">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton link @click="router.back()" data-test="album-detail-back">
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</ElButton>
|
||||
<span class="font-medium">{{ titleOf(album) }}</span>
|
||||
<ElTag size="small" type="info">{{ album?.defaultLocale }}</ElTag>
|
||||
</div>
|
||||
<ElButton type="primary" plain data-test="album-detail-edit" @click="editVisible = true">
|
||||
{{ $t('albumsManage.detail.edit') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElDescriptions
|
||||
v-if="album"
|
||||
:column="3"
|
||||
border
|
||||
:title="$t('albumsManage.detail.basicInfo')"
|
||||
data-test="album-detail-desc"
|
||||
>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldTitle')">
|
||||
{{ titleOf(album) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldToken')">
|
||||
<span class="flex items-center gap-1">
|
||||
<span data-test="album-detail-token">{{ album.publicToken }}</span>
|
||||
<ElButton link type="primary" size="small" @click="copyToken(album.publicToken)">
|
||||
<ElIcon><CopyDocument /></ElIcon>
|
||||
</ElButton>
|
||||
</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldStatus')">
|
||||
<ElTag :type="album.status === 'active' ? 'success' : 'info'">
|
||||
{{
|
||||
album.status === 'active'
|
||||
? $t('albumsManage.list.statusActive')
|
||||
: $t('albumsManage.list.statusOffline')
|
||||
}}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.list.colSwitches')">
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<ElTag
|
||||
v-for="sw in switchTags(album)"
|
||||
:key="sw.key"
|
||||
size="small"
|
||||
:type="sw.on ? 'success' : 'info'"
|
||||
:effect="sw.on ? 'light' : 'plain'"
|
||||
>
|
||||
{{ sw.label }} {{ sw.on ? $t('albumsManage.list.on') : $t('albumsManage.list.off') }}
|
||||
</ElTag>
|
||||
</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldTimezone')">
|
||||
{{ album.timezone }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldStartAt')">
|
||||
{{ formatDateTime(album.startAt) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldEndAt')">
|
||||
{{ album.endAt ? formatDateTime(album.endAt) : $t('albumsManage.list.endAtUndecided') }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldCreatedAt')">
|
||||
{{ formatDateTime(album.createdAt) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldUpdatedAt')">
|
||||
{{ formatDateTime(album.updatedAt) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<div v-if="album" class="mt-5">
|
||||
<AlbumBannerUpload :album-id="albumId" />
|
||||
</div>
|
||||
|
||||
<ElTabs v-if="album" v-model="activeTab" class="mt-4" data-test="album-detail-tabs">
|
||||
<!-- 页签头由 ElTabs 自动生成(pane 上的 attr 只落在隐藏面板上):
|
||||
data-test 经 #label 插槽挂到头部文案,供 e2e 点击 -->
|
||||
<ElTabPane name="photos">
|
||||
<template #label>
|
||||
<span data-test="album-photos-tab">{{ $t('albumsManage.detail.tabPhotos') }}</span>
|
||||
</template>
|
||||
<AlbumPhotos :album="album" />
|
||||
</ElTabPane>
|
||||
<ElTabPane name="members">
|
||||
<template #label>
|
||||
<span data-test="album-members-tab">{{ $t('albumsManage.detail.tabMembers') }}</span>
|
||||
</template>
|
||||
<AlbumMembers :album="album" />
|
||||
</ElTabPane>
|
||||
<ElTabPane name="categories">
|
||||
<template #label>
|
||||
<span data-test="album-categories-tab">
|
||||
{{ $t('albumsManage.detail.tabCategories') }}
|
||||
</span>
|
||||
</template>
|
||||
<AlbumCategories :album="album" @changed="load" />
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCard>
|
||||
|
||||
<AlbumFormDialog v-if="album" v-model="editVisible" :album="album" @saved="onAlbumSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ArrowLeft, CopyDocument } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AlbumFormDialog from '@/views/albums/manage/AlbumFormDialog.vue'
|
||||
import AlbumBannerUpload from './AlbumBannerUpload.vue'
|
||||
import AlbumMembers from './AlbumMembers.vue'
|
||||
import AlbumCategories from './AlbumCategories.vue'
|
||||
import AlbumPhotos from './AlbumPhotos.vue'
|
||||
import { fetchAlbum } from '@/api/albums'
|
||||
import { formatDateTime } from '@/utils/albums/format'
|
||||
|
||||
defineOptions({ name: 'AlbumsDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const albumId = computed(() => String(route.params.albumId ?? ''))
|
||||
|
||||
const album = ref<Api.Album.Detail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const editVisible = ref(false)
|
||||
const activeTab = ref('photos')
|
||||
|
||||
async function load() {
|
||||
if (!albumId.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
album.value = await fetchAlbum(albumId.value)
|
||||
} catch (e) {
|
||||
error.value = e as Error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onAlbumSaved(saved: Api.Album.Detail) {
|
||||
album.value = saved
|
||||
}
|
||||
|
||||
function titleOf(target: Api.Album.Detail | null): string {
|
||||
if (!target) return ''
|
||||
const preferred = target.translations.find((item) => item.locale === target.defaultLocale)
|
||||
return preferred?.title ?? target.translations[0]?.title ?? target.id
|
||||
}
|
||||
|
||||
function switchTags(target: Api.Album.Detail) {
|
||||
return [
|
||||
{ key: 'upload', on: target.uploadEnabled, label: t('albumsManage.list.switchUpload') },
|
||||
{ key: 'public', on: target.publicEnabled, label: t('albumsManage.list.switchPublic') },
|
||||
{ key: 'download', on: target.downloadEnabled, label: t('albumsManage.list.switchDownload') },
|
||||
{
|
||||
key: 'original',
|
||||
on: target.originalDownloadEnabled,
|
||||
label: t('albumsManage.list.switchOriginal')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function copyToken(token: string) {
|
||||
await navigator.clipboard.writeText(token)
|
||||
ElMessage.success(t('albumsManage.list.copied'))
|
||||
}
|
||||
|
||||
/** 列表「成员」入口带 #members 锚点:直达成员页签 */
|
||||
watch(
|
||||
() => route.hash,
|
||||
(hash) => {
|
||||
if (hash === '#members') activeTab.value = 'members'
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(albumId, () => {
|
||||
if (route.name === 'AlbumsDetail') load()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,294 @@
|
||||
<!-- 创建/编辑相册对话框:zh-CN/en 翻译分页签 + 契约镜像校验(utils/albums/albumValidation)。
|
||||
编辑为 PATCH 语义;translations 全量替换(提示 i18n)。 -->
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? t('albumsManage.form.editTitle') : t('albumsManage.form.createTitle')"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
data-test="album-form-dialog"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<ElForm :model="form" label-width="130px">
|
||||
<ElFormItem :label="t('albumsManage.form.fieldDefaultLocale')">
|
||||
<ElRadioGroup v-model="form.defaultLocale" data-test="album-form-default-locale">
|
||||
<ElRadioButton value="zh-CN">zh-CN</ElRadioButton>
|
||||
<ElRadioButton value="en">en</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<ElTabs v-model="activeTab">
|
||||
<ElTabPane :label="t('albumsManage.form.tabZh')" name="zh-CN">
|
||||
<TranslationFields v-model="drafts['zh-CN']" :locale="'zh-CN'" />
|
||||
</ElTabPane>
|
||||
<ElTabPane :label="t('albumsManage.form.tabEn')" name="en">
|
||||
<TranslationFields v-model="drafts['en']" :locale="'en'" />
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
|
||||
<ElFormItem
|
||||
:label="t('albumsManage.form.fieldStartAt')"
|
||||
required
|
||||
:error="fieldErrors.startAt"
|
||||
>
|
||||
<!-- ElDatePicker 的透传 attr 被 ElTooltip 吞掉,data-test 挂不到 DOM:
|
||||
外层普通 div 承载选择器,测试经 div 内 input 定位 -->
|
||||
<div data-test="album-form-start-at">
|
||||
<ElDatePicker
|
||||
v-model="form.startAt"
|
||||
type="datetime"
|
||||
:placeholder="t('albumsManage.form.fieldStartAt')"
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('albumsManage.form.fieldEndAt')" :error="fieldErrors.endAt">
|
||||
<div data-test="album-form-end-at">
|
||||
<ElDatePicker
|
||||
v-model="form.endAt"
|
||||
type="datetime"
|
||||
clearable
|
||||
:placeholder="t('albumsManage.form.fieldEndAt')"
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('albumsManage.form.fieldTimezone')">
|
||||
<ElInput
|
||||
v-model="form.timezone"
|
||||
data-test="album-form-timezone"
|
||||
placeholder="Asia/Shanghai"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="t('albumsManage.form.fieldSwitches')">
|
||||
<div class="flex flex-col gap-2" data-test="album-form-switches">
|
||||
<template v-if="isEdit">
|
||||
<ElSwitch
|
||||
v-model="form.uploadEnabled"
|
||||
:active-text="t('albumsManage.form.switchUpload')"
|
||||
data-test="album-form-switch-upload"
|
||||
/>
|
||||
<ElSwitch
|
||||
v-model="form.publicEnabled"
|
||||
:active-text="t('albumsManage.form.switchPublic')"
|
||||
data-test="album-form-switch-public"
|
||||
/>
|
||||
</template>
|
||||
<ElSwitch
|
||||
v-model="form.downloadEnabled"
|
||||
:active-text="t('albumsManage.form.switchDownload')"
|
||||
data-test="album-form-switch-download"
|
||||
/>
|
||||
<ElSwitch
|
||||
v-model="form.originalDownloadEnabled"
|
||||
:active-text="t('albumsManage.form.switchOriginal')"
|
||||
data-test="album-form-switch-original"
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElAlert
|
||||
v-if="isEdit"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
:title="t('albumsManage.form.translationsReplaceHint')"
|
||||
data-test="album-form-replace-hint"
|
||||
/>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton data-test="album-form-cancel" @click="visible = false">
|
||||
{{ t('albumsManage.form.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" data-test="album-form-submit" @click="submit">
|
||||
{{ t('albumsManage.form.submit') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import TranslationFields from './TranslationFields.vue'
|
||||
import { fetchCreateAlbum, fetchUpdateAlbum } from '@/api/albums'
|
||||
import {
|
||||
toTranslationPayload,
|
||||
validateTimeRange,
|
||||
validateTranslations,
|
||||
type TranslationDraft
|
||||
} from '@/utils/albums/albumValidation'
|
||||
|
||||
/** 翻译草稿形状(两语言固定键,模板按键取值) */
|
||||
type DraftMap = Record<'zh-CN' | 'en', TranslationDraft>
|
||||
|
||||
const props = defineProps<{
|
||||
/** 编辑目标(null = 创建) */
|
||||
album: Api.Album.Summary | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved', album: Api.Album.Detail): void
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const isEdit = computed(() => props.album !== null)
|
||||
|
||||
const emptyDraft = (locale: 'zh-CN' | 'en'): TranslationDraft => ({
|
||||
locale,
|
||||
title: '',
|
||||
description: '',
|
||||
venue: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
const activeTab = ref<'zh-CN' | 'en'>('zh-CN')
|
||||
const drafts = reactive<DraftMap>({
|
||||
'zh-CN': emptyDraft('zh-CN'),
|
||||
en: emptyDraft('en')
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
defaultLocale: 'zh-CN' as Api.Album.Locale,
|
||||
startAt: null as Date | null,
|
||||
endAt: null as Date | null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
uploadEnabled: true,
|
||||
publicEnabled: true,
|
||||
downloadEnabled: true,
|
||||
originalDownloadEnabled: false
|
||||
})
|
||||
|
||||
const fieldErrors = reactive<{ startAt: string; endAt: string }>({ startAt: '', endAt: '' })
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 打开时装载编辑目标(或重置为创建默认值) */
|
||||
watch(visible, (open) => {
|
||||
if (!open) return
|
||||
resetForm()
|
||||
const album = props.album
|
||||
if (!album) return
|
||||
form.defaultLocale = album.defaultLocale
|
||||
form.startAt = new Date(album.startAt)
|
||||
form.endAt = album.endAt ? new Date(album.endAt) : null
|
||||
form.timezone = album.timezone
|
||||
form.uploadEnabled = album.uploadEnabled
|
||||
form.publicEnabled = album.publicEnabled
|
||||
form.downloadEnabled = album.downloadEnabled
|
||||
form.originalDownloadEnabled = album.originalDownloadEnabled
|
||||
for (const locale of ['zh-CN', 'en'] as const) {
|
||||
const translation = album.translations.find((item) => item.locale === locale)
|
||||
drafts[locale] = {
|
||||
...emptyDraft(locale),
|
||||
title: translation?.title ?? '',
|
||||
description: translation?.description ?? '',
|
||||
venue: translation?.venue ?? '',
|
||||
address: translation?.address ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
activeTab.value = 'zh-CN'
|
||||
drafts['zh-CN'] = emptyDraft('zh-CN')
|
||||
drafts.en = emptyDraft('en')
|
||||
Object.assign(form, {
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: null,
|
||||
endAt: null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
uploadEnabled: true,
|
||||
publicEnabled: true,
|
||||
downloadEnabled: true,
|
||||
originalDownloadEnabled: false
|
||||
})
|
||||
fieldErrors.startAt = ''
|
||||
fieldErrors.endAt = ''
|
||||
}
|
||||
|
||||
/** 提交前校验(镜像契约:默认语言覆盖 + endAt>startAt) */
|
||||
function validate(): boolean {
|
||||
fieldErrors.startAt = ''
|
||||
fieldErrors.endAt = ''
|
||||
|
||||
if (!form.startAt) {
|
||||
fieldErrors.startAt = t('albumsManage.form.errors.startAtRequired')
|
||||
return false
|
||||
}
|
||||
|
||||
const translationErrors = validateTranslations([drafts['zh-CN'], drafts.en], form.defaultLocale)
|
||||
if (translationErrors.length > 0) {
|
||||
const code = translationErrors[0]
|
||||
// 定位到出错的页签并切换,便于用户直接修补
|
||||
const other =
|
||||
code === 'titleRequiredWithFields' &&
|
||||
!drafts.en.title.trim() &&
|
||||
drafts['zh-CN'].title.trim()
|
||||
? 'en'
|
||||
: null
|
||||
activeTab.value = other ?? form.defaultLocale
|
||||
ElMessage.error(
|
||||
code === 'defaultLocaleMissing'
|
||||
? t('albumsManage.form.errors.defaultLocaleMissing', { locale: form.defaultLocale })
|
||||
: t(`albumsManage.form.errors.${code}`)
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const startIso = form.startAt.toISOString()
|
||||
const endIso = form.endAt ? form.endAt.toISOString() : null
|
||||
if (!validateTimeRange(startIso, endIso)) {
|
||||
fieldErrors.endAt = t('albumsManage.form.errors.endAtNotAfterStartAt')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const translations = toTranslationPayload([
|
||||
drafts['zh-CN'],
|
||||
drafts.en
|
||||
]) as Api.Album.Translation[]
|
||||
const endAt = form.endAt ? form.endAt.toISOString() : null
|
||||
let saved: Api.Album.Detail
|
||||
if (props.album) {
|
||||
// 编辑:PATCH 全量可编辑字段(开关独立修改;translations 全量替换)
|
||||
saved = await fetchUpdateAlbum(props.album.id, {
|
||||
translations,
|
||||
defaultLocale: form.defaultLocale,
|
||||
startAt: form.startAt!.toISOString(),
|
||||
endAt,
|
||||
timezone: form.timezone.trim() || 'Asia/Shanghai',
|
||||
uploadEnabled: form.uploadEnabled,
|
||||
publicEnabled: form.publicEnabled,
|
||||
downloadEnabled: form.downloadEnabled,
|
||||
originalDownloadEnabled: form.originalDownloadEnabled
|
||||
})
|
||||
} else {
|
||||
// 创建:契约仅 download 两开关(upload/public 服务端固定初始 true)
|
||||
saved = await fetchCreateAlbum({
|
||||
translations,
|
||||
defaultLocale: form.defaultLocale,
|
||||
startAt: form.startAt!.toISOString(),
|
||||
endAt,
|
||||
timezone: form.timezone.trim() || 'Asia/Shanghai',
|
||||
downloadEnabled: form.downloadEnabled,
|
||||
originalDownloadEnabled: form.originalDownloadEnabled
|
||||
})
|
||||
}
|
||||
ElMessage.success(
|
||||
isEdit.value ? t('albumsManage.form.updateSuccess') : t('albumsManage.form.createSuccess')
|
||||
)
|
||||
emit('saved', saved)
|
||||
visible.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!-- 单语言翻译字段组(标题/描述/场地/地址;标题为契约必填项) -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-0" :data-test="`album-form-translation-${locale}`">
|
||||
<ElFormItem :label="t('albumsManage.form.fieldTitle')" required>
|
||||
<ElInput
|
||||
v-model="model.title"
|
||||
:placeholder="t('albumsManage.form.fieldTitle')"
|
||||
:data-test="`album-form-title-${locale}`"
|
||||
maxlength="255"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('albumsManage.form.fieldDescription')">
|
||||
<ElInput
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('albumsManage.form.fieldDescription')"
|
||||
:data-test="`album-form-description-${locale}`"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('albumsManage.form.fieldVenue')">
|
||||
<ElInput
|
||||
v-model="model.venue"
|
||||
:placeholder="t('albumsManage.form.fieldVenue')"
|
||||
:data-test="`album-form-venue-${locale}`"
|
||||
maxlength="255"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('albumsManage.form.fieldAddress')">
|
||||
<ElInput
|
||||
v-model="model.address"
|
||||
:placeholder="t('albumsManage.form.fieldAddress')"
|
||||
:data-test="`album-form-address-${locale}`"
|
||||
maxlength="255"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { TranslationDraft } from '@/utils/albums/albumValidation'
|
||||
|
||||
defineOptions({ name: 'AlbumTranslationFields' })
|
||||
|
||||
defineProps<{
|
||||
locale: Api.Album.Locale
|
||||
}>()
|
||||
|
||||
const model = defineModel<TranslationDraft>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -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