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
@@ -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>