From 423b94440d92891870c8c34e936fc624903f517a Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 19:54:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20ShareCardModal?= =?UTF-8?q?=EF=BC=88951=20=E8=A1=8C=20=E2=86=92=204=20=E4=B8=AA=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShareCardModal.tsx (199 行): 模态框编排逻辑(图片生成、保存、分享) - RestaurantShareCard.tsx: 餐厅分享卡片纯展示组件 - BlindboxShareCard.tsx: 盲盒分享卡片纯展示组件 - shareImage.ts: 图片工具函数(toPng 封装、dataUrl 转换、下载) --- src/components/BlindboxShareCard.tsx | 323 +++++++++++ src/components/RestaurantShareCard.tsx | 387 +++++++++++++ src/components/ShareCardModal.tsx | 774 +------------------------ src/lib/shareImage.ts | 52 ++ 4 files changed, 776 insertions(+), 760 deletions(-) create mode 100644 src/components/BlindboxShareCard.tsx create mode 100644 src/components/RestaurantShareCard.tsx create mode 100644 src/lib/shareImage.ts diff --git a/src/components/BlindboxShareCard.tsx b/src/components/BlindboxShareCard.tsx new file mode 100644 index 0000000..7389f53 --- /dev/null +++ b/src/components/BlindboxShareCard.tsx @@ -0,0 +1,323 @@ +import { QRCodeSVG } from "qrcode.react"; + +export interface BlindboxShareData { + type: "blindbox"; + idea: string; + submitter?: { avatar: string; username: string }; + drawer?: { avatar: string; username: string }; + roomName: string; +} + +export default function BlindboxShareCard({ + data, + cardRef, +}: { + data: BlindboxShareData; + cardRef: React.RefObject; +}) { + const { idea, submitter, drawer, roomName } = data; + const shareUrl = + typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; + + return ( +
+
+ {/* Decorative glows */} +
+
+ + {/* Brand header */} +
+ 🎁 +
+
+ NoWhatever +
+
+ 别说随便 · ADVENTURE ROULETTE +
+
+
+ + {/* Thin accent line */} +
+ + {/* Room name badge */} +
+
+ ✦ {roomName} ✦ +
+
+ + {/* Idea card */} +
+
+ {/* Corner decorations */} +
+
+
+
+ +
+ {idea} +
+ +
+ +
+ 此契约一旦开启,绝不反悔 +
+
+
+ + {/* Attribution */} + {(submitter || drawer) && ( +
+ {submitter && ( +
+ {submitter.avatar} + {submitter.username} 投入 +
+ )} + {submitter && drawer && ( + · + )} + {drawer && ( +
+ {drawer.avatar} + {drawer.username} 抽中 +
+ )} +
+ )} + + {/* QR footer */} +
+
+ +
+
+
+ 扫码一起「别说随便」 +
+
+ {shareUrl.replace(/^https?:\/\//, "")} +
+
+
+
+
+ ); +} diff --git a/src/components/RestaurantShareCard.tsx b/src/components/RestaurantShareCard.tsx new file mode 100644 index 0000000..e6fe9f5 --- /dev/null +++ b/src/components/RestaurantShareCard.tsx @@ -0,0 +1,387 @@ +import { Star, MapPin, Zap } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import type { Restaurant, MatchType, SceneType } from "@/types"; + +export interface RestaurantShareData { + type: "restaurant"; + restaurant: Restaurant; + matchType: MatchType; + matchLikes: number; + userCount: number; + scene?: SceneType; +} + +export default function RestaurantShareCard({ + data, + cardRef, + imageDataUrl, +}: { + data: RestaurantShareData; + cardRef: React.RefObject; + imageDataUrl: string | null; +}) { + const { restaurant, matchType, matchLikes, userCount, scene } = data; + const isUnanimous = matchType === "unanimous"; + const verb = scene === "drink" ? "喝" : "吃"; + const shareUrl = + typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; + const accentFrom = isUnanimous ? "#059669" : "#b45309"; + const accentTo = isUnanimous ? "#34d399" : "#fbbf24"; + const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d"; + const accentBg = isUnanimous + ? "rgba(16, 185, 129, 0.12)" + : "rgba(245, 158, 11, 0.12)"; + + return ( +
+
+ {/* Decorative glows */} +
+
+ + {/* Brand header */} +
+
+ +
+
+ NoWhatever +
+
+ 别说随便 · PANIC MODE +
+
+
+
+ + {/* Thin accent line */} +
+ + {/* Hero section */} +
+
+ {isUnanimous ? "🎉" : "🏆"} +
+
+ 就去这{verb}! +
+
+ {isUnanimous && ( + + )} + + {isUnanimous + ? `默契度 100% · ${userCount}人全员一致` + : `${matchLikes}/${userCount} 人选了这家`} + + {isUnanimous && ( + + )} +
+
+ + {/* Restaurant card */} +
+
+ {imageDataUrl && ( + {restaurant.name} + )} +
+
+
+ {restaurant.name} +
+ {restaurant.category && ( + + {restaurant.category} + + )} +
+ +
+ {restaurant.rating > 0 && ( + + + {restaurant.rating} + + )} + {restaurant.price && restaurant.price !== "未知" && ( + + {restaurant.price} + + )} + {restaurant.distance && ( + + + {restaurant.distance} + + )} +
+ + {restaurant.address && ( +
+ 📍 {restaurant.address} +
+ )} + + {restaurant.tag && ( +
+ {restaurant.tag + .split(",") + .slice(0, 4) + .map((t) => ( + + {t.trim()} + + ))} +
+ )} +
+
+
+ + {/* QR footer */} +
+
+ +
+
+
+ 扫码一起「别说随便」 +
+
+ {shareUrl.replace(/^https?:\/\//, "")} +
+
+
+
+
+ ); +} diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx index acff296..1b379a8 100644 --- a/src/components/ShareCardModal.tsx +++ b/src/components/ShareCardModal.tsx @@ -2,29 +2,23 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { X, Download, Share2, Loader2, Star, MapPin, Zap } from "lucide-react"; -import { QRCodeSVG } from "qrcode.react"; -import { toPng } from "html-to-image"; -import type { Restaurant, MatchType, SceneType } from "@/types"; +import { X, Download, Share2, Loader2 } from "lucide-react"; import { useToast } from "@/hooks/useToast"; import { useShare } from "@/hooks/useShare"; +import { + loadImageAsDataUrl, + generateImage, + downloadDataUrl, + dataUrlToFile, +} from "@/lib/shareImage"; +import RestaurantShareCard, { + type RestaurantShareData, +} from "@/components/RestaurantShareCard"; +import BlindboxShareCard, { + type BlindboxShareData, +} from "@/components/BlindboxShareCard"; -type ShareCardData = - | { - type: "restaurant"; - restaurant: Restaurant; - matchType: MatchType; - matchLikes: number; - userCount: number; - scene?: SceneType; - } - | { - type: "blindbox"; - idea: string; - submitter?: { avatar: string; username: string }; - drawer?: { avatar: string; username: string }; - roomName: string; - }; +export type ShareCardData = RestaurantShareData | BlindboxShareData; interface ShareCardModalProps { open: boolean; @@ -32,746 +26,6 @@ interface ShareCardModalProps { data: ShareCardData; } -async function loadImageAsDataUrl(src: string): Promise { - try { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.src = src; - await new Promise((resolve, reject) => { - img.onload = () => resolve(); - img.onerror = () => reject(); - }); - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(img, 0, 0); - return canvas.toDataURL("image/jpeg", 0.85); - } catch { - return null; - } -} - -async function generateImage(el: HTMLElement): Promise { - return toPng(el, { - pixelRatio: 2, - quality: 0.95, - cacheBust: true, - skipAutoScale: true, - filter: (node) => { - if (node instanceof HTMLElement && node.dataset.shareExclude === "true") { - return false; - } - return true; - }, - }); -} - -function downloadDataUrl(dataUrl: string, filename: string) { - const link = document.createElement("a"); - link.download = filename; - link.href = dataUrl; - link.click(); -} - -function dataUrlToFile(dataUrl: string, filename: string): File { - const arr = dataUrl.split(","); - const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png"; - const bstr = atob(arr[1]); - const u8 = new Uint8Array(bstr.length); - for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i); - return new File([u8], filename, { type: mime }); -} - -function RestaurantShareCard({ - data, - cardRef, - imageDataUrl, -}: { - data: Extract; - cardRef: React.RefObject; - imageDataUrl: string | null; -}) { - const { restaurant, matchType, matchLikes, userCount, scene } = data; - const isUnanimous = matchType === "unanimous"; - const verb = scene === "drink" ? "喝" : "吃"; - const shareUrl = - typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; - const accentFrom = isUnanimous ? "#059669" : "#b45309"; - const accentTo = isUnanimous ? "#34d399" : "#fbbf24"; - const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d"; - const accentBg = isUnanimous - ? "rgba(16, 185, 129, 0.12)" - : "rgba(245, 158, 11, 0.12)"; - - return ( -
-
- {/* Decorative glows */} -
-
- - {/* Brand header */} -
-
- -
-
- NoWhatever -
-
- 别说随便 · PANIC MODE -
-
-
-
- - {/* Thin accent line */} -
- - {/* Hero section */} -
-
- {isUnanimous ? "🎉" : "🏆"} -
-
- 就去这{verb}! -
-
- {isUnanimous && ( - - )} - - {isUnanimous - ? `默契度 100% · ${userCount}人全员一致` - : `${matchLikes}/${userCount} 人选了这家`} - - {isUnanimous && ( - - )} -
-
- - {/* Restaurant card */} -
-
- {imageDataUrl && ( - {restaurant.name} - )} -
-
-
- {restaurant.name} -
- {restaurant.category && ( - - {restaurant.category} - - )} -
- -
- {restaurant.rating > 0 && ( - - - {restaurant.rating} - - )} - {restaurant.price && restaurant.price !== "未知" && ( - - {restaurant.price} - - )} - {restaurant.distance && ( - - - {restaurant.distance} - - )} -
- - {restaurant.address && ( -
- 📍 {restaurant.address} -
- )} - - {restaurant.tag && ( -
- {restaurant.tag - .split(",") - .slice(0, 4) - .map((t) => ( - - {t.trim()} - - ))} -
- )} -
-
-
- - {/* QR footer */} -
-
- -
-
-
- 扫码一起「别说随便」 -
-
- {shareUrl.replace(/^https?:\/\//, "")} -
-
-
-
-
- ); -} - -function BlindboxShareCard({ - data, - cardRef, -}: { - data: Extract; - cardRef: React.RefObject; -}) { - const { idea, submitter, drawer, roomName } = data; - const shareUrl = - typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; - - return ( -
-
- {/* Decorative glows */} -
-
- - {/* Brand header */} -
- 🎁 -
-
- NoWhatever -
-
- 别说随便 · ADVENTURE ROULETTE -
-
-
- - {/* Thin accent line */} -
- - {/* Room name badge */} -
-
- ✦ {roomName} ✦ -
-
- - {/* Idea card */} -
-
- {/* Corner decorations */} -
-
-
-
- -
- {idea} -
- -
- -
- 此契约一旦开启,绝不反悔 -
-
-
- - {/* Attribution */} - {(submitter || drawer) && ( -
- {submitter && ( -
- {submitter.avatar} - {submitter.username} 投入 -
- )} - {submitter && drawer && ( - · - )} - {drawer && ( -
- {drawer.avatar} - {drawer.username} 抽中 -
- )} -
- )} - - {/* QR footer */} -
-
- -
-
-
- 扫码一起「别说随便」 -
-
- {shareUrl.replace(/^https?:\/\//, "")} -
-
-
-
-
- ); -} - export default function ShareCardModal({ open, onClose, diff --git a/src/lib/shareImage.ts b/src/lib/shareImage.ts new file mode 100644 index 0000000..9bb75a9 --- /dev/null +++ b/src/lib/shareImage.ts @@ -0,0 +1,52 @@ +import { toPng } from "html-to-image"; + +export async function loadImageAsDataUrl(src: string): Promise { + try { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = src; + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(); + }); + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0); + return canvas.toDataURL("image/jpeg", 0.85); + } catch { + return null; + } +} + +export async function generateImage(el: HTMLElement): Promise { + return toPng(el, { + pixelRatio: 2, + quality: 0.95, + cacheBust: true, + skipAutoScale: true, + filter: (node) => { + if (node instanceof HTMLElement && node.dataset.shareExclude === "true") { + return false; + } + return true; + }, + }); +} + +export function downloadDataUrl(dataUrl: string, filename: string) { + const link = document.createElement("a"); + link.download = filename; + link.href = dataUrl; + link.click(); +} + +export function dataUrlToFile(dataUrl: string, filename: string): File { + const arr = dataUrl.split(","); + const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png"; + const bstr = atob(arr[1]); + const u8 = new Uint8Array(bstr.length); + for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i); + return new File([u8], filename, { type: mime }); +}