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:
@@ -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>
|
||||
Reference in New Issue
Block a user