Files
Genarrative/src/components/common/PublishShareModal.tsx
T
kdletters c5763fdf25 重构作品分享链路
统一发布分享弹窗为作品分享卡片

支持下载分享卡与小程序九宫切图保存

小程序复制链接改为可直达作品详情的 web-view 路径

修复本地 dev Rust 构建绕过损坏 sccache

补充分享链路与 dev 启动文档和测试
2026-06-11 21:32:29 +08:00

274 lines
8.6 KiB
TypeScript

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 {
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';
type PublishShareModalProps = {
open: boolean;
payload: PublishShareModalPayload | null;
onClose: () => void;
};
type ActionState = 'idle' | 'success' | 'failed';
function normalizePayloadTitle(payload: PublishShareModalPayload | null) {
return payload?.title.trim() || '我的作品';
}
function resolvePayloadCoverImageSrc(payload: PublishShareModalPayload | null) {
return (
payload?.coverImageSrc?.trim() ||
payload?.fallbackCoverImageSrc?.trim() ||
''
);
}
function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) {
return payload?.workTypeLabel?.trim() || '互动作品';
}
/**
* 发布完成后的通用分享弹窗。
* 分享事实仍来自公开作品号与 stage;弹窗只负责把它表现成可复制、可下载的分享卡。
*/
export function PublishShareModal({
open,
payload,
onClose,
}: PublishShareModalProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
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 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(
() => () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
},
[],
);
useEffect(() => {
setCopyState('idle');
setDownloadState('idle');
setGridState('idle');
}, [payload?.publicWorkCode]);
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(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)}
title="分享给朋友"
onClose={onClose}
size="sm"
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="border-t-0 px-4 pb-5 pt-0 sm:px-5"
footer={
<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>
}
>
<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="分享卡片"
>
<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>
);
}