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

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>
);
}