完成
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"mcp__FramelinkFigmaMCP__get_figma_data",
|
||||||
|
"mcp__FramelinkFigmaMCP__download_figma_images"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
data
|
||||||
|
dist
|
||||||
|
output
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
|
|||||||
BIN
app/favicon.ico
|
Before Width: | Height: | Size: 25 KiB |
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
65
app/page.tsx
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
6034
package-lock.json
generated
20
package.json
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "1",
|
"name": "clawborn",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"dev:server": "node server/stats-server.mjs",
|
||||||
"start": "next start",
|
"dev:all": "node scripts/dev-all.mjs",
|
||||||
"lint": "eslint"
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.7",
|
"html-to-image": "^1.11.13",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
@@ -18,9 +20,9 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"eslint-config-next": "16.1.7",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vite": "^7.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1499
pnpm-lock.yaml
generated
Normal file
BIN
public/lobster-birth-2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/lobster-birth-bg.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/lobster-birth-tx-blank.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
33
public/qr/default-qr.svg
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
import { CertificateGenerator } from "@/ui/certificate-generator";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <CertificateGenerator />;
|
||||||
|
}
|
||||||
610
src/index.css
Normal 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
@@ -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
@@ -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
@@ -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>,
|
||||||
|
);
|
||||||
94
src/templates/lobster-birth-template.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/templates/lobster-birth-tx-template.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/templates/template-primitives.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/ui/certificate-generator.tsx
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
BIN
tmp_tx_form_grid.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
tmp_tx_grid.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
tmp_tx_grid_1576.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2020",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
"allowJs": true,
|
"useDefineForClassFields": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
@@ -12,23 +12,11 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"types": ["vite/client"],
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src"],
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts",
|
|
||||||
"**/*.mts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
26
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||