Files
2026-03-10 01:35:37 -05:00

380 lines
12 KiB
TypeScript
Raw Permalink 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.
"use client";
import { useState, useRef, useEffect } from "react";
import {
HERO_CONFIG,
BENEFITS_CONFIG,
CURRICULUM_CONFIG,
CTA_CONFIG,
INSTRUCTORS_CONFIG,
} from "@/config/course";
type Props = { title?: string; url?: string; className?: string };
const POSTER_WIDTH = 750;
const POSTER_HEIGHT = 1334;
/** 使用公共 API 生成二维码图片,无需 npm 包 */
async function getQRImage(url: string, size: number): Promise<HTMLImageElement | null> {
const encoded = encodeURIComponent(url);
const apiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encoded}&format=png&bgcolor=0f172a&color=22d3ee`;
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = apiUrl;
});
}
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number
): number {
const words = text.split("");
let line = "";
let lineCount = 0;
for (const char of words) {
const test = line + char;
const m = ctx.measureText(test);
if (m.width > maxWidth && line) {
ctx.fillText(line, x, y + lineCount * lineHeight);
lineCount++;
line = char;
} else {
line = test;
}
}
if (line) {
ctx.fillText(line, x, y + lineCount * lineHeight);
lineCount++;
}
return lineCount * lineHeight;
}
export function ShareButton({ title = HERO_CONFIG.title, url, className = "" }: Props) {
const [open, setOpen] = useState(false);
const [ready, setReady] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const getShareUrl = () => url || (typeof window !== "undefined" ? window.location.origin : "");
const drawPoster = async () => {
const canvas = canvasRef.current;
if (!canvas || typeof window === "undefined") return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const shareUrl = getShareUrl();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = POSTER_WIDTH * dpr;
canvas.height = POSTER_HEIGHT * dpr;
ctx.scale(dpr, dpr);
const w = POSTER_WIDTH;
const h = POSTER_HEIGHT;
const cyan = "#22d3ee";
const cyanDim = "rgba(34,211,238,0.6)";
const white = "#f8fafc";
const muted = "rgba(248,250,252,0.7)";
const dark = "#0f172a";
const cardBg = "rgba(15,23,42,0.8)";
// ========== 背景:深色科技感 ==========
const bgGrad = ctx.createLinearGradient(0, 0, w, h);
bgGrad.addColorStop(0, "#020617");
bgGrad.addColorStop(0.4, "#0f172a");
bgGrad.addColorStop(0.8, "#0c4a6e");
bgGrad.addColorStop(1, "#0e7490");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, w, h);
// 网格线
ctx.strokeStyle = "rgba(34,211,238,0.06)";
ctx.lineWidth = 1;
const gridStep = 40;
for (let i = 0; i <= w; i += gridStep) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, h);
ctx.stroke();
}
for (let i = 0; i <= h; i += gridStep) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(w, i);
ctx.stroke();
}
// 顶部光晕
const glow = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, 400);
glow.addColorStop(0, "rgba(34,211,238,0.15)");
glow.addColorStop(0.5, "rgba(34,211,238,0.04)");
glow.addColorStop(1, "transparent");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, 350);
// 底部光晕
const glowBot = ctx.createRadialGradient(w / 2, h, 0, w / 2, h, 350);
glowBot.addColorStop(0, "rgba(34,211,238,0.12)");
glowBot.addColorStop(1, "transparent");
ctx.fillStyle = glowBot;
ctx.fillRect(0, h - 400, w, 400);
// ========== 顶部:标题区 ==========
ctx.fillStyle = cyan;
ctx.font = "bold 38px sans-serif";
ctx.textAlign = "center";
ctx.fillText("🌍 " + title, w / 2, 88);
ctx.font = "20px sans-serif";
ctx.fillStyle = muted;
ctx.textAlign = "left";
wrapText(ctx, HERO_CONFIG.subtitle, 48, 128, 654, 26);
ctx.textAlign = "center";
// 分隔线
ctx.strokeStyle = "rgba(34,211,238,0.3)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(60, 168);
ctx.lineTo(w - 60, 168);
ctx.stroke();
// ========== 数据卡片 ==========
const cardY = 192;
const cardW = 210;
const cardH = 68;
const cardGap = (w - cardW * 3) / 4;
HERO_CONFIG.stats.forEach((s, i) => {
const x = cardGap + i * (cardW + cardGap);
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.25)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(x, cardY, cardW, cardH, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = cyan;
ctx.font = "bold 26px sans-serif";
ctx.textAlign = "center";
ctx.fillText(s.value, x + cardW / 2, cardY + 36);
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(s.label, x + cardW / 2, cardY + 56);
});
ctx.textAlign = "center";
// ========== 学完你将获得 ==========
let y = 290;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = white;
ctx.fillText(BENEFITS_CONFIG.title, w / 2, y);
y += 28;
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(BENEFITS_CONFIG.subtitle, w / 2, y);
y += 32;
BENEFITS_CONFIG.items.forEach((item) => {
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.15)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(48, y, w - 96, 40, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = cyanDim;
ctx.font = "15px sans-serif";
ctx.textAlign = "left";
ctx.fillText(`${item.num}. ${item.title}`, 64, y + 26);
ctx.textAlign = "center";
y += 48;
});
// ========== 课程模块 ==========
y += 16;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = white;
ctx.fillText(CURRICULUM_CONFIG.title, w / 2, y);
y += 26;
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(CURRICULUM_CONFIG.subtitle, w / 2, y);
y += 32;
const cols = 2;
const modW = (w - 96 - 12) / cols;
CURRICULUM_CONFIG.modules.forEach((m, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const mx = 48 + col * (modW + 12);
const my = y + row * 44;
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.12)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(mx, my, modW, 38, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = white;
ctx.font = "14px sans-serif";
ctx.textAlign = "left";
ctx.fillText(`${m.emoji} ${m.title}`, mx + 12, my + 24);
ctx.textAlign = "center";
});
y += Math.ceil(CURRICULUM_CONFIG.modules.length / cols) * 44 + 20;
// ========== 徽章 ==========
ctx.font = "15px sans-serif";
ctx.fillStyle = cyanDim;
ctx.fillText("✓ " + CTA_CONFIG.badges.join(" · "), w / 2, y);
y += 44;
// ========== 导师 ==========
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(INSTRUCTORS_CONFIG.subtitle, w / 2, y);
y += 32;
// ========== 二维码区 ==========
const qrSize = 200;
const qrX = (w - qrSize) / 2;
const qrY = y;
const qrImg = await getQRImage(shareUrl, qrSize);
if (qrImg) {
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.4)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(qrX - 10, qrY - 10, qrSize + 20, qrSize + 20, 12);
ctx.fill();
ctx.stroke();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
} else {
ctx.fillStyle = cardBg;
ctx.fillRect(qrX, qrY, qrSize, qrSize);
ctx.fillStyle = cyan;
ctx.font = "13px sans-serif";
ctx.fillText(shareUrl, w / 2, qrY + qrSize / 2 + 5);
}
y += qrSize + 24;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = cyan;
ctx.fillText("扫码访问课程", w / 2, y);
y += 24;
ctx.font = "14px sans-serif";
ctx.fillStyle = muted;
ctx.fillText("长按识别二维码", w / 2, y);
y += 28;
ctx.font = "12px sans-serif";
ctx.fillStyle = "rgba(248,250,252,0.5)";
ctx.fillText("自由工作 · 自在生活", w / 2, y);
setReady(true);
};
useEffect(() => {
if (open && canvasRef.current) {
setReady(false);
drawPoster();
}
}, [open, url]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
if (open) {
window.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
const handleDownload = () => {
const canvas = canvasRef.current;
if (!canvas || !ready) return;
const link = document.createElement("a");
link.download = `分享-${title}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={`inline-flex items-center gap-2 rounded-lg border border-[var(--border)] px-4 py-2 text-sm transition hover:bg-[var(--muted)] ${className}`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={() => setOpen(false)}
>
<div
className="relative max-h-[90vh] overflow-auto rounded-2xl bg-[var(--background)] p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-2 top-2 rounded-full p-2 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h3 className="mb-4 text-center font-semibold"></h3>
<div className="flex justify-center rounded-xl bg-[var(--muted)]/30 p-2">
<canvas
ref={canvasRef}
width={POSTER_WIDTH}
height={POSTER_HEIGHT}
className="max-w-full rounded-lg"
style={{ width: POSTER_WIDTH, height: POSTER_HEIGHT, maxWidth: "100%" }}
/>
</div>
<p className="mt-3 text-center text-sm text-[var(--muted-foreground)]">
</p>
<div className="mt-4 flex justify-center gap-3">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-[var(--border)] px-6 py-2 text-sm transition hover:bg-[var(--muted)]"
>
</button>
<button
type="button"
onClick={handleDownload}
disabled={!ready}
className="rounded-lg bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-50"
>
{ready ? "下载海报" : "生成中..."}
</button>
</div>
</div>
</div>
)}
</>
);
}