Files
perler-beads/src/components/CompletionCard.tsx

492 lines
18 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 React, { useState, useRef, useCallback } from 'react';
import { MappedPixel } from '../utils/pixelation';
interface CompletionCardProps {
isVisible: boolean;
mappedPixelData: MappedPixel[][];
gridDimensions: { N: number; M: number };
totalElapsedTime: number;
onClose: () => void;
}
const CompletionCard: React.FC<CompletionCardProps> = ({
isVisible,
mappedPixelData,
gridDimensions,
totalElapsedTime,
onClose
}) => {
const [userPhoto, setUserPhoto] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const [cameraError, setCameraError] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cardCanvasRef = useRef<HTMLCanvasElement>(null);
// 计算总豆子数(排除透明区域)
const totalBeads = React.useMemo(() => {
if (!mappedPixelData) return 0;
let count = 0;
for (let row = 0; row < gridDimensions.M; row++) {
for (let col = 0; col < gridDimensions.N; col++) {
const pixel = mappedPixelData[row][col];
// 排除透明色和空白区域
if (pixel.color &&
pixel.color !== 'transparent' &&
pixel.color !== 'rgba(0,0,0,0)' &&
!pixel.color.includes('rgba(0, 0, 0, 0)')) {
count++;
}
}
}
return count;
}, [mappedPixelData, gridDimensions]);
// 格式化时间
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}小时${minutes}分钟`;
} else {
return `${minutes}${secs}`;
}
};
// 生成原图缩略图
const generateThumbnail = useCallback(() => {
if (!mappedPixelData) return null;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const thumbnailSize = 200;
canvas.width = thumbnailSize;
canvas.height = thumbnailSize;
const cellWidth = thumbnailSize / gridDimensions.N;
const cellHeight = thumbnailSize / gridDimensions.M;
// 绘制缩略图
for (let row = 0; row < gridDimensions.M; row++) {
for (let col = 0; col < gridDimensions.N; col++) {
const pixel = mappedPixelData[row][col];
ctx.fillStyle = pixel.color;
ctx.fillRect(
col * cellWidth,
row * cellHeight,
cellWidth,
cellHeight
);
}
}
return canvas.toDataURL();
}, [mappedPixelData, gridDimensions]);
// 开启相机
const startCamera = async () => {
try {
setIsCapturing(true);
setCameraError(false);
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' } // 后置摄像头
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (error) {
console.error('无法访问相机:', error);
setIsCapturing(false);
setCameraError(true);
}
};
// 拍照
const takePhoto = () => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
const photoDataURL = canvas.toDataURL('image/jpeg', 0.8);
setUserPhoto(photoDataURL);
// 停止相机
const stream = video.srcObject as MediaStream;
stream?.getTracks().forEach(track => track.stop());
setIsCapturing(false);
};
// 跳过拍照,使用拼豆原图
const skipPhoto = () => {
const thumbnailDataURL = generateThumbnail();
if (thumbnailDataURL) {
setUserPhoto(thumbnailDataURL);
}
};
// 生成打卡图
const generateCompletionCard = useCallback(() => {
if (!userPhoto || !cardCanvasRef.current) return null;
const canvas = cardCanvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// 检查是否使用的是拼豆原图通过比较是否等于generateThumbnail的结果
const thumbnailDataURL = generateThumbnail();
const isUsingPixelArt = userPhoto === thumbnailDataURL;
// 设置画布尺寸 (9:16比例适合手机分享)
const cardWidth = 720;
const cardHeight = 1280;
canvas.width = cardWidth;
canvas.height = cardHeight;
return new Promise<string>((resolve) => {
// 加载用户照片/拼豆图
const userImg = new Image();
userImg.onload = () => {
if (isUsingPixelArt) {
// ===== 拼豆原图模式:原图占主导 =====
// 深色渐变背景,更有质感
const gradient = ctx.createLinearGradient(0, 0, 0, cardHeight);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(0.3, '#16213e');
gradient.addColorStop(0.7, '#0f3460');
gradient.addColorStop(1, '#533483');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, cardWidth, cardHeight);
// 计算拼豆图尺寸占据80%的高度,居中显示)
const imageMaxSize = Math.min(cardWidth * 0.9, cardHeight * 0.75);
const imageSize = imageMaxSize;
const imageX = (cardWidth - imageSize) / 2;
const imageY = (cardHeight - imageSize) / 2 - 20; // 稍微往上偏移
// 绘制主图片的装饰背景和阴影
ctx.save();
// 外层光晕效果
const glowGradient = ctx.createRadialGradient(
imageX + imageSize/2, imageY + imageSize/2, imageSize/2,
imageX + imageSize/2, imageY + imageSize/2, imageSize/2 + 30
);
glowGradient.addColorStop(0, 'rgba(255,255,255,0.1)');
glowGradient.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = glowGradient;
ctx.fillRect(imageX - 30, imageY - 30, imageSize + 60, imageSize + 60);
// 白色边框背景
ctx.fillStyle = '#ffffff';
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 25;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 15;
const borderWidth = 12;
ctx.fillRect(imageX - borderWidth, imageY - borderWidth,
imageSize + borderWidth * 2, imageSize + borderWidth * 2);
ctx.restore();
// 绘制拼豆原图
ctx.drawImage(userImg, imageX, imageY, imageSize, imageSize);
// 顶部区域:简洁的完成标识
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 28px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 8;
ctx.fillText('🎉 作品完成 🎉', cardWidth / 2, 80);
ctx.shadowBlur = 0;
// 底部信息区域:透明背景卡片
const infoY = imageY + imageSize + 50;
const infoHeight = 120;
const infoX = 40;
const infoWidth = cardWidth - 80;
// 半透明背景
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(infoX, infoY, infoWidth, infoHeight);
// 信息文字
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 20px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`⏱️ ${formatTime(totalElapsedTime)}`, cardWidth / 2, infoY + 35);
ctx.font = '18px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillText(`🔗 完成 ${totalBeads} 颗豆子`, cardWidth / 2, infoY + 65);
// 底部品牌信息
ctx.font = '14px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText('七卡瓦拼豆底稿生成器', cardWidth / 2, cardHeight - 50);
ctx.font = '12px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText('perlerbeads.zippland.com', cardWidth / 2, cardHeight - 25);
resolve(canvas.toDataURL('image/jpeg', 0.95));
} else {
// ===== 用户照片模式:照片占主导 =====
// 温暖渐变背景
const gradient = ctx.createLinearGradient(0, 0, 0, cardHeight);
gradient.addColorStop(0, '#ff9a9e');
gradient.addColorStop(0.3, '#fecfef');
gradient.addColorStop(0.7, '#fecfef');
gradient.addColorStop(1, '#ff9a9e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, cardWidth, cardHeight);
// 计算照片尺寸(占据大部分空间)
const photoMaxSize = Math.min(cardWidth * 0.85, cardHeight * 0.7);
const photoSize = photoMaxSize;
const photoX = (cardWidth - photoSize) / 2;
const photoY = (cardHeight - photoSize) / 2 - 30;
// 绘制照片装饰背景和阴影
ctx.save();
// 外层装饰边框
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
ctx.lineWidth = 8;
ctx.strokeRect(photoX - 15, photoY - 15, photoSize + 30, photoSize + 30);
// 内层白色边框背景
ctx.fillStyle = '#ffffff';
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 20;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 10;
ctx.fillRect(photoX - 12, photoY - 12, photoSize + 24, photoSize + 24);
ctx.restore();
// 绘制矩形照片
ctx.drawImage(userImg, photoX, photoY, photoSize, photoSize);
// 顶部完成标识
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 32px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 8;
ctx.fillText('🎉 拼豆达成', cardWidth / 2, 100);
ctx.shadowBlur = 0;
// 底部信息卡片
const infoCardY = photoY + photoSize + 40;
const cardHeight2 = 140;
const cardX = 60;
const cardWidth2 = cardWidth - 120;
// 信息卡片背景
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 15;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 8;
ctx.fillRect(cardX, infoCardY, cardWidth2, cardHeight2);
ctx.shadowBlur = 0;
// 信息文字
ctx.fillStyle = '#333333';
ctx.font = 'bold 22px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`⏱️ 总用时 ${formatTime(totalElapsedTime)}`, cardWidth / 2, infoCardY + 40);
ctx.font = '20px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = '#666666';
ctx.fillText(`🔗 共完成 ${totalBeads} 颗豆子`, cardWidth / 2, infoCardY + 75);
// 添加小的拼豆原图作为装饰
if (thumbnailDataURL) {
const thumbnailImg = new Image();
thumbnailImg.onload = () => {
const thumbSize = 60;
const thumbX = cardWidth / 2 - thumbSize / 2;
const thumbY = infoCardY + 90;
// 绘制小缩略图背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(thumbX - 3, thumbY - 3, thumbSize + 6, thumbSize + 6);
// 绘制小缩略图
ctx.drawImage(thumbnailImg, thumbX, thumbY, thumbSize, thumbSize);
// 缩略图边框
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
ctx.strokeRect(thumbX - 3, thumbY - 3, thumbSize + 6, thumbSize + 6);
// 底部品牌信息
ctx.font = '14px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.textAlign = 'center';
ctx.fillText('七卡瓦拼豆底稿生成器', cardWidth / 2, cardHeight - 50);
ctx.font = '12px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText('perlerbeads.zippland.com', cardWidth / 2, cardHeight - 25);
resolve(canvas.toDataURL('image/jpeg', 0.95));
};
thumbnailImg.src = thumbnailDataURL;
} else {
// 底部品牌信息
ctx.font = '14px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.textAlign = 'center';
ctx.fillText('七卡瓦拼豆底稿生成器', cardWidth / 2, cardHeight - 50);
ctx.font = '12px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText('perlerbeads.zippland.com', cardWidth / 2, cardHeight - 25);
resolve(canvas.toDataURL('image/jpeg', 0.95));
}
}
};
userImg.src = userPhoto;
});
}, [userPhoto, totalElapsedTime, generateThumbnail, totalBeads]);
// 下载打卡图
const downloadCard = async () => {
const cardDataURL = await generateCompletionCard();
if (cardDataURL) {
const link = document.createElement('a');
link.download = `拼豆完成打卡-${new Date().toLocaleDateString()}.jpg`;
link.href = cardDataURL;
link.click();
}
};
if (!isVisible) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800 mb-2">
🎉 🎉
</h2>
<div className="text-gray-600 space-y-1">
<p>{formatTime(totalElapsedTime)}</p>
<p>{totalBeads} </p>
</div>
</div>
{!userPhoto ? (
<div className="text-center">
{!isCapturing ? (
<div>
<p className="text-gray-600 mb-4">
</p>
{cameraError && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4">
<p className="text-yellow-800 text-sm">
📱 访<br/>
使
</p>
</div>
)}
<div className="space-y-3">
<button
onClick={startCamera}
className="w-full bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600 transition-colors"
>
📸
</button>
<button
onClick={skipPhoto}
className="w-full bg-green-500 text-white px-6 py-3 rounded-lg hover:bg-green-600 transition-colors"
>
🎨 使
</button>
</div>
</div>
) : (
<div>
<video
ref={videoRef}
autoPlay
playsInline
className="w-full max-w-xs mx-auto rounded-lg mb-4"
/>
<button
onClick={takePhoto}
className="bg-green-500 text-white px-6 py-3 rounded-lg hover:bg-green-600 transition-colors mr-2"
>
📸
</button>
<button
onClick={() => {
const stream = videoRef.current?.srcObject as MediaStream;
stream?.getTracks().forEach(track => track.stop());
setIsCapturing(false);
}}
className="bg-gray-500 text-white px-4 py-3 rounded-lg hover:bg-gray-600 transition-colors"
>
</button>
</div>
)}
</div>
) : (
<div className="text-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={userPhoto}
alt="用户照片"
className="w-32 h-32 rounded-full mx-auto mb-4 object-cover"
/>
<div className="space-y-3">
<button
onClick={downloadCard}
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 text-white py-3 rounded-lg hover:from-purple-600 hover:to-pink-600 transition-colors"
>
📥
</button>
<button
onClick={() => setUserPhoto(null)}
className="w-full bg-gray-500 text-white py-2 rounded-lg hover:bg-gray-600 transition-colors"
>
</button>
</div>
</div>
)}
<div className="mt-6 pt-4 border-t border-gray-200">
<button
onClick={onClose}
className="w-full bg-gray-100 text-gray-600 py-2 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</div>
</div>
</div>
{/* 隐藏的canvas用于生成图片 */}
<canvas ref={canvasRef} style={{ display: 'none' }} />
<canvas ref={cardCanvasRef} style={{ display: 'none' }} />
</div>
);
};
export default CompletionCard;