This commit is contained in:
2026-03-17 17:53:14 +08:00
parent 65d89084f6
commit 011d4fe60f
33 changed files with 4192 additions and 5212 deletions

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"mcp__FramelinkFigmaMCP__get_figma_data",
"mcp__FramelinkFigmaMCP__download_figma_images"
]
}
}

4
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
data
dist
output
# dependencies
/node_modules
/.pnp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,34 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>多模板证书图片生成器</title>
<meta
name="description"
content="选择模板、填写固定字段并导出证书图片的前端工具页。"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6032
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,17 @@
{
"name": "1",
"name": "clawborn",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"dev": "vite",
"dev:server": "node server/stats-server.mjs",
"dev:all": "node scripts/dev-all.mjs",
"build": "vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"next": "16.1.7",
"html-to-image": "^1.11.13",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -18,9 +20,9 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.7",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vite": "^7.1.10"
}
}

1499
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/lobster-birth-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/lobster-birth-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

33
public/qr/default-qr.svg Normal file
View File

@@ -0,0 +1,33 @@
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="320" height="320" rx="28" fill="white"/>
<rect x="18" y="18" width="284" height="284" rx="20" fill="#0F172A"/>
<rect x="40" y="40" width="70" height="70" rx="8" fill="white"/>
<rect x="56" y="56" width="38" height="38" rx="4" fill="#0F172A"/>
<rect x="210" y="40" width="70" height="70" rx="8" fill="white"/>
<rect x="226" y="56" width="38" height="38" rx="4" fill="#0F172A"/>
<rect x="40" y="210" width="70" height="70" rx="8" fill="white"/>
<rect x="56" y="226" width="38" height="38" rx="4" fill="#0F172A"/>
<rect x="136" y="44" width="16" height="16" rx="2" fill="white"/>
<rect x="164" y="44" width="16" height="16" rx="2" fill="white"/>
<rect x="136" y="72" width="16" height="16" rx="2" fill="white"/>
<rect x="164" y="72" width="16" height="16" rx="2" fill="white"/>
<rect x="136" y="100" width="16" height="16" rx="2" fill="white"/>
<rect x="164" y="128" width="16" height="16" rx="2" fill="white"/>
<rect x="192" y="128" width="16" height="16" rx="2" fill="white"/>
<rect x="220" y="128" width="16" height="16" rx="2" fill="white"/>
<rect x="248" y="128" width="16" height="16" rx="2" fill="white"/>
<rect x="136" y="156" width="16" height="16" rx="2" fill="white"/>
<rect x="164" y="156" width="16" height="16" rx="2" fill="white"/>
<rect x="220" y="156" width="16" height="16" rx="2" fill="white"/>
<rect x="248" y="156" width="16" height="16" rx="2" fill="white"/>
<rect x="136" y="184" width="16" height="16" rx="2" fill="white"/>
<rect x="192" y="184" width="16" height="16" rx="2" fill="white"/>
<rect x="248" y="184" width="16" height="16" rx="2" fill="white"/>
<rect x="128" y="220" width="24" height="24" rx="3" fill="white"/>
<rect x="164" y="220" width="24" height="24" rx="3" fill="white"/>
<rect x="200" y="220" width="24" height="24" rx="3" fill="white"/>
<rect x="236" y="220" width="24" height="24" rx="3" fill="white"/>
<rect x="128" y="256" width="24" height="24" rx="3" fill="white"/>
<rect x="200" y="256" width="24" height="24" rx="3" fill="white"/>
<rect x="236" y="256" width="24" height="24" rx="3" fill="white"/>
</svg>

99
scripts/dev-all.mjs Normal file
View File

@@ -0,0 +1,99 @@
import { execFileSync, spawn } from "node:child_process";
import { existsSync } from "node:fs";
const isWindows = process.platform === "win32";
function detectPackageManager() {
const userAgent = process.env.npm_config_user_agent ?? "";
if (userAgent.startsWith("pnpm/")) {
return "pnpm";
}
if (userAgent.startsWith("yarn/")) {
return "yarn";
}
if (userAgent.startsWith("npm/")) {
return "npm";
}
if (existsSync("pnpm-lock.yaml")) {
return "pnpm";
}
if (existsSync("yarn.lock")) {
return "yarn";
}
return "npm";
}
const packageManager = detectPackageManager();
const commands = [
`${packageManager} run dev:server`,
`${packageManager} run dev`,
];
function startCommand(command) {
if (isWindows) {
return spawn("cmd.exe", ["/d", "/s", "/c", command], {
cwd: process.cwd(),
stdio: "inherit",
});
}
return spawn(command, {
cwd: process.cwd(),
stdio: "inherit",
shell: true,
});
}
const children = commands.map(startCommand);
let shuttingDown = false;
function stopChild(child) {
if (child.exitCode !== null || child.killed) {
return;
}
if (isWindows) {
try {
execFileSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
stdio: "ignore",
});
} catch {
}
return;
}
try {
child.kill("SIGTERM");
} catch {
}
}
function shutdown(code = 0) {
if (shuttingDown) {
return;
}
shuttingDown = true;
children.forEach(stopChild);
process.exit(code);
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
children.forEach((child) => {
child.on("error", () => shutdown(1));
child.on("exit", (code) => {
if (shuttingDown) {
return;
}
shutdown(code ?? 0);
});
});

143
server/stats-server.mjs Normal file
View File

@@ -0,0 +1,143 @@
import { createServer } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const statsFilePath = path.resolve(__dirname, "../data/generation-stats.json");
const port = Number(process.env.PORT || 3001);
const defaultStats = {
total: 0,
byTemplate: {},
updatedAt: null,
};
let writeQueue = Promise.resolve();
async function ensureStatsFile() {
await fs.mkdir(path.dirname(statsFilePath), { recursive: true });
try {
await fs.access(statsFilePath);
} catch {
await fs.writeFile(statsFilePath, JSON.stringify(defaultStats, null, 2), "utf8");
}
}
async function readStats() {
await ensureStatsFile();
const raw = await fs.readFile(statsFilePath, "utf8");
try {
const parsed = JSON.parse(raw);
return {
total: Number(parsed.total || 0),
byTemplate: parsed.byTemplate && typeof parsed.byTemplate === "object" ? parsed.byTemplate : {},
updatedAt: parsed.updatedAt || null,
};
} catch {
return { ...defaultStats };
}
}
function writeStats(nextStats) {
writeQueue = writeQueue.then(() =>
fs.writeFile(statsFilePath, JSON.stringify(nextStats, null, 2), "utf8"),
);
return writeQueue;
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end(JSON.stringify(payload));
}
function readRequestBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
if (!body) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
const server = createServer(async (req, res) => {
if (!req.url || !req.method) {
sendJson(res, 400, { error: "Invalid request." });
return;
}
if (req.method === "OPTIONS") {
sendJson(res, 204, {});
return;
}
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
if (req.method === "GET" && url.pathname === "/api/stats") {
const stats = await readStats();
sendJson(res, 200, stats);
return;
}
if (req.method === "POST" && url.pathname === "/api/generate-count") {
const body = await readRequestBody(req);
const templateId = typeof body.templateId === "string" && body.templateId.trim()
? body.templateId.trim()
: "unknown";
const stats = await readStats();
const nextStats = {
total: stats.total + 1,
byTemplate: {
...stats.byTemplate,
[templateId]: Number(stats.byTemplate[templateId] || 0) + 1,
},
updatedAt: new Date().toISOString(),
};
await writeStats(nextStats);
sendJson(res, 200, nextStats);
return;
}
if (req.method === "GET" && url.pathname === "/api/health") {
sendJson(res, 200, { ok: true });
return;
}
sendJson(res, 404, { error: "Not found." });
} catch (error) {
sendJson(res, 500, {
error: error instanceof Error ? error.message : "Internal server error.",
});
}
});
server.listen(port, async () => {
await ensureStatsFile();
console.log(`Stats server listening on http://localhost:${port}`);
});

5
src/App.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { CertificateGenerator } from "@/ui/certificate-generator";
export default function App() {
return <CertificateGenerator />;
}

610
src/index.css Normal file
View File

@@ -0,0 +1,610 @@
@import "tailwindcss";
:root {
--page-bg: #edf2ff;
--page-accent: #5b6cff;
--page-accent-soft: rgba(91, 108, 255, 0.12);
--page-rose: #ff5f7d;
--panel-bg: rgba(255, 255, 255, 0.82);
--panel-border: rgba(125, 141, 255, 0.18);
--text-main: #172033;
--text-soft: #64748b;
--shadow-soft: 0 18px 60px rgba(70, 88, 170, 0.12);
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
color: var(--text-main);
background:
radial-gradient(circle at top left, rgba(255, 201, 214, 0.55), transparent 22%),
radial-gradient(circle at top right, rgba(151, 201, 255, 0.4), transparent 24%),
linear-gradient(180deg, #f8fbff 0%, #eef2ff 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
}
button,
input,
textarea {
font: inherit;
}
img {
display: block;
max-width: 100%;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
#root {
min-height: 100vh;
}
.app-shell {
min-height: 100vh;
padding: 28px 18px 36px;
background:
radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.85), transparent 24%),
linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.32));
}
.app-shell--empty {
display: grid;
place-items: center;
background: #111827;
color: #f8fafc;
}
.page {
margin: 0 auto;
max-width: 1440px;
}
.page--studio {
display: grid;
gap: 20px;
}
.studio-header {
display: flex;
flex-direction: column;
gap: 16px;
justify-content: space-between;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 28px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(244, 247, 255, 0.88));
padding: 22px 24px;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
}
.studio-kicker,
.section-kicker {
margin: 0 0 8px;
color: var(--page-accent);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.studio-header h1,
.panel-head h2 {
margin: 0;
color: var(--text-main);
font-weight: 800;
letter-spacing: -0.03em;
}
.studio-header h1 {
font-size: clamp(28px, 4vw, 42px);
}
.studio-copy,
.panel-head p {
margin: 10px 0 0;
color: var(--text-soft);
font-size: 14px;
line-height: 1.7;
}
.studio-summary {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.studio-summary span {
border: 1px solid rgba(91, 108, 255, 0.16);
border-radius: 999px;
background: rgba(91, 108, 255, 0.08);
padding: 10px 14px;
color: var(--page-accent);
font-size: 13px;
font-weight: 700;
}
.panel {
border: 1px solid var(--panel-border);
border-radius: 28px;
background: var(--panel-bg);
padding: 20px;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
}
.panel--soft {
position: relative;
overflow: hidden;
}
.panel--soft::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.42), transparent 38%);
pointer-events: none;
}
.panel-head {
position: relative;
z-index: 1;
margin-bottom: 18px;
}
.panel-head--inline {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: space-between;
}
.panel-head--preview {
margin-bottom: 14px;
}
.panel-head h2 {
font-size: 24px;
}
.panel--templates {
padding: 22px;
}
.template-strip {
display: grid;
gap: 16px;
position: relative;
z-index: 1;
}
.template-card {
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
padding: 0;
text-align: left;
cursor: pointer;
transition:
transform 0.22s ease,
box-shadow 0.22s ease,
border-color 0.22s ease;
}
.template-card:hover {
transform: translateY(-3px);
border-color: rgba(91, 108, 255, 0.34);
box-shadow: 0 18px 34px rgba(91, 108, 255, 0.14);
}
.template-card.is-active {
border-color: rgba(91, 108, 255, 0.48);
box-shadow: 0 20px 42px rgba(91, 108, 255, 0.2);
}
.template-card__preview {
position: relative;
aspect-ratio: 16 / 5;
overflow: hidden;
background: linear-gradient(135deg, #e9eeff, #f8faff);
}
.template-card__preview::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 42%;
background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.16));
}
.template-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.template-card__body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px 16px;
}
.template-card__title {
margin: 0;
color: var(--text-main);
font-size: 15px;
font-weight: 800;
}
.template-card__meta {
margin: 5px 0 0;
color: var(--text-soft);
font-size: 12px;
}
.template-card__tag {
border-radius: 999px;
background: rgba(91, 108, 255, 0.08);
padding: 7px 12px;
color: var(--page-accent);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.template-card.is-active .template-card__tag {
background: linear-gradient(135deg, var(--page-accent), #7f8dff);
color: #fff;
}
.workspace--studio {
display: grid;
gap: 20px;
}
.panel--fields {
min-height: 100%;
}
.form-grid {
position: relative;
z-index: 1;
display: grid;
gap: 14px;
}
.field {
display: grid;
gap: 8px;
color: var(--text-main);
font-size: 14px;
font-weight: 700;
}
.field input,
.field textarea {
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 18px;
background: rgba(255, 255, 255, 0.88);
padding: 13px 15px;
color: var(--text-main);
outline: none;
transition:
border-color 0.22s ease,
box-shadow 0.22s ease,
background 0.22s ease,
transform 0.22s ease;
resize: vertical;
}
.field input::placeholder,
.field textarea::placeholder {
color: #94a3b8;
}
.field input {
min-height: 48px;
}
.field textarea {
min-height: 88px;
}
.field input:focus,
.field textarea:focus {
border-color: rgba(91, 108, 255, 0.54);
background: #fff;
box-shadow: 0 0 0 4px rgba(91, 108, 255, 0.12);
transform: translateY(-1px);
}
.panel-note {
position: relative;
z-index: 1;
margin-top: 16px;
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 18px;
background: rgba(255, 251, 235, 0.88);
padding: 12px 14px;
color: #92400e;
font-size: 13px;
line-height: 1.6;
}
.panel--preview-shell {
display: flex;
flex-direction: column;
}
.preview-status {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.preview-status span {
border-radius: 999px;
background: rgba(91, 108, 255, 0.08);
padding: 8px 12px;
color: var(--page-accent);
font-size: 12px;
font-weight: 700;
}
.preview-frame {
position: relative;
z-index: 1;
overflow: auto;
}
.preview-frame--studio {
border-radius: 26px;
background:
radial-gradient(circle at top left, rgba(91, 108, 255, 0.08), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(239, 244, 255, 0.86));
padding: 18px;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
}
.preview-canvas {
position: relative;
margin: 0 auto;
overflow: hidden;
border-radius: 24px;
background: #f9fafb;
box-shadow: 0 24px 44px rgba(64, 78, 150, 0.18);
}
.preview-background,
.preview-text,
.preview-qr,
.preview-stats {
position: absolute;
}
.preview-background {
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-text {
white-space: pre-wrap;
word-break: break-word;
}
.preview-qr {
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
padding: 8px;
box-shadow: 0 14px 26px rgba(15, 23, 42, 0.12);
}
.preview-stats {
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
padding: 12px 20px;
color: var(--page-rose);
font-weight: 800;
line-height: 1;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
.download-row {
display: flex;
justify-content: right;
margin-top: 16px;
}
.download-row button {
min-width: 148px;
border: 0;
border-radius: 999px;
background: linear-gradient(135deg, var(--page-accent), var(--page-rose));
padding: 12px 24px;
color: #fff;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.02em;
cursor: pointer;
box-shadow: 0 14px 30px rgba(91, 108, 255, 0.24);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
}
.download-row button:hover:enabled {
transform: translateY(-2px);
box-shadow: 0 18px 34px rgba(91, 108, 255, 0.3);
filter: saturate(1.05);
}
.download-row button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.error-box {
position: relative;
z-index: 1;
margin-bottom: 14px;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 18px;
background: rgba(254, 242, 242, 0.9);
padding: 12px 14px;
color: #b91c1c;
font-size: 13px;
}
@media (min-width: 900px) {
.studio-header {
flex-direction: row;
align-items: flex-end;
}
.panel-head--inline {
flex-direction: row;
align-items: flex-end;
}
.template-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1080px) {
.workspace--studio {
grid-template-columns: 300px minmax(0, 1fr);
align-items: start;
}
}
.preview-frame {
position: relative;
}
.preview-canvas {
position: relative;
overflow: hidden;
border-radius: 24px;
background: #dbe4ff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
}
.preview-canvas--tx {
background: linear-gradient(180deg, #edf3ff 0%, #dbe7ff 100%);
}
.preview-background {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
user-select: none;
pointer-events: none;
}
.preview-background--cover {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.preview-text {
position: absolute;
z-index: 2;
white-space: pre-wrap;
word-break: break-word;
}
.preview-text--tx {
text-shadow: 0 4px 18px rgba(5, 21, 66, 0.12);
}
.preview-text--tx-count {
position: absolute;
z-index: 3;
white-space: nowrap;
word-break: normal;
}
.preview-text--layer {
z-index: 3;
}
.preview-qr {
position: absolute;
z-index: 2;
object-fit: cover;
}
.preview-qr--tx {
border-radius: 18px;
}
.preview-stats {
margin-top: 14px;
display: inline-flex;
align-self: flex-end;
border: 1px solid rgba(99, 102, 241, 0.18);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
padding: 10px 14px;
color: #ef4444;
font-size: 13px;
font-weight: 800;
}
.template-card__preview--component {
display: grid;
place-items: center;
aspect-ratio: auto;
padding: 18px;
background: linear-gradient(135deg, #e9eeff, #f8faff);
}
.template-card__preview-shell {
width: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: flex-start;
}
.preview-frame--studio {
overflow: auto;
display: flex;
justify-content: center;
}
.export-stage {
position: fixed;
left: -20000px;
top: 0;
opacity: 0.01;
pointer-events: none;
z-index: -1;
}

68
src/lib/certificate.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { ComponentType } from "react";
import { LobsterBirthTemplate } from "@/templates/lobster-birth-template";
import { LobsterBirthTxTemplate } from "@/templates/lobster-birth-tx-template";
export type CertificateFieldKey =
| "childName"
| "familyAddress"
| "parentName"
| "birthDate"
| "birthAddress";
export type CertificateFormData = Record<CertificateFieldKey, string>;
export type CertificateTemplateComponentProps = {
formData: CertificateFormData;
qrSrc: string;
currentCount: number;
};
export type CertificateTemplateDefinition = {
id: string;
name: string;
width: number;
height: number;
Component: ComponentType<CertificateTemplateComponentProps>;
};
export const certificateFieldLabels: Record<CertificateFieldKey, string> = {
childName: "小龙虾姓名",
familyAddress: "家庭住址",
parentName: "家长姓名",
birthDate: "出生日期",
birthAddress: "出生地址",
};
export const certificateFieldOrder: CertificateFieldKey[] = [
"childName",
"familyAddress",
"parentName",
"birthDate",
"birthAddress",
];
export const customTemplateUploadEnabled =
import.meta.env.VITE_ENABLE_CUSTOM_TEMPLATE_UPLOAD === "true";
const defaultQrSrc = "/qr/default-qr.svg";
export function getQrSource() {
return import.meta.env.VITE_CERTIFICATE_QR_SRC || defaultQrSrc;
}
export const certificateTemplates: CertificateTemplateDefinition[] = [
{
id: "lobster-birth",
name: "小龙虾出生证明",
width: 1576,
height: 1080,
Component: LobsterBirthTemplate,
},
{
id: "lobster-birth-tx",
name: "小龙虾出生证明腾讯云版",
width: 1576,
height: 1080,
Component: LobsterBirthTxTemplate,
},
];

37
src/lib/stats.ts Normal file
View File

@@ -0,0 +1,37 @@
export type StatsResponse = {
total: number;
byTemplate: Record<string, number>;
updatedAt: string | null;
};
const configuredStatsApiBase = (import.meta.env.VITE_STATS_API_BASE || "").replace(/\/$/, "");
function buildStatsApiUrl(path: string) {
return configuredStatsApiBase ? `${configuredStatsApiBase}${path}` : path;
}
export async function fetchStats(): Promise<StatsResponse> {
const response = await fetch(buildStatsApiUrl("/api/stats"));
if (!response.ok) {
throw new Error("获取统计信息失败。");
}
return response.json();
}
export async function incrementGenerationCount(templateId: string): Promise<StatsResponse> {
const response = await fetch(buildStatsApiUrl("/api/generate-count"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ templateId }),
});
if (!response.ok) {
throw new Error("记录生成次数失败。");
}
return response.json();
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,94 @@
import type { CSSProperties } from "react";
import type { CertificateTemplateComponentProps } from "@/lib/certificate";
import { TemplateShell } from "@/templates/template-primitives";
const width = 1576;
const height = 1080;
const fieldValueStyle: CSSProperties = {
position: "absolute",
fontSize: 32,
fontWeight: 700,
color: "#664def",
whiteSpace: "nowrap",
};
export function LobsterBirthTemplate({ formData, qrSrc, currentCount }: CertificateTemplateComponentProps) {
return (
<TemplateShell
width={width}
height={height}
style={{
position: "relative",
background: "#FFFFFF",
borderRadius: 46,
}}
>
<img
src="/lobster-birth-2.png"
alt="小龙虾出生证明"
style={{
position: "absolute",
left: 0,
top: 0,
width,
height,
objectFit: "cover",
borderRadius: 46,
}}
/>
{/* 小龙虾姓名 */}
<div style={{ ...fieldValueStyle, left: 1000, top: 468 }}>
{formData.childName}
</div>
{/* 家庭住址 */}
<div style={{ ...fieldValueStyle, left: 1000, top: 546 }}>
{formData.familyAddress}
</div>
{/* 家长姓名 */}
<div style={{ ...fieldValueStyle, left: 1000, top: 624 }}>
{formData.parentName}
</div>
{/* 出生日期 */}
<div style={{ ...fieldValueStyle, left: 1000, top: 712 }}>
{formData.birthDate}
</div>
{/* 出生地址 */}
<div style={{ ...fieldValueStyle, left: 1000, top: 790 }}>
{formData.birthAddress}
</div>
<img
src={qrSrc}
alt="二维码"
style={{
position: "absolute",
right: 139,
top: 220,
width: 230,
height: 230,
objectFit: "cover",
borderRadius: 8,
}}
/>
{/* 第 X 个出生的龙虾宝宝 */}
<div
style={{
position: "absolute",
left: 760,
top: 877,
width: 557,
height: 63,
fontSize: 45,
fontWeight: 700,
fontFamily: '"Alimama ShuHeiTi", sans-serif',
lineHeight: "1.23em",
color: "#566BF8",
WebkitTextStroke: "4px #ffffff",
paintOrder: "stroke fill",
}}
>
<span style={{ color: "#e53e3e" }}>{currentCount}</span>
</div>
</TemplateShell>
);
}

View File

@@ -0,0 +1,114 @@
import type { CSSProperties } from "react";
import type { CertificateTemplateComponentProps } from "@/lib/certificate";
import { TemplateShell } from "@/templates/template-primitives";
const width = 1576;
const height = 1080;
const backgroundSrc = "/lobster-birth-tx-blank.png";
const fieldTextBaseStyle: CSSProperties = {
position: "absolute",
height: 50,
paddingInline: 0,
fontSize: 38,
fontWeight: 500,
lineHeight: "50px",
color: "#202020",
fontFamily: '"Microsoft YaHei", "PingFang SC", sans-serif',
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
const fieldLayouts = {
childName: { left: 470, top: 340, minWidth: 470 },
familyAddress: { left: 420, top: 420, minWidth: 470 },
parentName: { left: 420, top: 510, minWidth: 470 },
birthDate: { left: 420, top: 600, minWidth: 470 },
birthAddress: { left: 420, top: 690, minWidth: 470 },
} as const;
function normalizeFieldValue(value: string) {
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
}
function FieldText({
value,
style,
}: {
value: string;
style: CSSProperties;
}) {
const text = normalizeFieldValue(value);
if (!text) {
return null;
}
return <div style={{ ...fieldTextBaseStyle, ...style }}>{text}</div>;
}
export function LobsterBirthTxTemplate({ formData, qrSrc, currentCount }: CertificateTemplateComponentProps) {
return (
<TemplateShell
width={width}
height={height}
style={{
position: "relative",
borderRadius: 0,
boxShadow: "none",
}}
>
<img
src={backgroundSrc}
alt="小龙虾出生证明腾讯云版背景"
style={{
width,
height,
objectFit: "cover",
display: "block",
userSelect: "none",
pointerEvents: "none",
}}
/>
<img
src={qrSrc}
alt="二维码"
style={{
position: "absolute",
left: 200,
top: 776,
width: 165,
height: 165,
objectFit: "cover",
borderRadius: 8
}}
/>
<FieldText value={formData.childName} style={fieldLayouts.childName} />
<FieldText value={formData.familyAddress} style={fieldLayouts.familyAddress} />
<FieldText value={formData.parentName} style={fieldLayouts.parentName} />
<FieldText value={formData.birthDate} style={fieldLayouts.birthDate} />
<FieldText value={formData.birthAddress} style={fieldLayouts.birthAddress} />
<div
style={{
position: "absolute",
left: 820,
top: 915,
width: 520,
fontSize: 40,
fontWeight: 700,
lineHeight: "1.22",
color: "#323232",
fontFamily: '"Alimama ShuHeiTi", "Microsoft YaHei", sans-serif',
WebkitTextStroke: "4px #ffffff",
paintOrder: "stroke fill",
textAlign: "center",
}}
>
<span style={{ color: "#e53e3e" }}>{currentCount}</span>
</div>
</TemplateShell>
);
}

View File

@@ -0,0 +1,141 @@
import type { CSSProperties, ReactNode } from "react";
const previewShadow = "0 24px 60px rgba(15, 23, 42, 0.16)";
export function TemplateScaleFrame({
width,
height,
scale,
children,
}: {
width: number;
height: number;
scale: number;
children: ReactNode;
}) {
return (
<div
style={{
width: `${width * scale}px`,
height: `${height * scale}px`,
overflow: "hidden",
}}
>
<div
style={{
width: `${width}px`,
height: `${height}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
{children}
</div>
</div>
);
}
export function TemplateShell({
width,
height,
children,
style,
}: {
width: number;
height: number;
children: ReactNode;
style?: CSSProperties;
}) {
return (
<div
style={{
width,
height,
display: "flex",
flexDirection: "column",
overflow: "hidden",
borderRadius: 42,
boxShadow: previewShadow,
...style,
}}
>
{children}
</div>
);
}
export function CertificateFieldCard({
label,
value,
accent,
dark = false,
}: {
label: string;
value: string;
accent: string;
dark?: boolean;
}) {
return (
<div
style={{
display: "grid",
gap: 14,
padding: "22px 26px",
borderRadius: 28,
border: dark ? "1px solid rgba(147, 197, 253, 0.18)" : "1px solid rgba(91, 108, 255, 0.16)",
background: dark
? "linear-gradient(180deg, rgba(13, 38, 88, 0.76), rgba(6, 22, 60, 0.74))"
: "linear-gradient(180deg, rgba(255,255,255,0.92), rgba(244,247,255,0.9))",
}}
>
<div
style={{
fontSize: 22,
fontWeight: 800,
letterSpacing: "0.12em",
color: dark ? "rgba(216, 233, 255, 0.72)" : accent,
}}
>
{label}
</div>
<div
style={{
minHeight: 58,
paddingBottom: 10,
borderBottom: dark ? "3px solid rgba(255,255,255,0.2)" : `3px solid ${accent}22`,
fontSize: 44,
fontWeight: 700,
lineHeight: 1.24,
color: dark ? "#d8e9ff" : accent,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{value}
</div>
</div>
);
}
export function StatsBadge({ total }: { total: number }) {
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "12px 18px",
borderRadius: 999,
border: "1px solid rgba(99, 102, 241, 0.18)",
background: "rgba(255, 255, 255, 0.92)",
boxShadow: "0 14px 28px rgba(15, 23, 42, 0.08)",
color: "#ef4444",
fontSize: 26,
fontWeight: 800,
letterSpacing: "0.04em",
}}
>
{total}
</div>
);
}

View File

@@ -0,0 +1,268 @@
import { toPng } from "html-to-image";
import type { ChangeEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
CertificateFieldKey,
CertificateFormData,
CertificateTemplateDefinition,
} from "@/lib/certificate";
import {
certificateFieldLabels,
certificateFieldOrder,
certificateTemplates,
customTemplateUploadEnabled,
getQrSource,
} from "@/lib/certificate";
import { fetchStats, incrementGenerationCount, type StatsResponse } from "@/lib/stats";
import { TemplateScaleFrame } from "@/templates/template-primitives";
const defaultFormData: CertificateFormData = {
childName: "",
familyAddress: "",
parentName: "",
birthDate: "",
birthAddress: "",
};
const emptyStats: StatsResponse = {
total: 0,
byTemplate: {},
updatedAt: null,
};
const previewWidth = 760;
const statsUnavailableMessage =
"统计服务未连接,当前数量不会持久化。请同时运行 npm run dev 和 npm run dev:server或直接运行 npm run dev:all。";
const statsIncrementFailedMessage =
"图片已下载,但统计服务没有记录这次生成。请同时运行 npm run dev 和 npm run dev:server或直接运行 npm run dev:all。";
function TemplateThumbnail({
template,
formData,
qrSource,
currentCount,
}: {
template: CertificateTemplateDefinition;
formData: CertificateFormData;
qrSource: string;
currentCount: number;
}) {
const scale = 220 / template.width;
const Component = template.Component;
return (
<div className="template-card__preview-shell" style={{ height: `${template.height * scale}px` }}>
<TemplateScaleFrame width={template.width} height={template.height} scale={scale}>
<Component formData={formData} qrSrc={qrSource} currentCount={currentCount} />
</TemplateScaleFrame>
</div>
);
}
export function CertificateGenerator() {
const [selectedTemplateId, setSelectedTemplateId] = useState(
certificateTemplates[0]?.id ?? "",
);
const [formData, setFormData] = useState<CertificateFormData>(defaultFormData);
const [stats, setStats] = useState<StatsResponse>(emptyStats);
const [isExporting, setIsExporting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [statsMessage, setStatsMessage] = useState("");
const exportRef = useRef<HTMLDivElement>(null);
const selectedTemplate = useMemo(
() =>
certificateTemplates.find((template) => template.id === selectedTemplateId) ??
certificateTemplates[0],
[selectedTemplateId],
);
const qrSource = useMemo(() => getQrSource(), []);
const currentCount = stats.total + 1;
const SelectedTemplateComponent = selectedTemplate.Component;
useEffect(() => {
let active = true;
fetchStats()
.then((nextStats) => {
if (!active) return;
setStats(nextStats);
setStatsMessage("");
})
.catch(() => {
if (!active) return;
setStatsMessage(statsUnavailableMessage);
});
return () => {
active = false;
};
}, []);
const handleChange =
(key: CertificateFieldKey) =>
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((current) => ({
...current,
[key]: event.target.value,
}));
};
const handleExport = async () => {
if (!exportRef.current) {
setErrorMessage("导出节点尚未准备好,请稍后重试。");
return;
}
setIsExporting(true);
setErrorMessage("");
try {
const dataUrl = await toPng(exportRef.current, {
cacheBust: true,
pixelRatio: 1,
canvasWidth: selectedTemplate.width,
canvasHeight: selectedTemplate.height,
backgroundColor: "#ffffff",
});
const link = document.createElement("a");
link.href = dataUrl;
link.download = `${selectedTemplate.name}.png`;
link.click();
try {
const nextStats = await incrementGenerationCount(selectedTemplate.id);
setStats(nextStats);
setStatsMessage("");
} catch {
setStatsMessage(statsIncrementFailedMessage);
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "导出失败,请稍后重试。");
} finally {
setIsExporting(false);
}
};
return (
<main className="app-shell">
<div className="page page--studio">
<section className="panel panel--templates panel--soft">
<div className="panel-head panel-head--inline">
<div>
<h2></h2>
</div>
</div>
<div className="template-strip">
{certificateTemplates.map((template) => {
const active = template.id === selectedTemplate.id;
return (
<button
key={template.id}
type="button"
onClick={() => setSelectedTemplateId(template.id)}
className={`template-card ${active ? "is-active" : ""}`}
>
<div className="template-card__preview template-card__preview--component">
<TemplateThumbnail
template={template}
formData={formData}
qrSource={qrSource}
currentCount={currentCount}
/>
</div>
<div className="template-card__body">
<div>
<p className="template-card__title">{template.name}</p>
<p className="template-card__meta">{template.width} x {template.height}</p>
</div>
<span className="template-card__tag">{active ? "当前模板" : "点击切换"}</span>
</div>
</button>
);
})}
</div>
</section>
<section className="workspace workspace--studio">
<aside className="panel panel--fields panel--soft">
<div className="panel-head">
<h2></h2>
</div>
<div className="form-grid">
{certificateFieldOrder.map((key) => (
<label key={key} className="field">
<span>{certificateFieldLabels[key]}</span>
{key === "familyAddress" || key === "birthAddress" ? (
<textarea
rows={2}
value={formData[key]}
onChange={handleChange(key)}
placeholder={`请输入${certificateFieldLabels[key]}`}
/>
) : (
<input
value={formData[key]}
onChange={handleChange(key)}
placeholder={`请输入${certificateFieldLabels[key]}`}
/>
)}
</label>
))}
</div>
{customTemplateUploadEnabled ? (
<div className="panel-note">
<code>VITE_ENABLE_CUSTOM_TEMPLATE_UPLOAD</code>
</div>
) : null}
</aside>
<section className="panel panel--preview-shell panel--soft">
<div className="panel-head panel-head--inline panel-head--preview">
<div>
<h2></h2>
</div>
</div>
{errorMessage ? <div className="error-box">{errorMessage}</div> : null}
{statsMessage ? <div className="error-box">{statsMessage}</div> : null}
<div className="preview-frame preview-frame--studio">
<TemplateScaleFrame
width={selectedTemplate.width}
height={selectedTemplate.height}
scale={previewWidth / selectedTemplate.width}
>
<SelectedTemplateComponent
formData={formData}
qrSrc={qrSource}
currentCount={currentCount}
/>
</TemplateScaleFrame>
</div>
<div className="download-row">
<button type="button" onClick={handleExport} disabled={isExporting}>
{isExporting ? "导出中..." : "下载图片"}
</button>
</div>
</section>
</section>
<div className="export-stage" aria-hidden="true">
<div ref={exportRef}>
<SelectedTemplateComponent
formData={formData}
qrSrc={qrSource}
currentCount={currentCount}
/>
</div>
</div>
</div>
</main>
);
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

BIN
tmp_tx_form_grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
tmp_tx_grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
tmp_tx_grid_1576.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"useDefineForClassFields": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
@@ -12,23 +12,11 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"types": ["vite/client"],
"paths": {
"@/*": ["./*"]
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"include": ["src"],
"exclude": ["node_modules"]
}

26
vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
const statsProxyTarget = process.env.VITE_STATS_PROXY_TARGET || "http://127.0.0.1:3001";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
port: 6412,
strictPort: true,
allowedHosts: ["clawborn.staroceanai.cloud"],
proxy: {
"/api": {
target: statsProxyTarget,
changeOrigin: true,
},
},
},
});