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
+7
View File
@@ -35,6 +35,13 @@ export function fetchDeleteAlbum(albumId: string) {
return request.del<void>({ url: `/v1/albums/${albumId}` })
}
/** 各 (kind, locale) 最新生效资产(管理端现状展示;签名现签) */
export function fetchAlbumAssetStatus(albumId: string) {
return request.get<Api.Album.AssetStatusResponse>({
url: `/v1/albums/${albumId}/assets`
})
}
/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
return request.post<Api.Album.AssetInitResponse>({
+6 -1
View File
@@ -407,7 +407,12 @@
"stageError": "Upload failed",
"reupload": "Upload Again",
"preview": "Preview (signed URL, valid until {expiresAt})",
"invalidType": "Only jpg / png / heic images are supported"
"invalidType": "Only jpg / png / heic images are supported",
"statusHint": "Current effective banner / cover / logo (re-upload replaces)",
"refresh": "Refresh",
"notSet": "Not set",
"notSetHint": "Not uploaded yet; use the uploader below",
"setAt": "Set at {time}"
},
"members": {
"title": "Album members",
+6 -1
View File
@@ -407,7 +407,12 @@
"stageError": "上传失败",
"reupload": "重新上传",
"preview": "当前预览(签名 URL,{expiresAt} 前有效)",
"invalidType": "仅支持 jpg / png / heic 图片"
"invalidType": "仅支持 jpg / png / heic 图片",
"statusHint": "当前生效的宣传图 / 封面 / 标志(同类型再上传即替换)",
"refresh": "刷新",
"notSet": "未设置",
"notSetHint": "尚未上传,可选择下方上传",
"setAt": "设置于 {time}"
},
"members": {
"title": "相册成员",
+15
View File
@@ -141,6 +141,21 @@ declare namespace Api {
expiresAt: string
}
/** 生效资产现状项(UI06 listAlbumAssetStatus) */
interface AssetStatusItem {
kind: AssetKind
locale: Locale | null
url: string
expiresAt: string
contentType: string
createdAt: string
}
/** 各 (kind, locale) 最新生效资产列表 */
interface AssetStatusResponse {
items: AssetStatusItem[]
}
/** 相册成员 */
interface Member {
userId: string
@@ -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>
+2 -1
View File
@@ -17,7 +17,8 @@
"@photo/contracts": "workspace:*",
"vue": "~3.4.0",
"vue-i18n": "^9.14.0",
"vue-router": "~4.4.0"
"vue-router": "~4.4.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@photo/db-cn": "workspace:*",
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import type { PublicAlbum, SortMode } from '../../api/client';
import { shareOrCopy } from '../../support/share';
import ShareSheet from '../viewer/ShareSheet.vue';
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
@@ -122,27 +122,17 @@ const venueText = computed(() => {
const descriptionText = computed(() => props.album.translation.description);
// —— 分享(V05 §5.4 完整编排):navigator.share 优先(相册标题 + 相册路由
// URL,附 lang 参数;不分享 OSS URL),不可用/失败回退剪贴板;复制失败
// 保留可选中文本;用户取消原生分享不显示错误 ——
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
// —— 分享(2026-09-23 用户需求:改为 App 式弹层——二维码 + 链接 + 复制,
// 页面级「已复制」提示退役;深链规则不变:相册路由 + lang,不分享 OSS URL)——
const shareSheetOpen = ref(false);
const albumUrl = computed(() => {
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
return new URL(`${path}?lang=${locale.value}`, window.location.origin).href;
});
async function share(): Promise<void> {
const outcome = await shareOrCopy({ title: props.album.translation.title, url: albumUrl.value });
shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
if (outcome === 'copied') {
shareResetTimer = setTimeout(() => {
shareState.value = 'idle';
}, 3000);
}
function share(): void {
shareSheetOpen.value = true;
}
// —— 分类横向滚动的溢出提示(右侧渐隐,仅在确有未滚出内容时显示) ——
@@ -197,10 +187,12 @@ onBeforeUnmount(() => {
<button v-if="titleClamped" type="button" class="text-expand" @click="titleExpanded = !titleExpanded">
{{ titleExpanded ? t('viewer.readLess') : t('viewer.readMore') }}
</button>
<span v-if="shareState !== 'idle'" class="share-status" role="status">
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
<span v-if="shareState === 'failed'" class="share-url">{{ albumUrl }}</span>
</span>
<ShareSheet
v-model="shareSheetOpen"
:heading="t('viewer.shareAlbum')"
:title="album.translation.title"
:url="albumUrl"
/>
<p
v-if="descriptionText !== null"
@@ -0,0 +1,310 @@
<!-- apps/viewer/src/components/viewer/ShareSheet.vue —— 展示页分享弹层
(2026-09-23 用户需求:分享按钮不再页面级「已复制」提示,改为 App 式弹层)。
形态对齐 App share_qr_dialog / admin AlbumShareCard:底部弹层(手机)/
居中卡(≥640px),内容 = 标题 + 二维码 + 可选中链接 + 操作按钮。
· 二维码:qrcode canvas 绘制(viewer 深链 URL;url 变化重绘);
· 复制:copyText(clipboard API + execCommand 兜底)——成功反馈收在按钮
文案上(「已复制」2.5s 复位),不再页面级提示条;失败展示可选中文本
+ 按钮文案(微信等受限环境长按二维码/选择文本仍可达);
· 系统分享:navigator.share 可用时提供次按钮(原生面板自反馈);
· 关闭:遮罩点击 / Esc / 关闭按钮;打开时焦点移入弹层(键盘可达)。 -->
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
import { canNativeShare } from '../../support/share';
import { copyText } from '../../support/clipboard';
const props = defineProps<{
/** 弹层标题文案(如「分享相册」/「分享照片」词条)。 */
heading: string;
/** 分享目标名称(相册标题等,展示行)。 */
title: string;
/** 观众端深链(二维码与复制目标;不使用 OSS 签名 URL)。 */
url: string;
}>();
const open = defineModel<boolean>({ default: false });
const { t } = useI18n();
const sheetEl = ref<HTMLElement | null>(null);
const qrCanvas = ref<HTMLCanvasElement | null>(null);
const copyState = ref<'idle' | 'copied' | 'failed'>('idle');
let copyResetTimer: ReturnType<typeof setTimeout> | undefined;
let qrAbort: { cancel: () => void } | null = null;
const showNativeShare = canNativeShare();
/** 二维码绘制(url 变化重绘;弹层打开时按可见尺寸绘制)。 */
async function drawQr(): Promise<void> {
const canvas = qrCanvas.value;
if (canvas === null || props.url === '') return;
qrAbort?.cancel();
const token = { cancelled: false };
qrAbort = { cancel: () => { token.cancelled = true; } };
try {
await QRCode.toCanvas(canvas, props.url, {
width: 188,
margin: 1,
errorCorrectionLevel: 'M',
color: { dark: '#172033', light: '#ffffff' },
});
} catch {
// 绘制失败(理论极低):留白画布 + 链接文本仍可用(不阻塞复制路径)。
}
void token;
}
watch(
() => [open.value, props.url] as const,
([isOpen], [wasOpen]) => {
if (isOpen) {
void nextTick().then(() => {
drawQr();
sheetEl.value?.focus();
});
} else if (wasOpen) {
copyState.value = 'idle';
if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
}
},
{ immediate: true },
);
async function onCopy(): Promise<void> {
const ok = await copyText(props.url);
copyState.value = ok ? 'copied' : 'failed';
if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
copyResetTimer = setTimeout(() => {
copyState.value = 'idle';
}, 2500);
}
async function onNativeShare(): Promise<void> {
try {
await navigator.share({ title: props.title, url: props.url });
} catch {
// 取消(AbortError)或其他失败:静默——弹层仍在,复制路径可用。
}
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
open.value = false;
}
}
onBeforeUnmount(() => {
qrAbort?.cancel();
if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
});
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="share-sheet-overlay"
@pointerdown.self="open = false"
@keydown="onKeydown"
>
<div
ref="sheetEl"
class="share-sheet"
role="dialog"
aria-modal="true"
:aria-label="heading"
tabindex="-1"
>
<header class="share-sheet-head">
<p class="share-sheet-heading">{{ heading }}</p>
<button
type="button"
class="share-sheet-close"
:aria-label="t('viewer.close')"
@click="open = false"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M18.3 5.71 12 12.01l-6.3-6.3-1.4 1.41 6.29 6.3-6.3 6.3 1.42 1.4 6.29-6.29 6.3 6.3 1.4-1.42-6.3-6.29 6.3-6.3z"
/>
</svg>
</button>
</header>
<p class="share-sheet-title" aria-hidden="true">{{ title }}</p>
<div class="share-sheet-qr">
<canvas ref="qrCanvas" width="188" height="188" aria-hidden="true"></canvas>
</div>
<p class="share-sheet-url" data-test="share-sheet-url">{{ url }}</p>
<div class="share-sheet-actions">
<button
v-if="showNativeShare"
type="button"
class="share-sheet-btn is-secondary"
data-test="share-sheet-native"
@click="onNativeShare"
>
{{ t('viewer.share') }}
</button>
<button
type="button"
class="share-sheet-btn is-primary"
:data-state="copyState"
data-test="share-sheet-copy"
@click="onCopy"
>
{{
copyState === 'copied'
? t('download.copied')
: copyState === 'failed'
? t('download.copyFailed')
: t('download.copyLink')
}}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.share-sheet-overlay {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
align-items: flex-end;
justify-content: center;
background: rgba(9, 14, 20, 0.55);
}
/* 桌面:居中卡片(App 底部弹层语义在宽屏收缩为对话框)。 */
@media (min-width: 640px) {
.share-sheet-overlay {
align-items: center;
}
}
.share-sheet {
width: 100%;
max-width: 400px;
max-height: calc(100% - 24px);
overflow: auto;
background: #ffffff;
border-radius: 18px 18px 0 0;
padding: 14px 18px calc(18px + env(safe-area-inset-bottom, 0px));
outline: none;
}
@media (min-width: 640px) {
.share-sheet {
border-radius: 16px;
padding-bottom: 18px;
}
}
.share-sheet:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: -2px;
}
.share-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.share-sheet-heading {
margin: 0;
font-size: 1rem;
font-weight: 650;
color: var(--viewer-text, #172033);
}
.share-sheet-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin: -10px -10px -10px 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #647084;
cursor: pointer;
}
.share-sheet-close:hover {
background: #f3f4f6;
}
.share-sheet-close:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.share-sheet-title {
margin: 2px 0 0;
font-size: 0.8125rem;
color: #647084;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-sheet-qr {
display: flex;
justify-content: center;
margin: 12px 0 8px;
padding: 8px;
border: 1px solid var(--viewer-divider, #e5eaf1);
border-radius: 12px;
width: fit-content;
margin-inline: auto;
background: #ffffff;
}
.share-sheet-qr canvas {
display: block;
width: 188px;
height: 188px;
}
.share-sheet-url {
margin: 0 0 12px;
font-size: 0.75rem;
color: #647084;
word-break: break-all;
text-align: center;
user-select: text;
}
.share-sheet-actions {
display: flex;
gap: 10px;
}
.share-sheet-btn {
flex: 1;
min-height: 44px;
border-radius: 10px;
border: 1px solid transparent;
font-size: 0.9375rem;
cursor: pointer;
}
.share-sheet-btn.is-primary {
background: var(--viewer-primary, #1a73e8);
color: #ffffff;
font-weight: 600;
}
.share-sheet-btn.is-primary[data-state='copied'] {
background: #1e8e5a;
}
.share-sheet-btn.is-primary[data-state='failed'] {
background: #b54a4a;
}
.share-sheet-btn.is-secondary {
flex: 0 0 auto;
padding: 0 18px;
background: #ffffff;
border-color: var(--viewer-divider, #d7dfeb);
color: var(--viewer-text, #172033);
}
.share-sheet-btn:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: 2px;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

+57
View File
@@ -455,6 +455,31 @@ paths:
/albums/{albumId}/assets:
parameters:
- $ref: '#/components/parameters/AlbumId'
get:
operationId: listAlbumAssetStatus
tags: [assets]
summary: 各类资产当前生效项(管理端现状展示)
description: >-
返回每个 (kind, locale) 组合的最新 effective 资产行 + 现签短期 URL
(同「替换语义」——同组再上传即整体替换)。未设置的组合不出现;
签名过期重取本端点刷新。权限同相册详情(可见即可读)。
security:
- bearerAuth: []
responses:
'200':
description: 生效资产列表(可能为空数组)。
content:
application/json:
schema:
$ref: '#/components/schemas/AssetStatusResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: 相册不存在(ALBUM_NOT_FOUND,含已删除)。
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
operationId: initAlbumAssetUpload
tags: [assets]
@@ -3119,6 +3144,38 @@ components:
# ── 成员 ──
AssetStatusItem:
type: object
description: 单个生效资产(管理端现状展示用)。
required: [kind, locale, url, expiresAt, contentType, createdAt]
properties:
kind:
type: string
enum: [banner, cover, logo]
locale:
type: [string, "null"]
description: 语言标签(banner 可带;null=全局)。
url:
type: string
description: 现签短期 GET URL。
expiresAt:
type: string
format: date-time
contentType:
type: string
createdAt:
type: string
format: date-time
AssetStatusResponse:
type: object
description: 各 (kind, locale) 组合最新生效资产列表。
required: [items]
properties:
items:
type: array
items:
$ref: '#/components/schemas/AssetStatusItem'
MemberAddRequest:
type: object
required: [userId]
+56 -1
View File
@@ -187,7 +187,11 @@ export interface paths {
};
cookie?: never;
};
get?: never;
/**
* 各类资产当前生效项(管理端现状展示)
* @description 返回每个 (kind, locale) 组合的最新 effective 资产行 + 现签短期 URL (同「替换语义」——同组再上传即整体替换)。未设置的组合不出现; 签名过期重取本端点刷新。权限同相册详情(可见即可读)。
*/
get: operations["listAlbumAssetStatus"];
put?: never;
/**
* 初始化资产上传
@@ -1518,6 +1522,24 @@ export interface components {
/** @description 资产 ID。 */
readonly assetId: string;
};
/** @description 单个生效资产(管理端现状展示用)。 */
AssetStatusItem: {
/** @enum {string} */
kind: "banner" | "cover" | "logo";
/** @description 语言标签(banner 可带;null=全局)。 */
locale: string | null;
/** @description 现签短期 GET URL。 */
url: string;
/** Format: date-time */
expiresAt: string;
contentType: string;
/** Format: date-time */
createdAt: string;
};
/** @description 各 (kind, locale) 组合最新生效资产列表。 */
AssetStatusResponse: {
items: components["schemas"]["AssetStatusItem"][];
};
MemberAddRequest: {
/** @description 被添加用户的 ID(须为已开通账号)。 */
userId: string;
@@ -3125,6 +3147,39 @@ export interface operations {
500: components["responses"]["InternalError"];
};
};
listAlbumAssetStatus: {
parameters: {
query?: never;
header?: never;
path: {
/** @description 相册 ID。 */
albumId: components["parameters"]["AlbumId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description 生效资产列表(可能为空数组)。 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssetStatusResponse"];
};
};
401: components["responses"]["Unauthorized"];
/** @description 相册不存在(ALBUM_NOT_FOUND,含已删除)。 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
initAlbumAssetUpload: {
parameters: {
query?: never;
+3
View File
@@ -186,6 +186,9 @@ importers:
'@photo/contracts':
specifier: workspace:*
version: link:../../packages/contracts
qrcode:
specifier: ^1.5.4
version: 1.5.4
vue:
specifier: ~3.4.0
version: 3.4.38(typescript@5.9.3)
@@ -9,11 +9,13 @@ import {
Controller,
HttpCode,
HttpStatus,
Get,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import type { components } from '@photo/contracts';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { userOf } from './albums.controller';
import { AssetInitRequestDto } from './dto';
@@ -29,6 +31,14 @@ export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
/** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */
@Get()
listStatus(
@Param('albumId') albumId: string,
@Req() req: AuthenticatedRequest,
): Promise<components['schemas']['AssetStatusResponse']> {
return this.assetsService.status(userOf(req), albumId);
}
@Post()
initAlbumAssetUpload(
@Param('albumId') albumId: string,
+44
View File
@@ -63,6 +63,50 @@ export class AssetsService {
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
/**
* 各 (kind, locale) 组合最新生效资产(契约 listAlbumAssetStatus):管理端
* 现状展示。权限同相册详情(可见即可读);deleted → 404。签名现签
* (SigV4 本地计算);同组取 createdAt,id DESC 首行(替换语义)。
*/
async status(
user: AuthUser,
albumId: string,
): Promise<components['schemas']['AssetStatusResponse']> {
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true, status: true },
});
if (album === null || album.status === 'deleted') {
throw albumNotFound();
}
const rows = await this.prisma.album_assets.findMany({
where: { albumId, state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
// 每 (kind, locale|null) 组合取首行(排序已保证最新在前)
const seen = new Set<string>();
const ttl = signedUrlTtlSeconds();
const items: components['schemas']['AssetStatusItem'][] = [];
for (const row of rows) {
const key = `${row.kind}|${row.locale ?? ''}`;
if (seen.has(key)) continue;
seen.add(key);
const url = await this.storage.presignGet(row.objectKey, ttl);
items.push({
kind: row.kind as components['schemas']['AssetStatusItem']['kind'],
locale: (row.locale ?? null) as string | null,
url,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
contentType: row.contentType,
createdAt: row.createdAt.toISOString(),
});
}
return { items };
}
/**
* 初始化:权限/参数校验 → 建行(state=pending)→ presign。
* 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。