a5d71686ff
Co-Authored-By: Claude Code <noreply@anthropic.com>
279 lines
7.7 KiB
Vue
279 lines
7.7 KiB
Vue
<!-- 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>
|