This commit is contained in:
2026-03-18 11:26:18 +08:00
parent 9245ce944d
commit b304c5d313
4 changed files with 606 additions and 300 deletions

View File

@@ -1,7 +1,7 @@
{
"total": 0,
"total": 1,
"byTemplate": {
"lobster-birth": 1
"lobster-birth": 2
},
"updatedAt": "2026-03-17T09:44:46.005Z"
"updatedAt": "2026-03-18T03:21:37.604Z"
}

View File

@@ -41,6 +41,10 @@ textarea {
font: inherit;
}
button {
border: 0;
}
img {
display: block;
max-width: 100%;
@@ -62,13 +66,6 @@ code {
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;
@@ -79,65 +76,6 @@ code {
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;
@@ -168,7 +106,7 @@ code {
.panel-head--inline {
display: flex;
flex-direction: column;
flex-direction: row;
gap: 12px;
justify-content: space-between;
}
@@ -178,7 +116,31 @@ code {
}
.panel-head h2 {
margin: 0;
color: var(--text-main);
font-size: 24px;
font-weight: 800;
letter-spacing: -0.03em;
}
.template-strip__summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
position: relative;
z-index: 1;
}
.template-strip__summary span {
display: inline-flex;
align-items: center;
min-height: 28px;
border-radius: 999px;
background: rgba(91, 108, 255, 0.08);
padding: 0 12px;
color: var(--page-accent);
font-size: 12px;
font-weight: 700;
}
.panel--templates {
@@ -186,13 +148,22 @@ code {
}
.template-strip {
display: grid;
gap: 16px;
position: relative;
z-index: 1;
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
scrollbar-width: none;
}
.template-strip::-webkit-scrollbar {
display: none;
}
.template-card {
flex: 0 0 min(280px, 84vw);
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 24px;
@@ -200,6 +171,7 @@ code {
padding: 0;
text-align: left;
cursor: pointer;
scroll-snap-align: start;
transition:
transform 0.22s ease,
box-shadow 0.22s ease,
@@ -219,7 +191,6 @@ code {
.template-card__preview {
position: relative;
aspect-ratio: 16 / 5;
overflow: hidden;
background: linear-gradient(135deg, #e9eeff, #f8faff);
}
@@ -232,17 +203,18 @@ code {
background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.16));
}
.template-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
.template-card__preview--component {
display: grid;
place-items: center;
padding: 16px;
background: linear-gradient(135deg, #e9eeff, #f8faff);
}
.template-card__body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 12px;
padding: 14px 16px 16px;
}
@@ -274,6 +246,50 @@ code {
color: #fff;
}
.template-card__preview-shell {
width: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: flex-start;
}
.template-strip__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
position: relative;
z-index: 1;
margin-top: 14px;
}
.template-strip__dots {
display: inline-flex;
align-items: center;
gap: 8px;
}
.template-strip__dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(91, 108, 255, 0.18);
padding: 0;
box-shadow: inset 0 0 0 1px rgba(91, 108, 255, 0.08);
}
.template-strip__dot.is-active {
width: 24px;
background: linear-gradient(135deg, var(--page-accent), #7f8dff);
}
.template-strip__position {
color: var(--text-soft);
font-size: 12px;
font-weight: 700;
}
.workspace--studio {
display: grid;
gap: 20px;
@@ -290,6 +306,10 @@ code {
gap: 14px;
}
.form-grid--mobile {
gap: 16px;
}
.field {
display: grid;
gap: 8px;
@@ -353,29 +373,16 @@ code {
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 {
overflow: hidden;
display: flex;
justify-content: center;
align-items: flex-start;
border-radius: 26px;
background:
radial-gradient(circle at top left, rgba(91, 108, 255, 0.08), transparent 26%),
@@ -384,88 +391,72 @@ code {
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;
justify-content: flex-end;
margin-top: 16px;
}
.download-row button {
min-width: 148px;
border: 0;
.download-row button,
.mobile-preview-actions__primary,
.mobile-preview-actions__secondary,
.mobile-form-sheet__back,
.mobile-form-sheet__close,
.mobile-form-sheet__done {
border-radius: 999px;
font-weight: 800;
letter-spacing: 0.02em;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease,
background 0.2s ease;
}
.download-row button,
.mobile-preview-actions__primary,
.mobile-form-sheet__done {
min-width: 148px;
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 {
.mobile-preview-actions__secondary,
.mobile-form-sheet__back,
.mobile-form-sheet__close {
background: rgba(255, 255, 255, 0.9);
color: var(--text-main);
border: 1px solid rgba(148, 163, 184, 0.24);
}
.download-row button:hover:enabled,
.mobile-preview-actions__primary:hover:enabled,
.mobile-preview-actions__secondary:hover,
.mobile-form-sheet__back:hover,
.mobile-form-sheet__close:hover,
.mobile-form-sheet__done:hover {
transform: translateY(-2px);
box-shadow: 0 18px 34px rgba(91, 108, 255, 0.3);
box-shadow: 0 18px 34px rgba(91, 108, 255, 0.24);
filter: saturate(1.05);
}
.download-row button:disabled {
.download-row button:disabled,
.mobile-preview-actions__primary:disabled,
.mobile-form-sheet__done:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.mobile-preview-actions {
display: none;
}
.error-box {
position: relative;
z-index: 1;
@@ -476,128 +467,11 @@ code {
padding: 12px 14px;
color: #b91c1c;
font-size: 13px;
line-height: 1.6;
}
@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;
.mobile-form-sheet {
display: none;
}
.export-stage {
@@ -608,3 +482,272 @@ code {
pointer-events: none;
z-index: -1;
}
@media (min-width: 900px) {
.panel-head--inline {
flex-direction: row;
align-items: flex-end;
}
.template-strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: visible;
padding-bottom: 0;
}
.template-card {
flex-basis: auto;
}
}
@media (min-width: 1080px) {
.workspace--studio {
grid-template-columns: 300px minmax(0, 1fr);
align-items: start;
}
}
@media (max-width: 767px) {
.app-shell {
padding: 14px 12px 24px;
}
.page--studio {
gap: 14px;
}
.panel {
border-radius: 22px;
padding: 16px;
}
.panel-head {
margin-bottom: 14px;
}
.panel-head h2 {
font-size: 20px;
}
.panel--templates {
padding: 16px;
}
.template-card {
flex: 0 0 72vw;
border-radius: 20px;
}
.template-card__preview--component {
padding: 12px;
}
.template-card__body {
align-items: flex-start;
padding: 12px 14px 14px;
}
.template-card__title {
font-size: 14px;
}
.template-card__tag {
padding: 6px 10px;
font-size: 11px;
}
.template-strip__summary {
justify-content: space-between;
}
.template-strip__summary span:last-child {
color: #7c89c0;
background: rgba(124, 137, 192, 0.1);
}
.template-strip {
padding-right: 18vw;
}
.template-strip__footer {
margin-top: 12px;
}
.panel--fields {
display: none;
}
.preview-frame--studio {
padding: 10px;
border-radius: 20px;
}
.download-row {
display: none;
}
.mobile-preview-actions {
position: relative;
z-index: 1;
display: grid;
gap: 12px;
margin-top: 16px;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 4px);
}
.mobile-preview-actions__primary,
.mobile-preview-actions__secondary {
min-height: 52px;
width: 100%;
padding: 14px 18px;
font-size: 16px;
text-align: center;
}
.error-box {
border-radius: 16px;
font-size: 12px;
}
.mobile-form-sheet {
position: fixed;
inset: 0;
z-index: 60;
display: block;
pointer-events: none;
}
.mobile-form-sheet__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.38);
opacity: 0;
transition: opacity 0.22s ease;
}
.mobile-form-sheet__panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #f9fbff 0%, #eef3ff 100%);
transform: translateY(100%);
transition: transform 0.22s ease;
box-shadow: 0 -18px 40px rgba(15, 23, 42, 0.16);
}
.mobile-form-sheet.is-open {
pointer-events: auto;
}
.mobile-form-sheet.is-open .mobile-form-sheet__backdrop {
opacity: 1;
}
.mobile-form-sheet.is-open .mobile-form-sheet__panel {
transform: translateY(0);
}
.mobile-form-sheet__header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 18px 16px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(18px);
}
.mobile-form-sheet__header h2 {
margin: 0;
font-size: 18px;
font-weight: 800;
text-align: center;
}
.mobile-form-sheet__back,
.mobile-form-sheet__close {
min-height: 40px;
padding: 0 14px;
font-size: 13px;
}
.mobile-form-sheet__body {
flex: 1;
overflow-y: auto;
padding: 16px 16px 24px;
}
.form-grid--mobile .field {
gap: 10px;
font-size: 15px;
}
.form-grid--mobile .field input,
.form-grid--mobile .field textarea {
min-height: 52px;
padding: 14px 16px;
border-radius: 16px;
}
.form-grid--mobile .field textarea {
min-height: 112px;
}
.mobile-form-sheet__footer {
padding: 14px 16px calc(env(safe-area-inset-bottom, 0px) + 18px);
border-top: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(18px);
}
.mobile-form-sheet__done {
width: 100%;
min-height: 52px;
font-size: 16px;
}
}
@media (max-width: 479px) {
.app-shell {
padding-inline: 10px;
}
.panel,
.panel--templates {
padding: 14px;
}
.template-card {
flex-basis: 82vw;
}
.template-strip {
padding-right: 20vw;
}
.template-strip__summary {
gap: 6px;
}
.template-strip__summary span,
.template-strip__position {
font-size: 11px;
}
.preview-frame--studio {
padding: 8px;
}
.mobile-preview-actions {
gap: 10px;
}
.mobile-form-sheet__header,
.mobile-form-sheet__body,
.mobile-form-sheet__footer {
padding-inline: 14px;
}
}

View File

@@ -61,7 +61,7 @@ export const certificateTemplates: CertificateTemplateDefinition[] = [
},
{
id: "lobster-birth-tx",
name: "小龙虾出生证明腾讯云版",
name: "小龙虾出生证明",
width: 1576,
height: 1080,
Component: LobsterBirthTxTemplate,

View File

@@ -30,7 +30,8 @@ const emptyStats: StatsResponse = {
updatedAt: null,
};
const previewWidth = 760;
const desktopPreviewWidth = 760;
const mobileBreakpoint = 768;
const statsUnavailableMessage =
"统计服务未连接,当前数量不会持久化。请同时运行 npm run dev 和 npm run dev:server或直接运行 npm run dev:all。";
const statsIncrementFailedMessage =
@@ -59,6 +60,94 @@ function TemplateThumbnail({
);
}
function useIsMobile(breakpoint: number) {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.innerWidth < breakpoint;
});
useEffect(() => {
if (typeof window === "undefined") {
return undefined;
}
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const sync = () => setIsMobile(mediaQuery.matches);
sync();
mediaQuery.addEventListener("change", sync);
return () => mediaQuery.removeEventListener("change", sync);
}, [breakpoint]);
return isMobile;
}
function useContainerWidth<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const node = ref.current;
if (!node) {
return undefined;
}
const updateWidth = () => {
setWidth(node.clientWidth);
};
updateWidth();
const observer = new ResizeObserver(() => {
updateWidth();
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
function CertificateFormFields({
formData,
onChange,
className = "form-grid",
}: {
formData: CertificateFormData;
onChange: (key: CertificateFieldKey) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
className?: string;
}) {
return (
<div className={className}>
{certificateFieldOrder.map((key) => (
<label key={key} className="field">
<span>{certificateFieldLabels[key]}</span>
{key === "familyAddress" || key === "birthAddress" ? (
<textarea
rows={3}
value={formData[key]}
onChange={onChange(key)}
placeholder={`请输入${certificateFieldLabels[key]}`}
/>
) : (
<input
value={formData[key]}
onChange={onChange(key)}
placeholder={`请输入${certificateFieldLabels[key]}`}
/>
)}
</label>
))}
</div>
);
}
export function CertificateGenerator() {
const [selectedTemplateId, setSelectedTemplateId] = useState(
certificateTemplates[0]?.id ?? "",
@@ -68,7 +157,10 @@ export function CertificateGenerator() {
const [isExporting, setIsExporting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [statsMessage, setStatsMessage] = useState("");
const [isMobileFormOpen, setIsMobileFormOpen] = useState(false);
const exportRef = useRef<HTMLDivElement>(null);
const [previewMeasureRef, previewContainerWidth] = useContainerWidth<HTMLDivElement>();
const isMobile = useIsMobile(mobileBreakpoint);
const selectedTemplate = useMemo(
() =>
@@ -80,6 +172,20 @@ export function CertificateGenerator() {
const qrSource = useMemo(() => getQrSource(), []);
const currentCount = stats.total + 1;
const SelectedTemplateComponent = selectedTemplate.Component;
const selectedTemplateIndex = Math.max(
certificateTemplates.findIndex((template) => template.id === selectedTemplate.id),
0,
);
const previewScale = useMemo(() => {
const fallbackWidth = isMobile ? 320 : desktopPreviewWidth;
const availableWidth = previewContainerWidth || fallbackWidth;
const targetWidth = isMobile
? Math.max(availableWidth, 280)
: Math.min(availableWidth, desktopPreviewWidth);
return Math.min(targetWidth / selectedTemplate.width, 1);
}, [isMobile, previewContainerWidth, selectedTemplate.width]);
useEffect(() => {
let active = true;
@@ -100,6 +206,29 @@ export function CertificateGenerator() {
};
}, []);
useEffect(() => {
if (!isMobile) {
setIsMobileFormOpen(false);
}
}, [isMobile]);
useEffect(() => {
if (typeof document === "undefined") {
return undefined;
}
const { body } = document;
const previousOverflow = body.style.overflow;
if (isMobile && isMobileFormOpen) {
body.style.overflow = "hidden";
}
return () => {
body.style.overflow = previousOverflow;
};
}, [isMobile, isMobileFormOpen]);
const handleChange =
(key: CertificateFieldKey) =>
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@@ -154,6 +283,9 @@ export function CertificateGenerator() {
<div>
<h2></h2>
</div>
<div className="template-strip__summary">
<span>{`${certificateTemplates.length} 个模板`}</span>
</div>
</div>
<div className="template-strip">
{certificateTemplates.map((template) => {
@@ -177,7 +309,6 @@ export function CertificateGenerator() {
<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>
@@ -185,6 +316,19 @@ export function CertificateGenerator() {
);
})}
</div>
<div className="template-strip__footer">
<div className="template-strip__dots" aria-label="模板位置">
{certificateTemplates.map((template, index) => (
<button
key={template.id}
type="button"
className={`template-strip__dot ${index === selectedTemplateIndex ? "is-active" : ""}`}
onClick={() => setSelectedTemplateId(template.id)}
aria-label={`切换到模板 ${index + 1}`}
/>
))}
</div>
</div>
</section>
<section className="workspace workspace--studio">
@@ -192,27 +336,7 @@ export function CertificateGenerator() {
<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>
<CertificateFormFields formData={formData} onChange={handleChange} />
{customTemplateUploadEnabled ? (
<div className="panel-note">
@@ -231,11 +355,11 @@ export function CertificateGenerator() {
{errorMessage ? <div className="error-box">{errorMessage}</div> : null}
{statsMessage ? <div className="error-box">{statsMessage}</div> : null}
<div className="preview-frame preview-frame--studio">
<div ref={previewMeasureRef} className="preview-frame preview-frame--studio">
<TemplateScaleFrame
width={selectedTemplate.width}
height={selectedTemplate.height}
scale={previewWidth / selectedTemplate.width}
scale={previewScale}
>
<SelectedTemplateComponent
formData={formData}
@@ -250,9 +374,48 @@ export function CertificateGenerator() {
{isExporting ? "导出中..." : "下载图片"}
</button>
</div>
<div className="mobile-preview-actions">
<button type="button" className="mobile-preview-actions__primary" onClick={handleExport} disabled={isExporting}>
{isExporting ? "导出中..." : "下载图片"}
</button>
<button type="button" className="mobile-preview-actions__secondary" onClick={() => setIsMobileFormOpen(true)}>
</button>
</div>
</section>
</section>
{isMobile ? (
<div className={`mobile-form-sheet ${isMobileFormOpen ? "is-open" : ""}`} aria-hidden={!isMobileFormOpen}>
<div className="mobile-form-sheet__backdrop" onClick={() => setIsMobileFormOpen(false)} />
<div className="mobile-form-sheet__panel" role="dialog" aria-modal="true" aria-labelledby="mobile-form-title">
<div className="mobile-form-sheet__header">
<button type="button" className="mobile-form-sheet__back" onClick={() => setIsMobileFormOpen(false)}>
</button>
<h2 id="mobile-form-title"></h2>
<button type="button" className="mobile-form-sheet__close" onClick={() => setIsMobileFormOpen(false)} aria-label="关闭表单">
</button>
</div>
<div className="mobile-form-sheet__body">
<CertificateFormFields formData={formData} onChange={handleChange} className="form-grid form-grid--mobile" />
{customTemplateUploadEnabled ? (
<div className="panel-note">
<code>VITE_ENABLE_CUSTOM_TEMPLATE_UPLOAD</code>
</div>
) : null}
</div>
<div className="mobile-form-sheet__footer">
<button type="button" className="mobile-form-sheet__done" onClick={() => setIsMobileFormOpen(false)}>
</button>
</div>
</div>
</div>
) : null}
<div className="export-stage" aria-hidden="true">
<div ref={exportRef}>
<SelectedTemplateComponent