Files
app_03_pregnancy/frontend/src/app/pages/SharePage.tsx

735 lines
28 KiB
TypeScript
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.
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useParams } from "react-router";
import { motion, AnimatePresence } from "motion/react";
import {
ChevronLeft,
Share2,
CheckCircle2,
AlertCircle,
Info,
Loader2,
Heart,
Baby,
TrendingUp,
Download,
Check,
Image as ImageIcon,
Smartphone,
} from "lucide-react";
import { toPng } from "html-to-image";
import { Card } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { generateShareData, generateWxacode } from "../services/api"
import bridge from "../services/bridge";
type SaveStatus = "idle" | "generating" | "success" | "error";
/** 检测是否支持微信小程序分享(wx.miniProgram 必须存在) */
function supportsWxMiniProgramShare(): boolean {
try {
const w = (window as any).wx;
return !!(w && w.miniProgram && typeof w.miniProgram.navigateBack === 'function');
} catch { return false; }
}
/** 模块级状态:避免构建压缩时变量名不一致导致 ReferenceError */
const _wp = { url: null as string | null, tick: 0 };
export function SharePage() {
const navigate = useNavigate();
const { reportId } = useParams();
const [loading, setLoading] = useState(true);
const [shareData, setShareData] = useState<any>(null);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
const [canShareToWechat, setCanShareToWechat] = useState(false);
const wxacodeUrl = useRef<string | null>(null);
const [, forceUpdate] = useState(0);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setCanShareToWechat(supportsWxMiniProgramShare());
}, []);
// 复制链接(客户端优先,bridge 降级)
const handleCopyLink = useCallback(async () => {
const shareUrl = `${window.location.origin}/app_03/share/${reportId}`;
let copied = false;
// 尝试 navigator.clipboard
try {
await navigator.clipboard.writeText(shareUrl);
copied = true;
} catch {}
// 降级:execCommand
if (!copied) {
const ta = document.createElement('textarea');
ta.value = shareUrl;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { copied = document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
}
// 再降级:bridge.copyLink(依赖 postMessage,web-view 中可能不即时)
if (!copied) {
bridge.copyLink(shareUrl);
}
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
}, [reportId]);
useEffect(() => {
if (!reportId) return;
generateShareData(reportId)
.then((data) => {
setShareData(data.share_data);
})
.catch(() => {/* keep null */})
.finally(() => setLoading(false));
}, [reportId]);
// 太阳码:shareData 就绪后异步生成,blob URL 存到 wxacodeUrl
useEffect(() => {
if (!shareData) return;
let cancelled = false;
generateWxacode({ width: 430 })
.then((url) => {
if (!cancelled) { _wp.url = url; forceUpdate(n => n + 1); }
})
.catch((err) => {
console.warn("[SharePage] 太阳码生成失败:", err);
});
return () => {
cancelled = true;
};
}, [shareData]);
// 卸载时清理 blob URL
useEffect(() => {
return () => {
if (_wp.url?.startsWith("blob:")) URL.revokeObjectURL(_wp.url);
};
}, []);
// 生成长图
const generateImage = useCallback(async (): Promise<string | null> => {
if (!cardRef.current) return null;
// 等待太阳码渲染完成(blob URL 需浏览器解码)
if (shareData && !_wp.url) {
await new Promise<void>((resolve) => {
const start = Date.now();
const tick = () => {
if (_wp.url || Date.now() - start > 1500) {
resolve();
return;
}
setTimeout(tick, 100);
};
tick();
});
if (!_wp.url) {
console.warn("[SharePage] 太阳码未就绪,截图中可能缺失二维码");
}
}
try {
// html-to-image: 将 DOM 节点转为 PNG data URL
const dataUrl = await toPng(cardRef.current, {
cacheBust: true,
pixelRatio: 2, // 2x 高清
backgroundColor: "#ffffff",
style: {
// 确保截图时移除圆角阴影的裁切问题
borderRadius: "0",
},
});
return dataUrl;
} catch (err) {
console.error("生成图片失败:", err);
return null;
}
}, [shareData]);
// 一键保存到相册 / 下载
const handleSaveImage = useCallback(async () => {
setSaveStatus("generating");
try {
const dataUrl = imageUrl || (await generateImage());
if (!dataUrl) {
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 2000);
return;
}
if (!imageUrl) setImageUrl(dataUrl);
// 尝试使用 Web Share API(移动端可直接分享/保存到相册)
if (navigator.share && navigator.canShare) {
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
const file = new File([blob], `孕育无忧-报告解读-${reportId}.png`, {
type: "image/png",
});
if (navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: "孕育无忧 · AI 报告解读",
});
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
return;
}
} catch (shareErr) {
// 用户取消分享或不支持,降级到下载
if ((shareErr as Error).name === "AbortError") {
setSaveStatus("idle");
return;
}
}
}
// 降级方案:触发下载(iOS Safari / Android 浏览器)
const link = document.createElement("a");
link.download = `孕育无忧-报告解读-${reportId}.png`;
link.href = dataUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
} catch (err) {
console.error("保存失败:", err);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 2000);
}
}, [imageUrl, generateImage, reportId]);
// 预览:在页面加载数据后自动生成图片预览
useEffect(() => {
if (shareData && cardRef.current && !imageUrl) {
// 延迟一帧确保 DOM 渲染完毕
const timer = setTimeout(async () => {
const url = await generateImage();
if (url) setImageUrl(url);
}, 500);
return () => clearTimeout(timer);
}
}, [shareData, generateImage, imageUrl]);
if (loading || !shareData) {
return (
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 text-morandi-400 animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-500">正在准备分享内容...</p>
</div>
</div>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case "normal":
return {
bg: "bg-morandi-sage-100",
text: "text-morandi-sage-600",
icon: "text-morandi-sage-400",
};
case "attention":
return {
bg: "bg-morandi-rose-100",
text: "text-morandi-600",
icon: "text-morandi-rose-400",
};
default:
return {
bg: "bg-gray-100",
text: "text-gray-600",
icon: "text-gray-400",
};
}
};
const statusColors = getStatusColor(shareData.overall_status);
const getSaveButtonContent = () => {
switch (saveStatus) {
case "generating":
return (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>正在生成图片...</span>
</>
);
case "success":
return (
<>
<Check className="w-5 h-5" />
<span>已保存到相册</span>
</>
);
case "error":
return (
<>
<AlertCircle className="w-5 h-5" />
<span>保存失败,请重试</span>
</>
);
default:
return (
<>
<Download className="w-5 h-5" />
<span>保存图片到相册</span>
</>
);
}
};
return (
<div className="min-h-dvh bg-morandi-50 pb-28">
{/* 顶部导航 */}
<div className="bg-white border-b border-gray-100 sticky top-0 z-10">
<div className="mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="w-10 h-10 rounded-full hover:bg-gray-100 flex items-center justify-center"
>
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-morandi-400 rounded-full flex items-center justify-center">
<Share2 className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-semibold text-gray-800">分享长图</h1>
<p className="text-xs text-gray-500">生成精美报告长图</p>
</div>
</div>
</div>
</div>
</div>
<div className="mx-auto p-4 space-y-5">
{/* 图片预览区域 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* 如果已经生成了图片,直接展示图片(可长按保存) */}
{imageUrl ? (
<div className="relative">
<img
src={imageUrl}
alt="分享长图"
className="w-full rounded-3xl shadow-lg"
style={{ userSelect: "none" }}
/>
{/* 长按提示覆盖层 */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/30 to-transparent rounded-b-3xl px-4 py-3">
<p className="text-white text-xs text-center opacity-80">
💡 长按图片也可直接保存
</p>
</div>
</div>
) : (
<div className="flex items-center justify-center py-16 bg-white rounded-3xl shadow-lg">
<div className="text-center">
<Loader2 className="w-8 h-8 text-morandi-400 animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-500">正在生成长图预览...</p>
</div>
</div>
)}
</motion.div>
{/* 隐藏的 HTML 卡片(用于截图,不展示给用户) */}
<div
style={{
position: "absolute",
left: "-9999px",
top: 0,
width: "375px", // 固定 iPhone 宽度,保证截图一致
}}
>
<div ref={cardRef} style={{ background: "#ffffff", padding: "0" }}>
<ShareCardContent shareData={shareData} getStatusColor={getStatusColor} statusColors={statusColors} />
</div>
</div>
{/* 操作提示 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="bg-morandi-50 border-morandi-200 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-morandi-400 flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-gray-800 text-sm mb-2">💡 分享说明</h4>
<div className="space-y-1.5">
<p className="text-xs text-gray-600 leading-relaxed">
• 点击下方按钮,一键保存图片到手机相册
</p>
<p className="text-xs text-gray-600 leading-relaxed">
• 也可以长按上方图片直接保存
</p>
<p className="text-xs text-gray-600 leading-relaxed">
• 个人信息已自动脱敏处理,保护您的隐私
</p>
</div>
</div>
</div>
</Card>
</motion.div>
</div>
{/* 底部固定操作栏 */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4 pb-safe z-10">
<div className="max-w-md mx-auto space-y-3">
{/* 微信分享:仅 wx.miniProgram 可用时显示 */}
{canShareToWechat ? (
<Button
onClick={() => bridge.share({
title: '孕育无忧 · 报告解读',
summary: shareData?.overall_summary || shareData?.emotional_summary || '',
href: window.location.href,
scene: 'WXSceneSession',
})}
className="w-full h-14 rounded-2xl text-base font-semibold flex items-center justify-center gap-2 bg-[#07C160] hover:bg-[#06AD56] text-white shadow-lg shadow-green-300/30"
>
<Share2 className="w-5 h-5" />
<span>分享给微信好友</span>
</Button>
) : (
<Card className="bg-blue-50 border-blue-200 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Smartphone className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-700 mb-1">📱 如何分享</p>
<p className="text-xs text-blue-600 leading-relaxed">
长按上方预览图片 → 保存到相册,然后通过微信发送给好友
</p>
</div>
</div>
</Card>
)}
<Button
onClick={handleCopyLink}
className="w-full h-12 rounded-2xl text-sm font-medium flex items-center justify-center gap-2 border-2 border-gray-200 bg-white hover:bg-gray-50 text-gray-600"
>
{linkCopied ? <Check className="w-4 h-4 text-green-500" /> : null}
<span>{linkCopied ? "已复制链接" : "复制链接分享"}</span>
</Button>
<Button
onClick={handleSaveImage}
disabled={saveStatus === "generating"}
className={`w-full h-14 rounded-2xl text-base font-semibold flex items-center justify-center gap-2 transition-all ${
saveStatus === "success"
? "bg-morandi-sage-400 hover:bg-morandi-sage-500 text-white"
: saveStatus === "error"
? "bg-red-400 hover:bg-red-500 text-white"
: "bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-600 text-white shadow-lg shadow-morandi-300/30"
}`}
>
{getSaveButtonContent()}
</Button>
<p className="text-center text-xs text-gray-400 mt-2">
分享到微信或保存图片到相册
<span className="ml-2 opacity-40">v26070203</span>
</p>
</div>
</div>
</div>
);
}
/**
* 分享卡片内容组件(纯 HTML/CSS,不使用 motion 动画,用于截图)
* 独立提取,确保截图时样式完整、无动画干扰
*/
function ShareCardContent({
shareData,
getStatusColor,
statusColors,
}: {
shareData: any;
getStatusColor: (status: string) => { bg: string; text: string; icon: string };
statusColors: { bg: string; text: string; icon: string };
}) {
return (
<div style={{ background: "#ffffff", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" }}>
<div style={{ padding: "24px", display: "flex", flexDirection: "column", gap: "20px" }}>
{/* 标题区 */}
<div style={{ textAlign: "center", paddingBottom: "20px", borderBottom: "2px solid #f3f4f6" }}>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "8px 16px",
background: "linear-gradient(135deg, #b8a9c9, #9d8ab5)",
color: "white",
borderRadius: "9999px",
fontSize: "14px",
fontWeight: 600,
marginBottom: "16px",
}}
>
<Heart style={{ width: 16, height: 16, fill: "white", color: "white" }} />
<span>孕育无忧 · AI 智能解读</span>
</div>
<h2
style={{
fontSize: "24px",
fontWeight: 700,
color: "#1f2937",
marginBottom: "8px",
marginTop: "0",
}}
>
{shareData.report_type}
</h2>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", fontSize: "14px", color: "#6b7280" }}>
{shareData.pregnancy_week && <span>孕 {shareData.pregnancy_week} 周</span>}
{shareData.user_name && (
<>
<span>·</span>
<span>{shareData.user_name}</span>
</>
)}
</div>
</div>
{/* 整体评估 */}
<div className={`${statusColors.bg} rounded-2xl p-5`}>
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "12px" }}>
<div
style={{
width: 48,
height: 48,
background: "rgba(255,255,255,0.5)",
borderRadius: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Baby className={`w-6 h-6 ${statusColors.icon}`} />
</div>
<div style={{ flex: 1 }}>
<h3 style={{ fontWeight: 700, color: "#1f2937", fontSize: "18px", margin: 0 }}>
整体评估
</h3>
<Badge className={`${statusColors.bg} ${statusColors.text} mt-1`}>
{shareData.overall_status === "normal" ? "一切正常" : "需要关注"}
</Badge>
</div>
</div>
<p className={`text-sm ${statusColors.text} leading-relaxed`} style={{ margin: 0 }}>
{shareData.emotional_summary || shareData.overall_summary}
</p>
</div>
{/* 详细指标 */}
{shareData.categories && shareData.categories.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<h3 style={{ fontWeight: 700, color: "#1f2937", fontSize: "18px", display: "flex", alignItems: "center", gap: "8px", margin: 0 }}>
<TrendingUp className="w-5 h-5 text-morandi-400" />
详细指标
</h3>
{shareData.categories.map((category: any, idx: number) => {
const catColors = getStatusColor(category.status);
return (
<div key={idx} className={`${catColors.bg} rounded-2xl p-4`}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "12px" }}>
<h4 style={{ fontWeight: 600, color: "#1f2937", margin: 0 }}>{category.category}</h4>
<Badge className={`${catColors.bg} ${catColors.text} text-xs`}>
{category.status === "normal" ? "正常" : "需关注"}
</Badge>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{category.indicators.map((ind: any, indIdx: number) => (
<div key={indIdx} style={{ background: "rgba(255,255,255,0.7)", borderRadius: "12px", padding: "12px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "8px" }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#1f2937", fontSize: "14px", marginBottom: "4px" }}>
{ind.name}
{ind.nickname && (
<span style={{ fontSize: "12px", color: "#9ca3af", marginLeft: "8px" }}>({ind.nickname})</span>
)}
</div>
{ind.value && (
<div style={{ fontSize: "18px", fontWeight: 700, color: "#7c6995" }}>
{ind.value} {ind.unit}
</div>
)}
</div>
<Badge className={`text-xs ${
ind.status === "normal"
? "bg-morandi-sage-100 text-morandi-sage-600"
: "bg-morandi-rose-100 text-morandi-rose-600"
}`}>
{ind.status === "normal" ? "正常" : ind.status === "high" ? "偏高" : "偏低"}
</Badge>
</div>
{ind.what_is_it && (
<p style={{ fontSize: "12px", color: "#6b7280", marginBottom: "8px", margin: "0 0 8px 0" }}>
{ind.what_is_it}
</p>
)}
{ind.suggestion && (
<div style={{ background: "#faf7fc", borderRadius: "8px", padding: "8px", marginTop: "8px" }}>
<p style={{ fontSize: "12px", color: "#4b5563", margin: 0 }}>💡 {ind.suggestion}</p>
</div>
)}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* 行动建议 */}
{shareData.action_items && shareData.action_items.length > 0 && (
<div>
<h3 style={{ fontWeight: 700, color: "#1f2937", fontSize: "18px", marginBottom: "12px", marginTop: 0 }}>
接下来该做什么?
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{shareData.action_items.map((item: any, idx: number) => (
<div
key={idx}
style={{
borderRadius: "12px",
padding: "12px",
background: item.priority === "high" ? "#fce4ec" : "#faf7fc",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}>
<span
style={{
flexShrink: 0,
width: 20,
height: 20,
background: "#b8a9c9",
color: "white",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: 700,
marginTop: "2px",
}}
>
{idx + 1}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#1f2937", fontSize: "14px", marginBottom: "4px" }}>
{item.title || item.content}
</div>
{item.description && (
<p style={{ fontSize: "12px", color: "#6b7280", margin: 0 }}>{item.description}</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 温馨提示 */}
{shareData.tips && shareData.tips.length > 0 && (
<div className="bg-morandi-50 rounded-2xl p-4">
<h4 style={{ fontWeight: 600, color: "#1f2937", fontSize: "14px", marginBottom: "8px", display: "flex", alignItems: "center", gap: "8px", marginTop: 0 }}>
<Heart className="w-4 h-4 text-morandi-400" />
温馨提示
</h4>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: "4px" }}>
{shareData.tips.map((tip: string, idx: number) => (
<li key={idx} style={{ fontSize: "12px", color: "#6b7280", display: "flex", alignItems: "flex-start", gap: "8px" }}>
<span style={{ color: "#b8a9c9" }}>•</span>
<span>{tip}</span>
</li>
))}
</ul>
</div>
)}
{/* 太阳码区(截图包含) */}
{_wp.url && (
<div
style={{
textAlign: "center",
paddingTop: "20px",
borderTop: "1px dashed #e5e7eb",
}}
>
<img
src={_wp.url}
alt="扫码下载孕育无忧"
crossOrigin="anonymous"
style={{
width: 200,
height: 200,
borderRadius: 8,
background: "#ffffff",
}}
/>
<p
style={{
marginTop: 8,
fontSize: 12,
color: "#9ca3af",
lineHeight: 1.5,
margin: "8px 0 0 0",
}}
>
微信扫码下载「孕育无忧」APP
<br />
长按图片也可保存到相册
</p>
</div>
)}
{/* 底部声明 */}
<div style={{ paddingTop: "20px", borderTop: "2px solid #f3f4f6", textAlign: "center" }}>
<p style={{ fontSize: "12px", color: "#9ca3af", marginBottom: "4px", margin: "0 0 4px 0" }}>
本报告由 AI 生成,仅供参考,不能替代医生诊断
</p>
<p style={{ fontSize: "12px", color: "#9ca3af", marginBottom: "8px", margin: "0 0 8px 0" }}>
如有疑问请及时咨询专业医生
</p>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
fontSize: "11px",
color: "#b8a9c9",
padding: "4px 12px",
background: "#faf7fc",
borderRadius: "9999px",
}}
>
<Heart style={{ width: 12, height: 12, fill: "#b8a9c9", color: "#b8a9c9" }} />
<span>孕育无忧 · 兜爸心选+</span>
</div>
</div>
</div>
</div>
);
}