diff --git a/data/generation-stats.json b/data/generation-stats.json index ef1b7ab..2efbffa 100644 --- a/data/generation-stats.json +++ b/data/generation-stats.json @@ -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" } \ No newline at end of file diff --git a/src/index.css b/src/index.css index 7b4ff74..ab033cf 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } +} diff --git a/src/lib/certificate.ts b/src/lib/certificate.ts index 70e1673..1ef4b3e 100644 --- a/src/lib/certificate.ts +++ b/src/lib/certificate.ts @@ -61,7 +61,7 @@ export const certificateTemplates: CertificateTemplateDefinition[] = [ }, { id: "lobster-birth-tx", - name: "小龙虾出生证明腾讯云版", + name: "小龙虾出生证明", width: 1576, height: 1080, Component: LobsterBirthTxTemplate, diff --git a/src/ui/certificate-generator.tsx b/src/ui/certificate-generator.tsx index 3e2a240..d29e961 100644 --- a/src/ui/certificate-generator.tsx +++ b/src/ui/certificate-generator.tsx @@ -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() { + const ref = useRef(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) => void; + className?: string; +}) { + return ( +
+ {certificateFieldOrder.map((key) => ( +