重构作品分享链路
统一发布分享弹窗为作品分享卡片 支持下载分享卡与小程序九宫切图保存 小程序复制链接改为可直达作品详情的 web-view 路径 修复本地 dev Rust 构建绕过损坏 sccache 补充分享链路与 dev 启动文档和测试
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
canUseWechatMiniProgramShareGrid,
|
||||
openWechatMiniProgramShareGridPage,
|
||||
} from '../../services/wechatMiniProgramShareGrid';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { downloadPublishShareCardImage } from './publishShareCardImage';
|
||||
import {
|
||||
buildPublishShareCopyUrl,
|
||||
type PublishShareModalPayload,
|
||||
} from './publishShareModalModel';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
@@ -15,78 +23,27 @@ type PublishShareModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type ShareChannelId = 'wechat' | 'qq' | 'douyin';
|
||||
type ActionState = 'idle' | 'success' | 'failed';
|
||||
|
||||
type ShareChannel = {
|
||||
id: ShareChannelId;
|
||||
label: string;
|
||||
iconClassName: string;
|
||||
};
|
||||
|
||||
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。
|
||||
const SHARE_CHANNEL_ICON_PATHS: Record<ShareChannelId, string> = {
|
||||
wechat:
|
||||
'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
|
||||
qq: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
|
||||
douyin:
|
||||
'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
|
||||
};
|
||||
|
||||
const SHARE_CHANNELS = [
|
||||
{
|
||||
id: 'wechat',
|
||||
label: '微信',
|
||||
iconClassName: 'bg-[#07c160] text-white',
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
label: 'QQ',
|
||||
iconClassName: 'bg-[#12b7f5] text-white',
|
||||
},
|
||||
{
|
||||
id: 'douyin',
|
||||
label: '抖音',
|
||||
iconClassName: 'bg-black text-white',
|
||||
},
|
||||
] as const satisfies readonly ShareChannel[];
|
||||
|
||||
function ShareChannelLogo({ channel }: { channel: ShareChannel }) {
|
||||
const iconPath = SHARE_CHANNEL_ICON_PATHS[channel.id];
|
||||
|
||||
if (channel.id === 'douyin') {
|
||||
return (
|
||||
<svg
|
||||
viewBox="-1 -1 26 26"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className="h-6 w-6 overflow-visible"
|
||||
data-share-channel-logo={channel.id}
|
||||
data-testid={`share-channel-logo-${channel.id}`}
|
||||
>
|
||||
<path d={iconPath} fill="#25f4ee" transform="translate(-0.75 0.45)" />
|
||||
<path d={iconPath} fill="#fe2c55" transform="translate(0.75 -0.45)" />
|
||||
<path d={iconPath} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function normalizePayloadTitle(payload: PublishShareModalPayload | null) {
|
||||
return payload?.title.trim() || '我的作品';
|
||||
}
|
||||
|
||||
function resolvePayloadCoverImageSrc(payload: PublishShareModalPayload | null) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className="h-6 w-6"
|
||||
data-share-channel-logo={channel.id}
|
||||
data-testid={`share-channel-logo-${channel.id}`}
|
||||
>
|
||||
<path d={iconPath} fill="currentColor" />
|
||||
</svg>
|
||||
payload?.coverImageSrc?.trim() ||
|
||||
payload?.fallbackCoverImageSrc?.trim() ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) {
|
||||
return payload?.workTypeLabel?.trim() || '互动作品';
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布完成后的分享弹窗。
|
||||
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK,可以只替换这里的渠道点击逻辑。
|
||||
* 发布完成后的通用分享弹窗。
|
||||
* 分享事实仍来自公开作品号与 stage;弹窗只负责把它表现成可复制、可下载的分享卡。
|
||||
*/
|
||||
export function PublishShareModal({
|
||||
open,
|
||||
@@ -94,14 +51,24 @@ export function PublishShareModal({
|
||||
onClose,
|
||||
}: PublishShareModalProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const [copyState, setCopyState] = useState<ActionState>('idle');
|
||||
const [downloadState, setDownloadState] = useState<ActionState>('idle');
|
||||
const [gridState, setGridState] = useState<ActionState>('idle');
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const shareText = useMemo(
|
||||
() => (payload ? buildPublishShareText(payload) : ''),
|
||||
const shareCopyUrl = useMemo(
|
||||
() =>
|
||||
payload
|
||||
? buildPublishShareCopyUrl(payload, {
|
||||
miniProgramRuntime: isWechatMiniProgramWebViewRuntime(),
|
||||
})
|
||||
: '',
|
||||
[payload],
|
||||
);
|
||||
const title = normalizePayloadTitle(payload);
|
||||
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
|
||||
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
|
||||
const showMiniProgramGridButton =
|
||||
canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -114,25 +81,92 @@ export function PublishShareModal({
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
setDownloadState('idle');
|
||||
setGridState('idle');
|
||||
}, [payload?.publicWorkCode]);
|
||||
|
||||
const copyShareText = () => {
|
||||
if (!shareText) {
|
||||
const scheduleStateReset = () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
setDownloadState('idle');
|
||||
setGridState('idle');
|
||||
}, 1400);
|
||||
};
|
||||
|
||||
const copyShareLink = () => {
|
||||
if (!shareCopyUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
}, 1400);
|
||||
void copyTextToClipboard(shareCopyUrl).then((copied) => {
|
||||
setCopyState(copied ? 'success' : 'failed');
|
||||
scheduleStateReset();
|
||||
});
|
||||
};
|
||||
|
||||
const resolveMiniProgramGridCover = async () => {
|
||||
if (!coverImageSrc) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return await resolveAssetReadUrl(coverImageSrc, {
|
||||
expireSeconds: 600,
|
||||
}).catch(() => coverImageSrc);
|
||||
};
|
||||
|
||||
const downloadShareCard = () => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadState('idle');
|
||||
void downloadPublishShareCardImage(
|
||||
{
|
||||
...payload,
|
||||
title,
|
||||
workTypeLabel,
|
||||
coverImageSrc,
|
||||
},
|
||||
coverImageSrc,
|
||||
)
|
||||
.then((downloaded) => {
|
||||
setDownloadState(downloaded ? 'success' : 'failed');
|
||||
scheduleStateReset();
|
||||
})
|
||||
.catch(() => {
|
||||
setDownloadState('failed');
|
||||
scheduleStateReset();
|
||||
});
|
||||
};
|
||||
|
||||
const openMiniProgramGridDownload = () => {
|
||||
if (!payload || !coverImageSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGridState('idle');
|
||||
void resolveMiniProgramGridCover()
|
||||
.then((resolvedCoverImageSrc) =>
|
||||
openWechatMiniProgramShareGridPage({
|
||||
imageUrl: resolvedCoverImageSrc,
|
||||
title,
|
||||
publicWorkCode: payload.publicWorkCode,
|
||||
}),
|
||||
)
|
||||
.then((opened) => {
|
||||
setGridState(opened ? 'success' : 'failed');
|
||||
scheduleStateReset();
|
||||
})
|
||||
.catch(() => {
|
||||
setGridState('failed');
|
||||
scheduleStateReset();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open && Boolean(payload)}
|
||||
@@ -142,53 +176,98 @@ export function PublishShareModal({
|
||||
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footer={
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
{SHARE_CHANNELS.map((channel) => {
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={copyShareText}
|
||||
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
|
||||
aria-label={`分享到${channel.label}`}
|
||||
title={channel.label}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
|
||||
>
|
||||
<ShareChannelLogo channel={channel} />
|
||||
</span>
|
||||
<span>{channel.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={`grid w-full gap-3 ${
|
||||
showMiniProgramGridButton ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyShareLink}
|
||||
disabled={!shareCopyUrl}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{copyState === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Link2 className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'success'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '复制链接'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadShareCard}
|
||||
disabled={!payload}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{downloadState === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{downloadState === 'success'
|
||||
? '已下载'
|
||||
: downloadState === 'failed'
|
||||
? '下载失败'
|
||||
: '下载卡片'}
|
||||
</button>
|
||||
{showMiniProgramGridButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMiniProgramGridDownload}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm"
|
||||
>
|
||||
{gridState === 'success' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
)}
|
||||
{gridState === 'success'
|
||||
? '已打开'
|
||||
: gridState === 'failed'
|
||||
? '打开失败'
|
||||
: '九宫切图'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{shareText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyShareText}
|
||||
disabled={!shareText}
|
||||
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
<section
|
||||
className="overflow-hidden rounded-lg border border-[var(--platform-subpanel-border)] bg-white/78 shadow-[0_18px_42px_rgba(127,85,57,0.12)]"
|
||||
aria-label="分享卡片"
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'copied'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享'}
|
||||
</button>
|
||||
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(135deg,#f4c38b,#e7b5b7_48%,#9bbfd1)]">
|
||||
{coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={coverImageSrc}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-6xl font-black text-white/84">
|
||||
{Array.from(title)[0] ?? '陶'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div className="inline-flex max-w-full items-center rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-accent-text)]">
|
||||
<span className="truncate">{workTypeLabel}</span>
|
||||
</div>
|
||||
<h3 className="line-clamp-2 text-lg font-black leading-snug text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-bold text-[var(--platform-text-muted)]">
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{payload?.publicWorkCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user