Files
clawborn/src/ui/certificate-generator.tsx
2026-03-17 17:53:14 +08:00

269 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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