Files
photo-live/apps/viewer/src/components/viewer/ShareSheet.vue
T

279 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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 { 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;
/** 二维码绘制(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, url], previous) => {
if (isOpen) {
void nextTick().then(() => {
drawQr();
sheetEl.value?.focus();
});
} else if (previous !== undefined && previous[0]) {
copyState.value = 'idle';
if (copyResetTimer !== undefined) clearTimeout(copyResetTimer);
}
void url;
},
{ 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);
}
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>
<!-- 链接文本已按用户要求隐藏(2026-09-24)——二维码 + 复制按钮保留。 -->
<div class="share-sheet-actions">
<!-- 系统分享按钮已按用户要求移除(2026-09-24)——仅保留复制链接。 -->
<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-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:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: 2px;
}
</style>