feat(assets): album asset status — show current banner/cover/logo in admin branding tab
- 契约 listAlbumAssetStatus(GET /albums/{albumId}/assets):每 (kind,locale)
最新 effective 行 + 现签 URL;权限同详情(可见即读);deleted → 404
- 服务端 AssetsService.status(createdAt,id DESC 组内取首;presignGet 现签)
- Admin 品牌资产页签改造:进入即加载现状——三种 kind 卡片(预览图/未设置
空态/设置时间),banner 分全局/zh/en 槽位;上传成功自动刷新(替换语义)
- API 56/56、admin 87/87 + build 全绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,45 @@
|
||||
签名 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">
|
||||
<ElCard data-test="album-banner-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2 class="ui06-card-title">{{ t('albumsManage.detail.banner') }}</h2>
|
||||
<p class="head-desc">{{ t('albumsManage.banner.statusHint') }}</p>
|
||||
</div>
|
||||
<ElButton :loading="statusLoading" data-test="album-banner-refresh" @click="loadStatus">
|
||||
{{ t('albumsManage.banner.refresh') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 当前生效资产(进入即加载;未设置空态)── -->
|
||||
<div v-loading="statusLoading" class="status-grid" data-test="album-banner-status-list">
|
||||
<div
|
||||
v-for="slot of statusSlots"
|
||||
:key="slot.key"
|
||||
class="asset-card"
|
||||
:data-test="`album-asset-${slot.kind}-${slot.locale ?? 'none'}`"
|
||||
>
|
||||
<div class="asset-thumb">
|
||||
<img v-if="slot.item" :src="slot.item.url" alt="" />
|
||||
<span v-else class="asset-empty">
|
||||
<ElIcon :size="22"><Picture /></ElIcon>
|
||||
<span>{{ t('albumsManage.banner.notSet') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="asset-meta">
|
||||
<strong>{{ slot.label }}</strong>
|
||||
<p v-if="slot.item">
|
||||
{{ t('albumsManage.banner.setAt', { time: formatDateTime(slot.item.createdAt) }) }}
|
||||
</p>
|
||||
<p v-else class="muted">{{ t('albumsManage.banner.notSetHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 upload-area">
|
||||
<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')" />
|
||||
@@ -76,9 +113,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchCompleteAlbumAsset, fetchInitAlbumAsset } from '@/api/albums'
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
import { fetchAlbumAssetStatus, fetchCompleteAlbumAsset, fetchInitAlbumAsset } from '@/api/albums'
|
||||
import {
|
||||
INITIAL_ASSET_UPLOAD_STATE,
|
||||
nextAssetUploadState,
|
||||
@@ -92,6 +130,63 @@
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── 当前生效资产(listAlbumAssetStatus;banner 可多语言行)──
|
||||
const statusItems = ref<Api.Album.AssetStatusItem[]>([])
|
||||
const statusLoading = ref(false)
|
||||
|
||||
const KIND_LABELS: Record<Api.Album.AssetKind, string> = {
|
||||
banner: '',
|
||||
cover: '',
|
||||
logo: ''
|
||||
}
|
||||
function kindLabel(k: Api.Album.AssetKind): string {
|
||||
KIND_LABELS.banner = t('albumsManage.banner.kindBanner')
|
||||
KIND_LABELS.cover = t('albumsManage.banner.kindCover')
|
||||
KIND_LABELS.logo = t('albumsManage.banner.kindLogo')
|
||||
return KIND_LABELS[k]
|
||||
}
|
||||
|
||||
/** 展示槽位:三种 kind × 已设置的 (kind,locale) 行;未设置的组合显示空态 */
|
||||
const statusSlots = computed(() => {
|
||||
const byKey = new Map(statusItems.value.map((it) => [`${it.kind}|${it.locale ?? ''}`, it]))
|
||||
const slots: {
|
||||
key: string
|
||||
kind: Api.Album.AssetKind
|
||||
locale: string | null
|
||||
label: string
|
||||
item: Api.Album.AssetStatusItem | null
|
||||
}[] = []
|
||||
for (const k of ['banner', 'cover', 'logo'] as const) {
|
||||
// banner:全局行 + 各语言行;cover/logo:仅全局(语言对它们无意义)
|
||||
const locales: (string | null)[] = k === 'banner' ? [null, 'zh-CN', 'en'] : [null]
|
||||
for (const loc of locales) {
|
||||
const key = `${k}|${loc ?? ''}`
|
||||
slots.push({
|
||||
key,
|
||||
kind: k,
|
||||
locale: loc,
|
||||
label: kindLabel(k) + (loc ? ` · ${loc}` : ''),
|
||||
item: byKey.get(key) ?? null
|
||||
})
|
||||
}
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
async function loadStatus() {
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const result = await fetchAlbumAssetStatus(props.albumId)
|
||||
statusItems.value = result.items
|
||||
} finally {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatus()
|
||||
})
|
||||
|
||||
const kind = ref<Api.Album.AssetKind>('banner')
|
||||
const locale = ref<'' | Api.Album.Locale>('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -192,8 +287,92 @@
|
||||
try {
|
||||
const complete = await fetchCompleteAlbumAsset(props.albumId, assetId)
|
||||
dispatch({ type: 'COMPLETE_OK', url: complete.url, expiresAt: complete.expiresAt })
|
||||
// 生效后刷新现状区(替换语义:同组旧图被替换)
|
||||
await loadStatus()
|
||||
} catch (e) {
|
||||
dispatch({ type: 'FAIL', stage: 'complete', message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.head-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #7b899f;
|
||||
}
|
||||
}
|
||||
|
||||
/* 现状卡栅格(示例 .album-grid 观感:多列小卡) */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5eaf1;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
position: relative;
|
||||
height: 120px;
|
||||
background: #dce5ef;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #7e93ab;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
padding: 12px 14px;
|
||||
background: #ffffff;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #7b899f;
|
||||
|
||||
&.muted {
|
||||
color: #97a4b5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #edf1f5;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user