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:
浅念
2026-09-23 10:51:53 +08:00
parent d678c8d38d
commit 2618ad3163
14 changed files with 711 additions and 28 deletions
@@ -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>