fix:第一次提交,当前还有很多BUG

This commit is contained in:
2026-07-09 00:25:37 +08:00
commit 21a772a7a5
45 changed files with 10511 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>毕业证书生成</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@graduation/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.2",
"vite": "^7.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

+41
View File
@@ -0,0 +1,41 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva('relative w-full rounded-lg border p-4 text-sm', {
variants: {
variant: {
default: 'bg-background text-foreground',
destructive: 'border-destructive/50 text-destructive'
}
},
defaultVariants: {
variant: 'default'
}
})
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-normal', className)} {...props} />
)
)
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
+49
View File
@@ -0,0 +1,49 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: 'border bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-xl font-semibold leading-none', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
)
Input.displayName = 'Input'
export { Input }
+16
View File
@@ -0,0 +1,16 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
)
)
Label.displayName = 'Label'
export { Label }
+100
View File
@@ -0,0 +1,100 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react'
import { cn } from '@/lib/utils'
type ToastType = 'default' | 'success' | 'error'
type ToastPayload = {
title: string
description?: string
type?: ToastType
}
type ToastItem = ToastPayload & {
id: string
}
const TOAST_EVENT = 'graduation-certificate-toast'
function emitToast(payload: ToastPayload) {
window.dispatchEvent(new CustomEvent<ToastPayload>(TOAST_EVENT, { detail: payload }))
}
export const toast = Object.assign(
(title: string, options?: { description?: string }) =>
emitToast({ title, description: options?.description, type: 'default' }),
{
success: (title: string, options?: { description?: string }) =>
emitToast({ title, description: options?.description, type: 'success' }),
error: (title: string, options?: { description?: string }) =>
emitToast({ title, description: options?.description, type: 'error' })
}
)
export function Toaster() {
const [toasts, setToasts] = useState<ToastItem[]>([])
useEffect(() => {
function handleToast(event: Event) {
const payload = (event as CustomEvent<ToastPayload>).detail
const id = crypto.randomUUID()
setToasts((current) => [{ ...payload, id }, ...current].slice(0, 5))
window.setTimeout(() => {
setToasts((current) => current.filter((toastItem) => toastItem.id !== id))
}, 3600)
}
window.addEventListener(TOAST_EVENT, handleToast)
return () => window.removeEventListener(TOAST_EVENT, handleToast)
}, [])
return (
<div className="fixed right-4 top-4 z-50 flex w-[min(420px,calc(100vw-2rem))] flex-col gap-2">
{toasts.map((toastItem) => {
const Icon =
toastItem.type === 'success'
? CheckCircle2
: toastItem.type === 'error'
? AlertCircle
: Info
return (
<div
key={toastItem.id}
className={cn(
'rounded-lg border bg-card p-4 text-card-foreground shadow-lg',
toastItem.type === 'success' && 'border-primary/40',
toastItem.type === 'error' && 'border-destructive/50 text-destructive'
)}
>
<div className="flex gap-3">
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-none">{toastItem.title}</p>
{toastItem.description ? (
<p className="mt-2 text-sm text-muted-foreground">{toastItem.description}</p>
) : null}
</div>
<button
type="button"
className="rounded-sm opacity-70 transition-opacity hover:opacity-100"
onClick={() =>
setToasts((current) =>
current.filter((currentToast) => currentToast.id !== toastItem.id)
)
}
aria-label="关闭提示"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)
})}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
letter-spacing: 0;
}
button,
input,
select {
letter-spacing: 0;
}
}
+625
View File
@@ -0,0 +1,625 @@
import JSZip from 'jszip'
import {
type BatchIssue,
type CertificateTemplateConfig,
type GeneratedAsset,
type IdPhotoConfig,
type IdPhotoCrop,
type Rect,
type StudentRow,
DEFAULT_ID_PHOTO_CROP,
denormalizeRect,
idPhotoBackgroundColor,
normalizeMatchName,
resolveIdPhotoCrop,
sanitizeFileName
} from '@/lib/certificate-types'
export type ImageSource = {
fileName: string
blob?: Blob
dataUrl: string
width: number
height: number
extension: 'jpg' | 'jpeg' | 'png'
contentType: 'image/jpeg' | 'image/png'
}
type ParsedWorkbook = {
rows: StudentRow[]
error?: string
}
function decodeXml(value: string) {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
function canvasBlob(canvas: HTMLCanvasElement, type: string, quality = 0.94) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('图片导出失败'))
}
}, type, quality)
})
}
function readAsDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('图片读取失败'))
reader.readAsDataURL(blob)
})
}
async function blobFromImageSource(source: ImageSource) {
if (source.blob) {
return source.blob.type
? source.blob
: new Blob([source.blob], { type: source.contentType })
}
const response = await fetch(source.dataUrl)
if (!response.ok) {
throw new Error('照片读取失败')
}
const blob = await response.blob()
return blob.type
? blob
: new Blob([blob], { type: source.contentType })
}
function readImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('图片加载失败'))
image.src = dataUrl
})
}
function foregroundBounds(image: HTMLImageElement) {
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) {
throw new Error('浏览器不支持 Canvas')
}
context.drawImage(image, 0, 0)
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data
let minX = canvas.width
let minY = canvas.height
let maxX = 0
let maxY = 0
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
if (pixels[(y * canvas.width + x) * 4 + 3] <= 8) {
continue
}
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
}
if (minX > maxX || minY > maxY) {
return {
x: 0,
y: 0,
width: image.naturalWidth,
height: image.naturalHeight
}
}
return {
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1
}
}
async function cropImageSource(source: ImageSource, rect: Rect) {
const image = await readImage(source.dataUrl)
const sourceX = rect.x * image.naturalWidth
const sourceY = rect.y * image.naturalHeight
const sourceWidth = rect.width * image.naturalWidth
const sourceHeight = rect.height * image.naturalHeight
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(sourceWidth))
canvas.height = Math.max(1, Math.round(sourceHeight))
const context = canvas.getContext('2d')
if (!context) {
throw new Error('浏览器不支持 Canvas')
}
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(
image,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
canvas.width,
canvas.height
)
return loadImageSource(
await canvasBlob(canvas, source.contentType, 0.94),
`${sanitizeFileName(source.fileName.replace(/\.[^.]+$/, ''))}-裁剪.${source.extension}`
)
}
function imageMetaFromFileName(fileName: string) {
const extension = fileName.toLowerCase().endsWith('.png') ? 'png' : 'jpg'
return {
extension,
contentType: extension === 'png' ? 'image/png' : 'image/jpeg'
} as const
}
export async function loadImageSource(fileOrBlob: File | Blob, fileName = 'image.jpg') {
const dataUrl = await readAsDataUrl(fileOrBlob)
const image = await readImage(dataUrl)
const meta = imageMetaFromFileName(fileName)
return {
fileName,
blob: fileOrBlob,
dataUrl,
width: image.naturalWidth,
height: image.naturalHeight,
...meta
} satisfies ImageSource
}
export async function loadImageSourceFromUrl(
url: string,
fileName = 'image.jpg',
width?: number,
height?: number,
contentType?: 'image/jpeg' | 'image/png'
) {
const image =
width && height
? null
: await readImage(url)
const meta = contentType
? {
extension: contentType === 'image/png' ? 'png' : 'jpg',
contentType
} as const
: imageMetaFromFileName(fileName)
return {
fileName,
dataUrl: url,
width: width ?? image?.naturalWidth ?? 0,
height: height ?? image?.naturalHeight ?? 0,
...meta
} satisfies ImageSource
}
export async function loadImageSourceFromDataUrl(dataUrl: string, fileName = 'image.jpg') {
if (!dataUrl.startsWith('data:')) {
return loadImageSourceFromUrl(dataUrl, fileName)
}
const mimeType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'image/jpeg'
const base64 = dataUrl.split(',')[1] ?? ''
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
return loadImageSource(new Blob([bytes], { type: mimeType }), fileName)
}
async function removeBackgroundOnServer(image: Blob) {
const response = await fetch('/api/background-removal', {
method: 'POST',
headers: {
'Content-Type': image.type || 'application/octet-stream'
},
body: image
})
if (!response.ok) {
const message = await response.text()
let error = '背景移除失败'
try {
error = JSON.parse(message).error ?? error
} catch {
error = message || error
}
throw new Error(error)
}
return response.blob()
}
export async function generateIdPhoto(
source: ImageSource,
config: IdPhotoConfig,
crop: IdPhotoCrop = DEFAULT_ID_PHOTO_CROP
) {
const resolvedCrop = resolveIdPhotoCrop(crop)
const workingSource = resolvedCrop.sourceRect
? await cropImageSource(source, resolvedCrop.sourceRect)
: source
const foregroundBlob = await removeBackgroundOnServer(await blobFromImageSource(workingSource))
const foregroundSource = await loadImageSource(foregroundBlob, `${sanitizeFileName(source.fileName)}-foreground.png`)
const foregroundImage = await readImage(foregroundSource.dataUrl)
const bounds = resolvedCrop.sourceRect
? {
x: 0,
y: 0,
width: foregroundImage.naturalWidth,
height: foregroundImage.naturalHeight
}
: foregroundBounds(foregroundImage)
const canvas = document.createElement('canvas')
canvas.width = config.widthPx
canvas.height = config.heightPx
const context = canvas.getContext('2d')
if (!context) {
throw new Error('浏览器不支持 Canvas')
}
context.fillStyle = idPhotoBackgroundColor(config)
context.fillRect(0, 0, canvas.width, canvas.height)
const targetWidth = resolvedCrop.sourceRect ? canvas.width : canvas.width * 0.9
const targetHeight = resolvedCrop.sourceRect ? canvas.height : canvas.height * 1.03
const scale = Math.max(targetWidth / bounds.width, targetHeight / bounds.height) *
(resolvedCrop.sourceRect ? 1 : resolvedCrop.scale)
const width = bounds.width * scale
const height = bounds.height * scale
const x = (canvas.width - width) / 2 + (resolvedCrop.sourceRect ? 0 : canvas.width * resolvedCrop.offsetX)
const y = resolvedCrop.sourceRect
? (canvas.height - height) / 2
: canvas.height - height + canvas.height * 0.03 + canvas.height * resolvedCrop.offsetY
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(
foregroundImage,
bounds.x,
bounds.y,
bounds.width,
bounds.height,
x,
y,
width,
height
)
const blob = await canvasBlob(canvas, 'image/jpeg', 0.94)
return loadImageSource(blob, `${sanitizeFileName(source.fileName.replace(/\.[^.]+$/, ''))}-证件照.jpg`)
}
function drawCoverImage(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
rect: Rect
) {
const sourceRatio = image.naturalWidth / image.naturalHeight
const targetRatio = rect.width / rect.height
let sourceWidth = image.naturalWidth
let sourceHeight = image.naturalHeight
let sourceX = 0
let sourceY = 0
if (sourceRatio > targetRatio) {
sourceWidth = image.naturalHeight * targetRatio
sourceX = (image.naturalWidth - sourceWidth) / 2
} else {
sourceHeight = image.naturalWidth / targetRatio
sourceY = (image.naturalHeight - sourceHeight) / 2
}
context.drawImage(
image,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
rect.x,
rect.y,
rect.width,
rect.height
)
}
function drawName(
context: CanvasRenderingContext2D,
name: string,
rect: Rect,
config: CertificateTemplateConfig
) {
const style = config.textStyle
let fontSize = style.fontSize
context.fillStyle = style.color
context.textAlign = style.align
context.textBaseline = 'middle'
do {
context.font = `${style.fontWeight} ${fontSize}px ${style.fontFamily}`
if (context.measureText(name).width <= rect.width * 0.96 || fontSize <= 12) {
break
}
fontSize -= 2
} while (fontSize > 12)
const x =
style.align === 'left'
? rect.x
: style.align === 'right'
? rect.x + rect.width
: rect.x + rect.width / 2
context.fillText(name, x, rect.y + rect.height / 2)
}
export async function renderCertificateJpg(
template: ImageSource,
photo: ImageSource,
name: string,
config: CertificateTemplateConfig
) {
const canvas = document.createElement('canvas')
canvas.width = template.width
canvas.height = template.height
const context = canvas.getContext('2d')
if (!context) {
throw new Error('浏览器不支持 Canvas')
}
const templateImage = await readImage(template.dataUrl)
const photoImage = await readImage(photo.dataUrl)
const imageRect = denormalizeRect(config.imageRect, template.width, template.height)
const nameRect = denormalizeRect(config.nameRect, template.width, template.height)
context.fillStyle = '#ffffff'
context.fillRect(0, 0, canvas.width, canvas.height)
context.drawImage(templateImage, 0, 0, canvas.width, canvas.height)
drawCoverImage(context, photoImage, imageRect)
drawName(context, name, nameRect, config)
return canvasBlob(canvas, 'image/jpeg')
}
export async function generateCertificateAsset(
template: ImageSource,
photo: ImageSource,
name: string,
config: CertificateTemplateConfig
) {
const safeName = sanitizeFileName(name)
const jpg = await renderCertificateJpg(template, photo, name, config)
return {
fileName: safeName,
jpg
} satisfies GeneratedAsset
}
function parseCsv(text: string): ParsedWorkbook {
const lines = text
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (lines.length === 0) {
return { rows: [], error: '表格为空' }
}
const headers = lines[0].split(',').map((cell) => cell.trim())
const nameIndex = headers.indexOf('姓名')
if (nameIndex < 0) {
return { rows: [], error: 'Excel/CSV 必须包含“姓名”列' }
}
return {
rows: lines
.slice(1)
.map((line, index) => ({
name: line.split(',')[nameIndex]?.trim() ?? '',
rowNumber: index + 2
}))
.filter((row) => row.name)
}
}
function columnIndex(cellRef: string) {
const letters = cellRef.replace(/\d+/g, '')
return [...letters].reduce((total, letter) => total * 26 + letter.charCodeAt(0) - 64, 0) - 1
}
async function parseXlsx(file: File): Promise<ParsedWorkbook> {
const zip = await JSZip.loadAsync(await file.arrayBuffer())
const sharedStringsXml = await zip.file('xl/sharedStrings.xml')?.async('text')
const sharedStrings = sharedStringsXml
? [...sharedStringsXml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((match) =>
decodeXml([...match[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((text) => text[1]).join(''))
)
: []
const workbookRels = await zip.file('xl/_rels/workbook.xml.rels')?.async('text')
const firstSheetRel = workbookRels?.match(
/<Relationship\b(?=[^>]*Type="[^"]*\/worksheet")[^>]*Target="([^"]+)"/
)?.[1]
const sheetPath = firstSheetRel
? `xl/${firstSheetRel.replace(/^\//, '').replace(/^xl\//, '')}`
: 'xl/worksheets/sheet1.xml'
const sheetXml = await zip.file(sheetPath)?.async('text')
if (!sheetXml) {
return { rows: [], error: '未找到 Excel 第一张工作表' }
}
const rows = [...sheetXml.matchAll(/<row[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map((rowMatch) => {
const values: string[] = []
for (const cellMatch of rowMatch[2].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = cellMatch[1]
const body = cellMatch[2]
const ref = attrs.match(/\br="([^"]+)"/)?.[1] ?? ''
const type = attrs.match(/\bt="([^"]+)"/)?.[1]
const rawValue = body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? ''
const inlineValue = body.match(/<t[^>]*>([\s\S]*?)<\/t>/)?.[1]
const value = type === 's' ? sharedStrings[Number(rawValue)] : inlineValue ?? rawValue
values[columnIndex(ref)] = decodeXml(String(value ?? '')).trim()
}
return {
rowNumber: Number(rowMatch[1]),
values
}
})
const headerRow = rows[0]
const nameIndex = headerRow?.values.indexOf('姓名') ?? -1
if (nameIndex < 0) {
return { rows: [], error: 'Excel/CSV 必须包含“姓名”列' }
}
return {
rows: rows
.slice(1)
.map((row) => ({
name: row.values[nameIndex] ?? '',
rowNumber: row.rowNumber
}))
.filter((row) => row.name)
}
}
export async function parseStudentFile(file: File) {
if (/\.csv$/i.test(file.name)) {
return parseCsv(await file.text())
}
return parseXlsx(file)
}
export async function collectPhotos(files: File[], zipFiles: File[]) {
const photos = new Map<string, ImageSource>()
const issues: BatchIssue[] = []
async function addPhoto(fileName: string, blob: Blob) {
if (!/\.(jpe?g|png)$/i.test(fileName)) {
return
}
const key = normalizeMatchName(fileName)
if (photos.has(key)) {
issues.push({
name: fileName,
status: 'skipped',
message: '照片文件名重复,已保留第一张'
})
return
}
photos.set(key, await loadImageSource(blob, fileName))
}
for (const file of files) {
await addPhoto(file.name, file)
}
for (const zipFile of zipFiles) {
const zip = await JSZip.loadAsync(await zipFile.arrayBuffer())
for (const [zipPath, entry] of Object.entries(zip.files)) {
if (entry.dir) {
continue
}
await addPhoto(zipPath.split(/[\\/]/).pop() ?? zipPath, await entry.async('blob'))
}
}
return { photos, issues }
}
export async function buildBatchZip(
template: ImageSource,
students: StudentRow[],
photos: Map<string, ImageSource>,
config: CertificateTemplateConfig,
initialIssues: BatchIssue[]
) {
const zip = new JSZip()
const issues: BatchIssue[] = [...initialIssues]
const usedNames = new Map<string, number>()
for (const student of students) {
const key = normalizeMatchName(student.name)
const photo = photos.get(key)
if (!photo) {
issues.push({
name: student.name,
status: 'skipped',
message: `${student.rowNumber} 行缺少匹配照片`
})
continue
}
const asset = await generateCertificateAsset(template, photo, student.name, config)
const usedCount = usedNames.get(asset.fileName) ?? 0
usedNames.set(asset.fileName, usedCount + 1)
const suffix = usedCount > 0 ? `-${usedCount + 1}` : ''
const outputName = `${asset.fileName}${suffix}`
zip.file(`jpg/${outputName}.jpg`, asset.jpg)
issues.push({
name: student.name,
status: 'success',
message: `已生成 ${outputName}`
})
}
const report = ['姓名,状态,说明']
.concat(issues.map((issue) => `${issue.name},${issue.status},${issue.message}`))
.join('\n')
zip.file('生成结果.csv', `\uFEFF${report}`)
return zip.generateAsync({
type: 'blob',
compression: 'DEFLATE'
})
}
+465
View File
@@ -0,0 +1,465 @@
'use client'
import JSZip from 'jszip'
import {
collectPhotos,
generateCertificateAsset,
generateIdPhoto,
loadImageSource,
loadImageSourceFromDataUrl,
loadImageSourceFromUrl,
parseStudentFile,
type ImageSource
} from '@/lib/certificate-browser'
import {
DEFAULT_TEMPLATE_CONFIG,
DEFAULT_ID_PHOTO_CONFIG,
DEFAULT_ID_PHOTO_CROP,
type CertificateTemplateConfig,
type GeneratedAsset,
type IdPhotoConfig,
type IdPhotoCrop,
type StudentRow,
normalizeMatchName,
resolveIdPhotoCrop,
resolveIdPhotoConfig,
sanitizeFileName
} from '@/lib/certificate-types'
import type {
PersistedAppState,
PersistedClassItem,
PersistedGeneratedCertificate,
PersistedImageSource,
PersistedTemplateItem
} from '@/lib/storage-types'
export type ActiveTool = 'image' | 'name'
export type ClassStudent = {
id: string
name: string
photo?: ImageSource
photoFileName?: string
idPhoto?: ImageSource
idPhotoFileName?: string
idPhotoCrop?: IdPhotoCrop
}
export type ClassItem = {
id: string
name: string
students: ClassStudent[]
idPhotoConfig: IdPhotoConfig
createdAt: string
}
export type TemplateItem = {
id: string
name: string
image: ImageSource
config: CertificateTemplateConfig
updatedAt: string
}
export type GeneratedCertificate = {
id: string
classId: string
className: string
studentId: string
studentName: string
photo: ImageSource
template: TemplateItem
asset: GeneratedAsset
jpgUrl: string
updatedAt: string
}
const DEFAULT_TEMPLATE_URL = '/default-certificate.jpg'
export function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`
}
export function fileList(input: HTMLInputElement) {
return Array.from(input.files ?? [])
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
link.click()
URL.revokeObjectURL(url)
}
export function downloadText(text: string, fileName: string) {
downloadBlob(new Blob([text], { type: 'application/json;charset=utf-8' }), fileName)
}
function readBlobAsDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsDataURL(blob)
})
}
function blobFromDataUrl(dataUrl: string) {
const mimeType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream'
const base64 = dataUrl.split(',')[1] ?? ''
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
return new Blob([bytes], { type: mimeType })
}
export async function blobFromSource(source: string) {
if (source.startsWith('data:')) {
return blobFromDataUrl(source)
}
const response = await fetch(source)
if (!response.ok) {
throw new Error('证书图片加载失败')
}
return response.blob()
}
export function persistImageSource(source: ImageSource): PersistedImageSource {
const { blob: _blob, ...persistedSource } = source
return persistedSource
}
export async function hydrateImageSource(source?: PersistedImageSource | ImageSource) {
if (!source?.dataUrl) {
return undefined
}
if (!source.dataUrl.startsWith('data:')) {
return loadImageSourceFromUrl(
source.dataUrl,
source.fileName,
source.width,
source.height,
source.contentType
)
}
return loadImageSourceFromDataUrl(source.dataUrl, source.fileName)
}
export async function loadDefaultTemplateImage() {
const response = await fetch(DEFAULT_TEMPLATE_URL)
if (!response.ok) {
throw new Error('默认模板底图加载失败')
}
return loadImageSource(await response.blob(), 'default-certificate.jpg')
}
function isLegacyDefaultCmykTemplate(template: TemplateItem | PersistedTemplateItem) {
return (
template.name === '亲爱版毕业证书' &&
template.image.width === DEFAULT_TEMPLATE_CONFIG.templateWidth &&
template.image.height === DEFAULT_TEMPLATE_CONFIG.templateHeight &&
template.image.dataUrl.startsWith('data:image/jpeg') &&
template.image.dataUrl.includes('Q01ZS0xhYi')
)
}
export async function hydrateTemplate(
template: TemplateItem | PersistedTemplateItem
): Promise<TemplateItem> {
const image = isLegacyDefaultCmykTemplate(template)
? await loadDefaultTemplateImage()
: await hydrateImageSource(template.image)
if (!image) {
throw new Error('模板底图加载失败')
}
return {
...template,
image,
config: {
...template.config,
templateWidth: image.width,
templateHeight: image.height
}
}
}
export function hydrateClassMetadata(classItem: PersistedClassItem): ClassItem {
return {
...classItem,
idPhotoConfig: resolveIdPhotoConfig(classItem.idPhotoConfig),
students: classItem.students.map((student) => ({
...student,
photo: undefined,
idPhoto: undefined
}))
}
}
export async function hydrateClassItem(classItem: PersistedClassItem): Promise<ClassItem> {
return {
...classItem,
idPhotoConfig: resolveIdPhotoConfig(classItem.idPhotoConfig),
students: await Promise.all(
classItem.students.map(async (student) => ({
...student,
photo: await hydrateImageSource(student.photo),
idPhoto: await hydrateImageSource(student.idPhoto)
}))
)
}
}
export async function hydrateCertificate(
certificate: PersistedGeneratedCertificate
): Promise<GeneratedCertificate> {
const [photo, template] = await Promise.all([
hydrateImageSource(certificate.photo),
hydrateTemplate(certificate.template)
])
if (!photo) {
throw new Error('证书照片加载失败')
}
if (!certificate.jpgUrl) {
throw new Error('证书图片加载失败')
}
return {
id: certificate.id,
classId: certificate.classId,
className: certificate.className,
studentId: certificate.studentId,
studentName: certificate.studentName,
photo,
template,
asset: {
fileName: certificate.asset.fileName || sanitizeFileName(certificate.studentName)
},
jpgUrl: certificate.jpgUrl,
updatedAt: certificate.updatedAt
}
}
export async function loadAppState() {
const response = await fetch('/api/storage')
if (!response.ok) {
throw new Error('SQLite 数据读取失败')
}
return (await response.json()) as PersistedAppState
}
export async function saveAppState(state: PersistedAppState) {
const response = await fetch('/api/storage', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(state)
})
if (!response.ok) {
throw new Error('SQLite 保存失败')
}
}
export async function persistGeneratedCertificate(
certificate: GeneratedCertificate
): Promise<PersistedGeneratedCertificate> {
return {
...certificate,
photo: persistImageSource(certificate.photo),
template: {
...certificate.template,
image: persistImageSource(certificate.template.image)
},
asset: {
fileName: certificate.asset.fileName
},
jpgUrl: certificate.asset.jpg
? await readBlobAsDataUrl(certificate.asset.jpg)
: certificate.jpgUrl
}
}
export function persistClasses(
classes: ClassItem[],
fallbackState?: PersistedAppState
): PersistedClassItem[] {
const fallbackStudentsById = new Map(
(fallbackState?.classes ?? []).flatMap((classItem) =>
classItem.students.map((student) => [student.id, student])
)
)
return classes.map((classItem) => ({
...classItem,
idPhotoConfig: resolveIdPhotoConfig(classItem.idPhotoConfig),
students: classItem.students.map((student) => ({
...student,
photo: student.photo
? persistImageSource(student.photo)
: fallbackStudentsById.get(student.id)?.photo,
photoFileName: student.photoFileName ?? fallbackStudentsById.get(student.id)?.photoFileName,
idPhoto: student.idPhoto
? persistImageSource(student.idPhoto)
: fallbackStudentsById.get(student.id)?.idPhoto,
idPhotoFileName: student.idPhotoFileName ?? fallbackStudentsById.get(student.id)?.idPhotoFileName,
idPhotoCrop: student.idPhotoCrop
? resolveIdPhotoCrop(student.idPhotoCrop)
: fallbackStudentsById.get(student.id)?.idPhotoCrop
}))
}))
}
export async function parseRosterFromClassZip(zipFile: File): Promise<StudentRow[]> {
const zip = await JSZip.loadAsync(await zipFile.arrayBuffer())
const rosterEntry = Object.values(zip.files).find(
(entry) => !entry.dir && /\.(xlsx|csv)$/i.test(entry.name)
)
if (rosterEntry) {
const rosterFile = new File([await rosterEntry.async('blob')], rosterEntry.name)
const parsed = await parseStudentFile(rosterFile)
if (parsed.error) {
throw new Error(parsed.error)
}
return parsed.rows
}
const imageNames = Object.values(zip.files)
.filter((entry) => !entry.dir && /\.(jpe?g|png)$/i.test(entry.name))
.map((entry, index) => ({
name: sanitizeFileName(entry.name.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') ?? ''),
rowNumber: index + 1
}))
.filter((row) => row.name)
if (imageNames.length === 0) {
throw new Error('ZIP 内需要包含 Excel/CSV 名单或按姓名命名的照片')
}
return imageNames
}
export async function studentsFromClassZip(file: File) {
const rows = await parseRosterFromClassZip(file)
const { photos, issues } = await collectPhotos([], [file])
const students: ClassStudent[] = rows.map((row) => {
const photo = photos.get(normalizeMatchName(row.name))
return {
id: createId('student'),
name: row.name,
photo,
photoFileName: photo?.fileName
}
})
return { students, issues }
}
export async function ensureStudentIdPhoto(student: ClassStudent, config: IdPhotoConfig, force = false) {
if (!student.photo) {
return student
}
if (
!force &&
student.idPhoto &&
student.idPhoto.width === config.widthPx &&
student.idPhoto.height === config.heightPx
) {
return student
}
try {
const crop = resolveIdPhotoCrop(student.idPhotoCrop ?? DEFAULT_ID_PHOTO_CROP)
const idPhoto = await generateIdPhoto(student.photo, resolveIdPhotoConfig(config), crop)
return {
...student,
idPhoto,
idPhotoFileName: idPhoto.fileName,
idPhotoCrop: crop
}
} catch (error) {
console.warn(`证件照生成失败:${student.name}`, error)
return student
}
}
export async function ensureClassIdPhotos(classItem: ClassItem, force = false) {
const idPhotoConfig = resolveIdPhotoConfig(classItem.idPhotoConfig ?? DEFAULT_ID_PHOTO_CONFIG)
return {
...classItem,
idPhotoConfig,
students: await Promise.all(
classItem.students.map((student) => ensureStudentIdPhoto(student, idPhotoConfig, force))
)
}
}
export async function generateForStudent(
classItem: ClassItem,
student: ClassStudent,
template: TemplateItem
) {
const certificatePhoto = student.idPhoto
if (!certificatePhoto) {
return null
}
const asset = await generateCertificateAsset(
template.image,
certificatePhoto,
student.name,
template.config
)
return {
id: createId('certificate'),
classId: classItem.id,
className: classItem.name,
studentId: student.id,
studentName: student.name,
photo: student.photo ?? certificatePhoto,
template,
asset,
jpgUrl: URL.createObjectURL(asset.jpg),
updatedAt: new Date().toISOString()
} satisfies GeneratedCertificate
}
export function createResultReport(certificates: GeneratedCertificate[], classItem: ClassItem) {
const generatedIds = new Set(certificates.map((certificate) => certificate.studentId))
const lines = ['姓名,状态,说明']
for (const student of classItem.students) {
lines.push(
generatedIds.has(student.id)
? `${student.name},success,已生成`
: `${student.name},skipped,缺少照片或生成失败`
)
}
return `\uFEFF${lines.join('\n')}`
}
+221
View File
@@ -0,0 +1,221 @@
export type Rect = {
x: number
y: number
width: number
height: number
}
export type TextStyle = {
fontFamily: string
fontSize: number
color: string
fontWeight: '400' | '600' | '700'
align: 'left' | 'center' | 'right'
}
export type CertificateTemplateConfig = {
version: 1
templateWidth: number
templateHeight: number
imageRect: Rect
nameRect: Rect
textStyle: TextStyle
imageFit: 'cover'
}
export type StudentRow = {
name: string
rowNumber: number
}
export type GeneratedAsset = {
fileName: string
jpg?: Blob
}
export type IdPhotoBackground = 'blue' | 'white' | 'red' | 'custom'
export type IdPhotoSize = 'one-inch' | 'two-inch'
export type IdPhotoConfig = {
background: IdPhotoBackground
customColor?: string
size: IdPhotoSize
widthPx: number
heightPx: number
}
export type IdPhotoCrop = {
scale: number
offsetX: number
offsetY: number
sourceRect?: Rect
}
export type BatchIssue = {
name: string
status: 'success' | 'skipped'
message: string
}
export const DEFAULT_TEMPLATE_CONFIG: CertificateTemplateConfig = {
version: 1,
templateWidth: 2647,
templateHeight: 3675,
imageRect: {
x: 1135 / 2647,
y: 1371 / 3675,
width: 386 / 2647,
height: 576 / 3675
},
nameRect: {
x: 986 / 2647,
y: 2068 / 3675,
width: 707 / 2647,
height: 88 / 3675
},
textStyle: {
fontFamily: 'serif',
fontSize: 72,
color: '#1f1b1c',
fontWeight: '700',
align: 'center'
},
imageFit: 'cover'
}
export const ID_PHOTO_BACKGROUND_COLORS: Record<Exclude<IdPhotoBackground, 'custom'>, string> = {
blue: '#438EDB',
white: '#FFFFFF',
red: '#D91F2D'
}
export const ID_PHOTO_SIZE_PRESETS: Record<IdPhotoSize, Pick<IdPhotoConfig, 'widthPx' | 'heightPx'>> = {
'one-inch': {
widthPx: 295,
heightPx: 413
},
'two-inch': {
widthPx: 413,
heightPx: 579
}
}
export const DEFAULT_ID_PHOTO_CONFIG: IdPhotoConfig = {
background: 'blue',
size: 'one-inch',
...ID_PHOTO_SIZE_PRESETS['one-inch']
}
export const DEFAULT_ID_PHOTO_CROP: IdPhotoCrop = {
scale: 1,
offsetX: 0,
offsetY: 0
}
export function resolveIdPhotoCrop(crop?: Partial<IdPhotoCrop>): IdPhotoCrop {
const sourceRect =
crop?.sourceRect &&
crop.sourceRect.width > 0 &&
crop.sourceRect.height > 0
? {
x: Math.min(Math.max(Number(crop.sourceRect.x), 0), 1),
y: Math.min(Math.max(Number(crop.sourceRect.y), 0), 1),
width: Math.min(Math.max(Number(crop.sourceRect.width), 0.01), 1),
height: Math.min(Math.max(Number(crop.sourceRect.height), 0.01), 1)
}
: undefined
return {
scale: Math.min(Math.max(Number(crop?.scale ?? DEFAULT_ID_PHOTO_CROP.scale), 0.75), 1.35),
offsetX: Math.min(Math.max(Number(crop?.offsetX ?? DEFAULT_ID_PHOTO_CROP.offsetX), -0.35), 0.35),
offsetY: Math.min(Math.max(Number(crop?.offsetY ?? DEFAULT_ID_PHOTO_CROP.offsetY), -0.35), 0.35),
sourceRect: sourceRect
? {
...sourceRect,
width: Math.min(sourceRect.width, 1 - sourceRect.x),
height: Math.min(sourceRect.height, 1 - sourceRect.y)
}
: undefined
}
}
export function resolveIdPhotoConfig(config?: Partial<IdPhotoConfig>): IdPhotoConfig {
const size = config?.size === 'two-inch' ? 'two-inch' : 'one-inch'
const background: IdPhotoBackground = ['blue', 'white', 'red', 'custom'].includes(
String(config?.background)
)
? (config?.background as IdPhotoBackground)
: DEFAULT_ID_PHOTO_CONFIG.background
const customColor = /^#[\da-f]{6}$/i.test(config?.customColor ?? '')
? config?.customColor
: DEFAULT_ID_PHOTO_CONFIG.customColor
return {
background,
customColor,
size,
...ID_PHOTO_SIZE_PRESETS[size]
}
}
export function idPhotoBackgroundColor(config: IdPhotoConfig) {
return config.background === 'custom'
? config.customColor || ID_PHOTO_BACKGROUND_COLORS.blue
: ID_PHOTO_BACKGROUND_COLORS[config.background]
}
export function idPhotoSizeLabel(size: IdPhotoSize) {
return size === 'two-inch' ? '二寸' : '一寸'
}
export function idPhotoBackgroundLabel(background: IdPhotoBackground) {
if (background === 'white') {
return '白底'
}
if (background === 'red') {
return '红底'
}
if (background === 'custom') {
return '自定义底'
}
return '蓝底'
}
export function denormalizeRect(rect: Rect, width: number, height: number): Rect {
return {
x: rect.x * width,
y: rect.y * height,
width: rect.width * width,
height: rect.height * height
}
}
export function normalizeRect(rect: Rect, width: number, height: number): Rect {
return {
x: rect.x / width,
y: rect.y / height,
width: rect.width / width,
height: rect.height / height
}
}
export function sanitizeFileName(value: string) {
return (
[...value]
.filter((character) => !'<>:"/\\|?*'.includes(character) && character.charCodeAt(0) >= 32)
.join('')
.trim() || '毕业证书'
)
}
export function normalizeMatchName(value: string) {
return value
.replace(/\.[^.]+$/, '')
.replace(/\s+/g, '')
.trim()
.toLowerCase()
}
+55
View File
@@ -0,0 +1,55 @@
import type { CertificateTemplateConfig, IdPhotoConfig, IdPhotoCrop } from '@/lib/certificate-types'
import type { ImageSource } from '@/lib/certificate-browser'
export type PersistedImageSource = Omit<ImageSource, 'blob'>
export type PersistedGeneratedAsset = {
fileName: string
}
export type PersistedClassStudent = {
id: string
name: string
photo?: PersistedImageSource
photoFileName?: string
idPhoto?: PersistedImageSource
idPhotoFileName?: string
idPhotoCrop?: IdPhotoCrop
}
export type PersistedClassItem = {
id: string
name: string
students: PersistedClassStudent[]
idPhotoConfig?: IdPhotoConfig
createdAt: string
}
export type PersistedTemplateItem = {
id: string
name: string
image: PersistedImageSource
config: CertificateTemplateConfig
updatedAt: string
}
export type PersistedGeneratedCertificate = {
id: string
classId: string
className: string
studentId: string
studentName: string
photo: PersistedImageSource
template: PersistedTemplateItem
asset: PersistedGeneratedAsset
jpgUrl?: string
updatedAt: string
}
export type PersistedAppState = {
templates: PersistedTemplateItem[]
activeTemplateId: string
classes: PersistedClassItem[]
activeClassId: string
certificates: PersistedGeneratedCertificate[]
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Archive, GraduationCap, LayoutTemplate, School, Users } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/sonner'
import ClassesPage from '@/pages/classes-page'
import TemplatesPage from '@/pages/templates-page'
import GeneratedPage from '@/pages/generated-page'
import StudentsPage from '@/pages/students-page'
import '@/globals.css'
const NAV_ITEMS = [
{ href: '/classes', label: '班级管理', Icon: School },
{ href: '/students', label: '幼儿管理', Icon: Users },
{ href: '/templates', label: '证书模板', Icon: LayoutTemplate },
{ href: '/generated', label: '生成完成', Icon: Archive }
]
function useCurrentPath() {
const [path, setPath] = useState(window.location.pathname)
useEffect(() => {
const handleNavigation = () => setPath(window.location.pathname)
window.addEventListener('popstate', handleNavigation)
return () => window.removeEventListener('popstate', handleNavigation)
}, [])
return path
}
function navigate(href: string) {
window.history.pushState(null, '', href)
window.dispatchEvent(new PopStateEvent('popstate'))
}
function CurrentPage({ path }: { path: string }) {
if (path.startsWith('/templates')) {
return <TemplatesPage />
}
if (path.startsWith('/generated')) {
return <GeneratedPage />
}
if (path.startsWith('/students')) {
return <StudentsPage />
}
return <ClassesPage />
}
function App() {
const path = useCurrentPath()
return (
<>
<main className="min-h-screen bg-background">
<div className="grid min-h-screen lg:grid-cols-[260px_1fr]">
<aside className="border-r bg-card p-4">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<GraduationCap className="h-5 w-5" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<h1 className="font-semibold"></h1>
</div>
</div>
<nav className="space-y-2">
{NAV_ITEMS.map(({ href, label, Icon }) => (
<Button
key={href}
type="button"
variant={path.startsWith(href) ? 'secondary' : 'ghost'}
className="w-full justify-start"
onClick={() => navigate(href)}
>
<Icon />
{label}
</Button>
))}
</nav>
</aside>
<section className="min-w-0 p-5">
<CurrentPage path={path} />
</section>
</div>
</main>
<Toaster />
</>
)
}
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+754
View File
@@ -0,0 +1,754 @@
'use client'
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'
import {
BadgeCheck,
Loader2,
MoreHorizontal,
Palette,
Pencil,
Plus,
School,
Search,
Trash2,
Upload,
UserRoundCheck
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { collectPhotos } from '@/lib/certificate-browser'
import {
DEFAULT_ID_PHOTO_CONFIG,
ID_PHOTO_BACKGROUND_COLORS,
ID_PHOTO_SIZE_PRESETS,
type IdPhotoBackground,
type IdPhotoSize,
idPhotoBackgroundColor,
idPhotoBackgroundLabel,
idPhotoSizeLabel,
normalizeMatchName,
resolveIdPhotoConfig
} from '@/lib/certificate-types'
import {
type ClassItem,
type TemplateItem,
createId,
ensureClassIdPhotos,
fileList,
generateForStudent,
hydrateClassItem,
hydrateClassMetadata,
hydrateTemplate,
loadAppState,
persistClasses,
persistGeneratedCertificate,
saveAppState,
studentsFromClassZip
} from '@/lib/certificate-page-utils'
import type { PersistedAppState } from '@/lib/storage-types'
import { cn } from '@/lib/utils'
function navigate(href: string) {
window.history.pushState(null, '', href)
window.dispatchEvent(new PopStateEvent('popstate'))
}
export default function ClassesPage() {
const classZipInputRef = useRef<HTMLInputElement | null>(null)
const studentPhotoInputRef = useRef<HTMLInputElement | null>(null)
const [storedState, setStoredState] = useState<PersistedAppState | null>(null)
const [classes, setClasses] = useState<ClassItem[]>([])
const [templates, setTemplates] = useState<TemplateItem[]>([])
const [activeClassId, setActiveClassId] = useState('')
const [classSearchQuery, setClassSearchQuery] = useState('')
const [isClassEditorOpen, setIsClassEditorOpen] = useState(false)
const [editingClassId, setEditingClassId] = useState('')
const [draftName, setDraftName] = useState('')
const [draftZipFile, setDraftZipFile] = useState<File | null>(null)
const [draftIdPhotoConfig, setDraftIdPhotoConfig] = useState(DEFAULT_ID_PHOTO_CONFIG)
const [openClassMenuId, setOpenClassMenuId] = useState('')
const [pendingClassZipImportId, setPendingClassZipImportId] = useState('')
const [pendingStudentPhotoImportId, setPendingStudentPhotoImportId] = useState('')
const [isWorking, setIsWorking] = useState(false)
const [generatedCountsByClassId, setGeneratedCountsByClassId] = useState<Record<string, number>>({})
const loadedClassPhotoIdsRef = useRef(new Set<string>())
const activeClass = classes.find((classItem) => classItem.id === activeClassId) ?? classes[0]
const activeTemplate = templates.find((template) => template.id === storedState?.activeTemplateId) ?? templates[0]
const filteredClasses = useMemo(() => {
const query = classSearchQuery.trim().toLowerCase()
if (!query) {
return classes
}
return classes.filter((classItem) => {
const studentNames = classItem.students.map((student) => student.name).join(' ')
return [classItem.name, classItem.id, studentNames].some((value) =>
value.toLowerCase().includes(query)
)
})
}, [classSearchQuery, classes])
useEffect(() => {
async function loadClasses() {
const state = await loadAppState()
const restoredTemplates = await Promise.all((state.templates ?? []).map(hydrateTemplate))
const restoredClasses = (state.classes ?? []).map(hydrateClassMetadata)
const counts = (state.certificates ?? []).reduce<Record<string, number>>((result, certificate) => {
result[certificate.classId] = (result[certificate.classId] ?? 0) + 1
return result
}, {})
setStoredState(state)
setTemplates(restoredTemplates)
setClasses(restoredClasses)
setActiveClassId(
restoredClasses.some((classItem) => classItem.id === state.activeClassId)
? state.activeClassId
: restoredClasses[0]?.id ?? ''
)
setGeneratedCountsByClassId(counts)
}
loadClasses()
}, [])
async function persist(nextClasses: ClassItem[], nextActiveClassId = activeClassId, nextState = storedState) {
if (!nextState) {
return
}
const nextPersistedState: PersistedAppState = {
...nextState,
classes: persistClasses(nextClasses, nextState),
activeClassId: nextActiveClassId
}
await saveAppState(nextPersistedState)
setStoredState(nextPersistedState)
}
async function ensureClassPhotosLoaded(classItem: ClassItem) {
if (loadedClassPhotoIdsRef.current.has(classItem.id)) {
return classItem
}
const persistedClass = storedState?.classes.find((item) => item.id === classItem.id)
if (!persistedClass) {
loadedClassPhotoIdsRef.current.add(classItem.id)
return classItem
}
const hydratedClass = await hydrateClassItem(persistedClass)
loadedClassPhotoIdsRef.current.add(classItem.id)
setClasses((current) => current.map((item) => (item.id === hydratedClass.id ? hydratedClass : item)))
return hydratedClass
}
function resetEditor() {
setEditingClassId('')
setDraftName('')
setDraftZipFile(null)
setDraftIdPhotoConfig(DEFAULT_ID_PHOTO_CONFIG)
setIsClassEditorOpen(false)
}
function openNewClassEditor() {
setEditingClassId('')
setDraftName('')
setDraftZipFile(null)
setDraftIdPhotoConfig(DEFAULT_ID_PHOTO_CONFIG)
setIsClassEditorOpen(true)
setOpenClassMenuId('')
}
function openClassEditor(classItem: ClassItem) {
setEditingClassId(classItem.id)
setDraftName(classItem.name)
setDraftZipFile(null)
setDraftIdPhotoConfig(resolveIdPhotoConfig(classItem.idPhotoConfig))
setActiveClassId(classItem.id)
setIsClassEditorOpen(true)
setOpenClassMenuId('')
}
function updateDraftSize(size: IdPhotoSize) {
setDraftIdPhotoConfig((current) =>
resolveIdPhotoConfig({
...current,
size,
...ID_PHOTO_SIZE_PRESETS[size]
})
)
}
async function saveClass() {
const className = draftName.trim()
if (!className) {
return
}
setIsWorking(true)
try {
const zipResult = draftZipFile ? await studentsFromClassZip(draftZipFile) : null
const idPhotoConfig = resolveIdPhotoConfig(draftIdPhotoConfig)
let nextActiveClassId = activeClassId
let nextClasses: ClassItem[]
if (editingClassId) {
const editingClass = classes.find((classItem) => classItem.id === editingClassId)
const hydratedClass = editingClass ? await ensureClassPhotosLoaded(editingClass) : null
const configChanged =
resolveIdPhotoConfig(hydratedClass?.idPhotoConfig).background !== idPhotoConfig.background ||
resolveIdPhotoConfig(hydratedClass?.idPhotoConfig).customColor !== idPhotoConfig.customColor ||
resolveIdPhotoConfig(hydratedClass?.idPhotoConfig).size !== idPhotoConfig.size
const updatedClass = hydratedClass
? {
...hydratedClass,
name: className,
idPhotoConfig,
students: zipResult?.students ?? hydratedClass.students.map((student) => ({
...student,
idPhoto: configChanged ? undefined : student.idPhoto,
idPhotoFileName: configChanged ? undefined : student.idPhotoFileName
}))
}
: null
nextClasses = classes.map((classItem) =>
updatedClass && classItem.id === editingClassId ? updatedClass : classItem
)
nextActiveClassId = editingClassId
loadedClassPhotoIdsRef.current.add(editingClassId)
} else {
const nextClass = {
id: createId('class'),
name: className,
idPhotoConfig,
students: zipResult?.students ?? [],
createdAt: new Date().toISOString()
}
nextClasses = [...classes, nextClass]
nextActiveClassId = nextClass.id
loadedClassPhotoIdsRef.current.add(nextClass.id)
}
setClasses(nextClasses)
setActiveClassId(nextActiveClassId)
await persist(nextClasses, nextActiveClassId)
resetEditor()
} finally {
setIsWorking(false)
}
}
function openClassZipPicker(classId: string) {
setPendingClassZipImportId(classId)
setActiveClassId(classId)
setOpenClassMenuId('')
classZipInputRef.current?.click()
}
function openStudentPhotoPicker(classId: string) {
setPendingStudentPhotoImportId(classId)
setActiveClassId(classId)
setOpenClassMenuId('')
studentPhotoInputRef.current?.click()
}
async function importClassZip(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
const targetClassId = pendingClassZipImportId || activeClass?.id
const targetClass = classes.find((classItem) => classItem.id === targetClassId)
event.target.value = ''
if (!file || !targetClass) {
return
}
setIsWorking(true)
try {
const { students } = await studentsFromClassZip(file)
const nextClass = {
...targetClass,
students
}
const nextClasses = classes.map((classItem) =>
classItem.id === targetClass.id ? nextClass : classItem
)
loadedClassPhotoIdsRef.current.add(targetClass.id)
setClasses(nextClasses)
await persist(nextClasses, targetClass.id)
} finally {
setPendingClassZipImportId('')
setIsWorking(false)
}
}
async function importStudentPhotos(event: ChangeEvent<HTMLInputElement>) {
const targetClassId = pendingStudentPhotoImportId || activeClass?.id
const targetClass = classes.find((classItem) => classItem.id === targetClassId)
const files = fileList(event.currentTarget)
event.target.value = ''
if (!targetClass || files.length === 0) {
return
}
setIsWorking(true)
try {
const classWithPhotos = await ensureClassPhotosLoaded(targetClass)
const imageFiles = files.filter((file) => !/\.zip$/i.test(file.name))
const zipFiles = files.filter((file) => /\.zip$/i.test(file.name))
const { photos } = await collectPhotos(imageFiles, zipFiles)
const nextClass = {
...classWithPhotos,
students: classWithPhotos.students.map((student) => {
const matchedPhoto = photos.get(normalizeMatchName(student.name))
return matchedPhoto
? {
...student,
photo: matchedPhoto,
photoFileName: matchedPhoto.fileName,
idPhoto: undefined,
idPhotoFileName: undefined
}
: student
})
}
const nextClasses = classes.map((classItem) =>
classItem.id === targetClass.id ? nextClass : classItem
)
loadedClassPhotoIdsRef.current.add(targetClass.id)
setClasses(nextClasses)
await persist(nextClasses, targetClass.id)
} finally {
setPendingStudentPhotoImportId('')
setIsWorking(false)
}
}
async function generateClassCertificates(classItem: ClassItem) {
if (!activeTemplate || classItem.students.length === 0 || !storedState) {
return
}
setIsWorking(true)
try {
const classWithIdPhotos = await ensureClassPhotosLoaded(classItem)
const generated = (
await Promise.all(
classWithIdPhotos.students.map((student) =>
generateForStudent(classWithIdPhotos, student, activeTemplate)
)
)
).filter((certificate): certificate is NonNullable<typeof certificate> => Boolean(certificate))
const persistedGenerated = await Promise.all(generated.map(persistGeneratedCertificate))
const nextClasses = classes.map((item) => (item.id === classWithIdPhotos.id ? classWithIdPhotos : item))
const nextState: PersistedAppState = {
...storedState,
classes: persistClasses(nextClasses, storedState),
activeClassId: classWithIdPhotos.id,
certificates: [
...(storedState.certificates ?? []).filter((certificate) => certificate.classId !== classWithIdPhotos.id),
...persistedGenerated
]
}
await saveAppState(nextState)
setClasses(nextClasses)
setStoredState(nextState)
setGeneratedCountsByClassId((current) => ({
...current,
[classWithIdPhotos.id]: generated.length
}))
navigate(`/generated?classId=${classWithIdPhotos.id}`)
} finally {
setIsWorking(false)
}
}
async function generateClassIdPhotos(classItem: ClassItem) {
if (classItem.students.length === 0 || !storedState) {
return
}
setIsWorking(true)
try {
const classWithIdPhotos = await ensureClassIdPhotos(await ensureClassPhotosLoaded(classItem), true)
const nextClasses = classes.map((item) => (item.id === classWithIdPhotos.id ? classWithIdPhotos : item))
setClasses(nextClasses)
await persist(nextClasses, classWithIdPhotos.id)
} finally {
setIsWorking(false)
}
}
async function removeClass(classItem: ClassItem) {
const nextClasses = classes.filter((item) => item.id !== classItem.id)
const nextActiveClassId = nextClasses[0]?.id ?? ''
const nextState = storedState
? {
...storedState,
classes: persistClasses(nextClasses, storedState),
activeClassId: nextActiveClassId,
certificates: (storedState.certificates ?? []).filter(
(certificate) => certificate.classId !== classItem.id
)
}
: null
setClasses(nextClasses)
setActiveClassId(nextActiveClassId)
setGeneratedCountsByClassId((current) => {
const next = { ...current }
delete next[classItem.id]
return next
})
if (nextState) {
await saveAppState(nextState)
setStoredState(nextState)
}
}
return (
<div className="space-y-5">
<div className="flex items-start gap-3">
<div className="mt-1 rounded-md bg-primary/10 p-2 text-primary">
<School className="h-5 w-5" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-normal"></h2>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
<Card className="border-dashed">
<CardContent className="space-y-4 p-4">
<div className="flex flex-col gap-3 lg:flex-row">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={classSearchQuery}
onChange={(event) => setClassSearchQuery(event.target.value)}
placeholder="搜索班级名称、学生或 UUID"
className="h-12 rounded-xl pl-11"
/>
</div>
<Button type="button" className="h-12 rounded-xl px-5" onClick={openNewClassEditor}>
<Plus />
</Button>
</div>
<Input ref={classZipInputRef} type="file" accept=".zip" className="hidden" onChange={importClassZip} />
<Input
ref={studentPhotoInputRef}
type="file"
accept=".zip,image/jpeg,image/png"
multiple
className="hidden"
onChange={importStudentPhotos}
/>
</CardContent>
</Card>
{filteredClasses.length > 0 ? (
<div className="grid gap-5 md:grid-cols-2 2xl:grid-cols-4">
{filteredClasses.map((classItem) => {
const count = generatedCountsByClassId[classItem.id] ?? 0
const config = resolveIdPhotoConfig(classItem.idPhotoConfig)
return (
<Card
key={classItem.id}
className={cn(
'relative overflow-visible rounded-2xl border transition-all',
activeClass?.id === classItem.id && 'border-primary shadow-lg shadow-primary/10'
)}
>
<CardContent className="space-y-5 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-2xl font-semibold">{classItem.name}</h3>
<span className="rounded-full bg-accent px-2.5 py-1 text-xs text-accent-foreground">
{count > 0 ? '已生成' : '待生成'}
</span>
</div>
<p className="break-all text-xs text-muted-foreground">{classItem.id}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-muted/60 px-4 py-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-2 text-3xl font-semibold">{classItem.students.length}</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="rounded-xl bg-muted/60 px-4 py-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-2 text-3xl font-semibold">
{count} / {classItem.students.length || 0}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span
className="h-4 w-4 rounded-full border"
style={{ backgroundColor: idPhotoBackgroundColor(config) }}
/>
<span>
{idPhotoSizeLabel(config.size)} / {idPhotoBackgroundLabel(config.background)}
</span>
</div>
<div className="flex items-center justify-end gap-3">
<div className="relative">
<Button
type="button"
variant="outline"
className="gap-2 rounded-xl"
onClick={() => {
setActiveClassId(classItem.id)
setOpenClassMenuId((current) => (current === classItem.id ? '' : classItem.id))
}}
>
<MoreHorizontal />
</Button>
{openClassMenuId === classItem.id ? (
<div className="absolute bottom-12 right-0 z-20 w-60 rounded-2xl border bg-card p-2 shadow-2xl">
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => openClassEditor(classItem)}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => openClassZipPicker(classItem.id)}
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => openStudentPhotoPicker(classItem.id)}
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-50"
disabled={isWorking || classItem.students.length === 0}
onClick={() => generateClassIdPhotos(classItem)}
>
{isWorking ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserRoundCheck className="h-4 w-4" />}
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => navigate(`/students?classId=${classItem.id}`)}
>
<UserRoundCheck className="h-4 w-4" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-50"
disabled={!activeTemplate || isWorking || classItem.students.length === 0}
onClick={() => generateClassCertificates(classItem)}
>
{isWorking ? <Loader2 className="h-4 w-4 animate-spin" /> : <BadgeCheck className="h-4 w-4" />}
</button>
</div>
) : null}
</div>
<Button
type="button"
variant="destructive"
className="rounded-xl"
onClick={() => removeClass(classItem)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
) : (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-muted-foreground">
{classes.length === 0 ? '还没有班级,先点击上方“新建班级”开始。' : '没有匹配的班级。'}
</CardContent>
</Card>
)}
{isClassEditorOpen ? (
<div className="fixed inset-0 z-50 bg-foreground/35" role="dialog" aria-modal="true">
<button
type="button"
aria-label="关闭班级编辑"
className="absolute inset-0 h-full w-full cursor-default"
onClick={resetEditor}
/>
<aside className="absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-card shadow-2xl">
<div className="border-b p-5">
<p className="text-sm text-muted-foreground">{editingClassId ? '编辑班级' : '新建班级'}</p>
<h3 className="text-xl font-semibold">{editingClassId ? '更新班级信息' : '创建新的班级'}</h3>
</div>
<div className="flex-1 space-y-5 overflow-auto p-5">
<div className="space-y-2">
<label htmlFor="class-name" className="text-sm font-medium">
</label>
<Input
id="class-name"
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
placeholder="例如:大一班"
className="h-11"
/>
</div>
<div className="space-y-3 rounded-xl border bg-muted/30 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Palette className="h-4 w-4" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<label htmlFor="id-photo-size" className="text-sm font-medium">
</label>
<select
id="id-photo-size"
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={draftIdPhotoConfig.size}
onChange={(event) => updateDraftSize(event.target.value as IdPhotoSize)}
>
<option value="one-inch"> 295x413</option>
<option value="two-inch"> 413x579</option>
</select>
</div>
<div className="space-y-2">
<label htmlFor="id-photo-background" className="text-sm font-medium">
</label>
<select
id="id-photo-background"
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={draftIdPhotoConfig.background}
onChange={(event) =>
setDraftIdPhotoConfig((current) =>
resolveIdPhotoConfig({
...current,
background: event.target.value as IdPhotoBackground
})
)
}
>
<option value="blue"></option>
<option value="white"></option>
<option value="red"></option>
<option value="custom"></option>
</select>
</div>
{draftIdPhotoConfig.background === 'custom' ? (
<div className="col-span-2 space-y-2">
<label htmlFor="id-photo-custom-color" className="text-sm font-medium">
</label>
<Input
id="id-photo-custom-color"
type="color"
value={draftIdPhotoConfig.customColor ?? ID_PHOTO_BACKGROUND_COLORS.blue}
onChange={(event) =>
setDraftIdPhotoConfig((current) =>
resolveIdPhotoConfig({
...current,
customColor: event.target.value
})
)
}
/>
</div>
) : null}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<label
htmlFor="class-zip"
className="flex min-h-40 cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed bg-muted/40 px-6 text-center transition-colors hover:bg-muted"
>
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<div className="rounded-full bg-background p-3 shadow-sm">
<Upload className="h-6 w-6" />
</div>
<div>
<div className="text-sm font-medium text-foreground">
{draftZipFile ? draftZipFile.name : '点击上传幼儿信息 ZIP'}
</div>
<div className="mt-1 text-xs">ZIP </div>
</div>
</div>
</label>
<Input
id="class-zip"
type="file"
accept=".zip"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
setDraftZipFile(file && /\.zip$/i.test(file.name) ? file : null)
}}
/>
</div>
</div>
<div className="flex justify-end gap-2 border-t p-5">
<Button type="button" variant="outline" onClick={resetEditor}>
</Button>
<Button type="button" disabled={isWorking} onClick={saveClass}>
{isWorking ? <Loader2 className="animate-spin" /> : null}
{editingClassId ? '保存班级' : '创建班级'}
</Button>
</div>
</aside>
</div>
) : null}
</div>
)
}
+332
View File
@@ -0,0 +1,332 @@
'use client'
import { ChangeEvent, useEffect, useMemo, useState } from 'react'
import JSZip from 'jszip'
import { FileArchive, Loader2, Pencil, RefreshCw, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { generateCertificateAsset, loadImageSource, type ImageSource } from '@/lib/certificate-browser'
import { sanitizeFileName } from '@/lib/certificate-types'
import {
type ClassItem,
type GeneratedCertificate,
blobFromSource,
createResultReport,
downloadBlob,
hydrateCertificate,
hydrateClassMetadata,
loadAppState,
persistGeneratedCertificate,
saveAppState
} from '@/lib/certificate-page-utils'
import type { PersistedAppState } from '@/lib/storage-types'
export default function GeneratedPage() {
const [storedState, setStoredState] = useState<PersistedAppState | null>(null)
const [classes, setClasses] = useState<ClassItem[]>([])
const [selectedClassId, setSelectedClassId] = useState('')
const [generatedCertificates, setGeneratedCertificates] = useState<GeneratedCertificate[]>([])
const [countsByClassId, setCountsByClassId] = useState<Record<string, number>>({})
const [isWorking, setIsWorking] = useState(false)
const [editingCertificateId, setEditingCertificateId] = useState('')
const [editingName, setEditingName] = useState('')
const [editingPhoto, setEditingPhoto] = useState<ImageSource | null>(null)
const selectedClass = classes.find((classItem) => classItem.id === selectedClassId) ?? classes[0]
const editingCertificate = generatedCertificates.find(
(certificate) => certificate.id === editingCertificateId
)
const currentCertificates = useMemo(
() => generatedCertificates.filter((certificate) => certificate.classId === selectedClass?.id),
[generatedCertificates, selectedClass?.id]
)
useEffect(() => {
async function loadGeneratedIndex() {
const state = await loadAppState()
const restoredClasses = (state.classes ?? []).map(hydrateClassMetadata)
const counts = (state.certificates ?? []).reduce<Record<string, number>>((result, certificate) => {
result[certificate.classId] = (result[certificate.classId] ?? 0) + 1
return result
}, {})
const classIdFromUrl = new URLSearchParams(window.location.search).get('classId') ?? ''
const initialClassId =
classIdFromUrl ||
state.activeClassId ||
restoredClasses[0]?.id ||
state.certificates?.[0]?.classId ||
''
setStoredState(state)
setClasses(restoredClasses)
setCountsByClassId(counts)
setSelectedClassId(initialClassId)
}
loadGeneratedIndex()
}, [])
useEffect(() => {
async function loadSelectedClassCertificates() {
if (!storedState || !selectedClassId) {
return
}
if (generatedCertificates.some((certificate) => certificate.classId === selectedClassId)) {
return
}
setIsWorking(true)
try {
const restoredCertificates = await Promise.all(
(storedState.certificates ?? [])
.filter((certificate) => certificate.classId === selectedClassId)
.map(hydrateCertificate)
)
setGeneratedCertificates((current) => [
...current.filter((certificate) => certificate.classId !== selectedClassId),
...restoredCertificates
])
} finally {
setIsWorking(false)
}
}
loadSelectedClassCertificates()
}, [generatedCertificates, selectedClassId, storedState])
async function persistCertificates(nextCertificates: GeneratedCertificate[]) {
if (!storedState || !selectedClass) {
return
}
const persistedCurrentClass = await Promise.all(
nextCertificates
.filter((certificate) => certificate.classId === selectedClass.id)
.map(persistGeneratedCertificate)
)
const nextState: PersistedAppState = {
...storedState,
activeClassId: selectedClass.id,
certificates: [
...(storedState.certificates ?? []).filter(
(certificate) => certificate.classId !== selectedClass.id
),
...persistedCurrentClass
]
}
await saveAppState(nextState)
setStoredState(nextState)
setCountsByClassId((current) => ({
...current,
[selectedClass.id]: persistedCurrentClass.length
}))
}
async function downloadClassCertificates() {
if (!selectedClass || currentCertificates.length === 0) {
return
}
const zip = new JSZip()
for (const certificate of currentCertificates) {
const fileName = sanitizeFileName(certificate.studentName)
zip.file(`jpg/${fileName}.jpg`, certificate.asset.jpg ?? await blobFromSource(certificate.jpgUrl))
}
zip.file('生成结果.csv', createResultReport(currentCertificates, selectedClass))
downloadBlob(
await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' }),
`${sanitizeFileName(selectedClass.name)}毕业证书.zip`
)
}
function startEdit(certificate: GeneratedCertificate) {
setEditingCertificateId(certificate.id)
setEditingName(certificate.studentName)
setEditingPhoto(certificate.photo)
}
function closeEdit() {
setEditingCertificateId('')
setEditingName('')
setEditingPhoto(null)
}
async function handleEditPhoto(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
setEditingPhoto(file ? await loadImageSource(file, file.name) : null)
}
async function saveEditedCertificate() {
if (!editingCertificate || !editingPhoto || !editingName.trim()) {
return
}
setIsWorking(true)
try {
const asset = await generateCertificateAsset(
editingCertificate.template.image,
editingPhoto,
editingName.trim(),
editingCertificate.template.config
)
const nextCertificate: GeneratedCertificate = {
...editingCertificate,
studentName: editingName.trim(),
photo: editingPhoto,
asset,
jpgUrl: URL.createObjectURL(asset.jpg),
updatedAt: new Date().toISOString()
}
const nextCertificates = generatedCertificates.map((certificate) =>
certificate.id === editingCertificate.id ? nextCertificate : certificate
)
if (editingCertificate.jpgUrl.startsWith('blob:')) {
URL.revokeObjectURL(editingCertificate.jpgUrl)
}
setGeneratedCertificates(nextCertificates)
await persistCertificates(nextCertificates)
closeEdit()
} finally {
setIsWorking(false)
}
}
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">Generated Certificates</p>
<h2 className="text-2xl font-semibold tracking-normal"></h2>
</div>
<div className="flex flex-wrap gap-2">
<select
className="h-10 rounded-md border bg-background px-3 text-sm"
value={selectedClass?.id ?? ''}
onChange={(event) => setSelectedClassId(event.target.value)}
>
{classes.map((classItem) => (
<option key={classItem.id} value={classItem.id}>
{classItem.name} ({countsByClassId[classItem.id] ?? 0})
</option>
))}
</select>
<Button type="button" variant="outline" onClick={downloadClassCertificates}>
<FileArchive />
</Button>
</div>
</div>
{isWorking && currentCertificates.length === 0 ? (
<div className="rounded-md border border-dashed bg-card p-10 text-center text-muted-foreground">
...
</div>
) : currentCertificates.length > 0 ? (
<div className="columns-1 gap-4 md:columns-2 2xl:columns-3">
{currentCertificates.map((certificate) => (
<Card key={certificate.id} className="mb-4 break-inside-avoid overflow-hidden">
<div className="bg-muted p-2">
<img
src={certificate.jpgUrl}
alt={`${certificate.studentName}的毕业证书`}
className="block w-full rounded-sm border bg-white object-contain"
/>
</div>
<CardContent className="space-y-3 p-4">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold">{certificate.studentName}</h3>
<span className="rounded-sm bg-accent px-2 py-1 text-xs text-accent-foreground">
{certificate.className}
</span>
</div>
<p className="text-sm text-muted-foreground">{certificate.template.name}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="outline">
<a href={certificate.jpgUrl} download={`${sanitizeFileName(certificate.studentName)}.jpg`}>
<FileArchive />
JPG
</a>
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => startEdit(certificate)}>
<Pencil />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-md border border-dashed bg-card p-10 text-center text-muted-foreground">
</div>
)}
{editingCertificate ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/35 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="edit-certificate-title"
>
<Card className="max-h-[90vh] w-full max-w-xl overflow-auto shadow-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle id="edit-certificate-title"></CardTitle>
<Button type="button" size="icon" variant="ghost" aria-label="关闭编辑弹窗" onClick={closeEdit}>
<X />
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[1fr_180px]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="edit-name"></Label>
<Input
id="edit-name"
value={editingName}
onChange={(event) => setEditingName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-photo"></Label>
<Input id="edit-photo" type="file" accept="image/jpeg,image/png" onChange={handleEditPhoto} />
</div>
</div>
<div className="rounded-md border bg-muted p-2">
{editingPhoto ? (
<img
src={editingPhoto.dataUrl}
alt={editingName || '替换照片预览'}
className="mx-auto max-h-52 rounded-sm object-contain"
/>
) : null}
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={closeEdit}>
</Button>
<Button type="button" disabled={isWorking} onClick={saveEditedCertificate}>
{isWorking ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
)
}
+369
View File
@@ -0,0 +1,369 @@
'use client'
import { PointerEvent, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeft, Loader2, UserRoundCheck, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { generateIdPhoto } from '@/lib/certificate-browser'
import {
DEFAULT_ID_PHOTO_CROP,
type IdPhotoCrop,
type Rect,
idPhotoBackgroundColor,
idPhotoBackgroundLabel,
idPhotoSizeLabel,
resolveIdPhotoCrop,
resolveIdPhotoConfig
} from '@/lib/certificate-types'
import {
type ClassItem,
type ClassStudent,
hydrateClassItem,
hydrateClassMetadata,
loadAppState,
persistClasses,
saveAppState
} from '@/lib/certificate-page-utils'
import type { PersistedAppState } from '@/lib/storage-types'
function navigate(href: string) {
window.history.pushState(null, '', href)
window.dispatchEvent(new PopStateEvent('popstate'))
}
export default function StudentsPage() {
const cropImageRef = useRef<HTMLImageElement | null>(null)
const [storedState, setStoredState] = useState<PersistedAppState | null>(null)
const [classes, setClasses] = useState<ClassItem[]>([])
const [activeClass, setActiveClass] = useState<ClassItem | null>(null)
const [selectedStudentId, setSelectedStudentId] = useState('')
const [cropDraft, setCropDraft] = useState<IdPhotoCrop>(DEFAULT_ID_PHOTO_CROP)
const [cropDragStart, setCropDragStart] = useState<{ x: number; y: number } | null>(null)
const [isWorking, setIsWorking] = useState(false)
const selectedStudent = useMemo(
() => activeClass?.students.find((student) => student.id === selectedStudentId),
[activeClass?.students, selectedStudentId]
)
const idPhotoConfig = resolveIdPhotoConfig(activeClass?.idPhotoConfig)
useEffect(() => {
if (selectedStudent) {
setCropDraft(resolveIdPhotoCrop(selectedStudent.idPhotoCrop))
}
}, [selectedStudent])
useEffect(() => {
async function loadStudents() {
const state = await loadAppState()
const classIdFromUrl = new URLSearchParams(window.location.search).get('classId') ?? ''
const persistedClass =
state.classes.find((classItem) => classItem.id === classIdFromUrl) ?? state.classes[0]
const restoredClasses = (state.classes ?? []).map(hydrateClassMetadata)
const hydratedClass = persistedClass ? await hydrateClassItem(persistedClass) : null
setStoredState(state)
setClasses(
hydratedClass
? restoredClasses.map((classItem) => (classItem.id === hydratedClass.id ? hydratedClass : classItem))
: restoredClasses
)
setActiveClass(hydratedClass)
}
loadStudents()
}, [])
async function persistActiveClass(nextClass: ClassItem) {
if (!storedState) {
return
}
const nextClasses = classes.map((classItem) => (classItem.id === nextClass.id ? nextClass : classItem))
const nextState: PersistedAppState = {
...storedState,
classes: persistClasses(nextClasses, storedState),
activeClassId: nextClass.id
}
await saveAppState(nextState)
setStoredState(nextState)
setClasses(nextClasses)
setActiveClass(nextClass)
}
async function generateStudentIdPhoto(student: ClassStudent) {
if (!activeClass || !student.photo) {
return
}
setIsWorking(true)
try {
const crop = resolveIdPhotoCrop(cropDraft)
const idPhoto = await generateIdPhoto(student.photo, idPhotoConfig, crop)
const nextClass: ClassItem = {
...activeClass,
idPhotoConfig,
students: activeClass.students.map((item) =>
item.id === student.id
? {
...item,
idPhoto,
idPhotoFileName: idPhoto.fileName,
idPhotoCrop: crop
}
: item
)
}
await persistActiveClass(nextClass)
} finally {
setIsWorking(false)
}
}
function cropPoint(event: PointerEvent<HTMLElement>) {
const image = cropImageRef.current
if (!image) {
return null
}
const bounds = image.getBoundingClientRect()
const x = (event.clientX - bounds.left) / bounds.width
const y = (event.clientY - bounds.top) / bounds.height
return {
x: Math.min(Math.max(x, 0), 1),
y: Math.min(Math.max(y, 0), 1)
}
}
function cropRectFromPoints(start: { x: number; y: number }, end: { x: number; y: number }): Rect {
return {
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
width: Math.max(Math.abs(end.x - start.x), 0.01),
height: Math.max(Math.abs(end.y - start.y), 0.01)
}
}
function handleCropPointerDown(event: PointerEvent<HTMLElement>) {
const point = cropPoint(event)
if (!point) {
return
}
event.currentTarget.setPointerCapture(event.pointerId)
setCropDragStart(point)
setCropDraft((current) => resolveIdPhotoCrop({ ...current, sourceRect: { ...point, width: 0.01, height: 0.01 } }))
}
function handleCropPointerMove(event: PointerEvent<HTMLElement>) {
if (!cropDragStart) {
return
}
const point = cropPoint(event)
if (!point) {
return
}
setCropDraft((current) =>
resolveIdPhotoCrop({
...current,
sourceRect: cropRectFromPoints(cropDragStart, point)
})
)
}
function handleCropPointerUp(event: PointerEvent<HTMLElement>) {
if (cropDragStart) {
handleCropPointerMove(event)
}
setCropDragStart(null)
}
if (!activeClass) {
return (
<div className="rounded-md border border-dashed bg-card p-10 text-center text-muted-foreground">
...
</div>
)
}
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">Student Management</p>
<h2 className="text-2xl font-semibold tracking-normal">{activeClass.name}</h2>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span
className="h-4 w-4 rounded-full border"
style={{ backgroundColor: idPhotoBackgroundColor(idPhotoConfig) }}
/>
<span>
{idPhotoSizeLabel(idPhotoConfig.size)} / {idPhotoBackgroundLabel(idPhotoConfig.background)}
</span>
</div>
</div>
<Button type="button" variant="outline" onClick={() => navigate('/classes')}>
<ArrowLeft />
</Button>
</div>
{activeClass.students.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{activeClass.students.map((student) => (
<Card key={student.id} className="overflow-hidden">
<div className="grid grid-cols-2 gap-2 bg-muted p-2">
<div className="aspect-[3/4] overflow-hidden rounded-sm border bg-background">
{student.photo ? (
<img src={student.photo.dataUrl} alt={`${student.name}原始照片`} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-3 text-center text-xs text-muted-foreground">
</div>
)}
</div>
<div className="aspect-[3/4] overflow-hidden rounded-sm border bg-background">
{student.idPhoto ? (
<img src={student.idPhoto.dataUrl} alt={`${student.name}证件照`} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-3 text-center text-xs text-muted-foreground">
</div>
)}
</div>
</div>
<CardContent className="space-y-3 p-4">
<div>
<h3 className="text-base font-semibold">{student.name}</h3>
<p className="mt-1 text-xs text-muted-foreground">{student.idPhoto ? '证件照已生成' : '证件照待生成'}</p>
</div>
<Button type="button" size="sm" className="w-full" onClick={() => setSelectedStudentId(student.id)}>
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="p-10 text-center text-muted-foreground">
</CardContent>
</Card>
)}
{selectedStudent ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/35 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="student-detail-title"
>
<Card className="max-h-[90vh] w-full max-w-3xl overflow-auto shadow-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle id="student-detail-title">{selectedStudent.name}</CardTitle>
<Button type="button" size="icon" variant="ghost" aria-label="关闭详情" onClick={() => setSelectedStudentId('')}>
<X />
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="overflow-hidden rounded-md border bg-muted">
{selectedStudent.photo ? (
<div
className="relative cursor-crosshair select-none"
onPointerDown={handleCropPointerDown}
onPointerMove={handleCropPointerMove}
onPointerUp={handleCropPointerUp}
onPointerCancel={() => setCropDragStart(null)}
>
<img
ref={cropImageRef}
src={selectedStudent.photo.dataUrl}
alt={`${selectedStudent.name}原始照片`}
className="block w-full"
draggable={false}
/>
{cropDraft.sourceRect ? (
<div
className="pointer-events-none absolute border-2 border-primary bg-primary/10 shadow-[0_0_0_9999px_rgba(15,23,42,0.35)]"
style={{
left: `${cropDraft.sourceRect.x * 100}%`,
top: `${cropDraft.sourceRect.y * 100}%`,
width: `${cropDraft.sourceRect.width * 100}%`,
height: `${cropDraft.sourceRect.height * 100}%`
}}
/>
) : null}
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
</div>
)}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="aspect-[3/4] overflow-hidden rounded-md border bg-muted">
{selectedStudent.idPhoto ? (
<img
src={selectedStudent.idPhoto.dataUrl}
alt={`${selectedStudent.name}证件照`}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
</div>
)}
</div>
</div>
</div>
<div className="rounded-md border bg-muted/30 p-4">
<div className="text-sm font-medium"></div>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setCropDraft(resolveIdPhotoCrop(DEFAULT_ID_PHOTO_CROP))}
>
</Button>
<Button type="button" variant="outline" onClick={() => setSelectedStudentId('')}>
</Button>
<Button
type="button"
disabled={isWorking || !selectedStudent.photo}
onClick={() => generateStudentIdPhoto(selectedStudent)}
>
{isWorking ? <Loader2 className="animate-spin" /> : <UserRoundCheck />}
</Button>
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
)
}
+445
View File
@@ -0,0 +1,445 @@
'use client'
import { PointerEvent, useEffect, useMemo, useRef, useState } from 'react'
import { FileJson, ImagePlus, MousePointer2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
DEFAULT_TEMPLATE_CONFIG,
type CertificateTemplateConfig,
type Rect,
denormalizeRect,
normalizeRect
} from '@/lib/certificate-types'
import {
type ActiveTool,
type TemplateItem,
createId,
downloadText,
hydrateTemplate,
loadAppState,
loadDefaultTemplateImage,
persistImageSource,
saveAppState
} from '@/lib/certificate-page-utils'
import { loadImageSource, type ImageSource } from '@/lib/certificate-browser'
import { cn } from '@/lib/utils'
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
export default function TemplatesPage() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const [storedState, setStoredState] = useState<Awaited<ReturnType<typeof loadAppState>> | null>(null)
const [templates, setTemplates] = useState<TemplateItem[]>([])
const [activeTemplateId, setActiveTemplateId] = useState('')
const [templateDraftName, setTemplateDraftName] = useState('亲爱版毕业证书')
const [templateDraftImage, setTemplateDraftImage] = useState<ImageSource | null>(null)
const [templateDraftConfig, setTemplateDraftConfig] =
useState<CertificateTemplateConfig>(DEFAULT_TEMPLATE_CONFIG)
const [activeTool, setActiveTool] = useState<ActiveTool>('image')
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const activeTemplate = templates.find((template) => template.id === activeTemplateId) ?? templates[0]
const renderedRects = useMemo(() => {
if (!templateDraftImage) {
return null
}
return {
image: denormalizeRect(
templateDraftConfig.imageRect,
templateDraftImage.width,
templateDraftImage.height
),
name: denormalizeRect(
templateDraftConfig.nameRect,
templateDraftImage.width,
templateDraftImage.height
)
}
}, [templateDraftConfig.imageRect, templateDraftConfig.nameRect, templateDraftImage])
useEffect(() => {
async function loadTemplates() {
const state = await loadAppState()
const restoredTemplates = await Promise.all((state.templates ?? []).map(hydrateTemplate))
const restoredActiveTemplate =
restoredTemplates.find((template) => template.id === state.activeTemplateId) ??
restoredTemplates[0]
setStoredState(state)
if (restoredActiveTemplate) {
setTemplates(restoredTemplates)
setActiveTemplateId(restoredActiveTemplate.id)
setTemplateDraftName(restoredActiveTemplate.name)
setTemplateDraftImage(restoredActiveTemplate.image)
setTemplateDraftConfig(restoredActiveTemplate.config)
return
}
const image = await loadDefaultTemplateImage()
const template: TemplateItem = {
id: createId('template'),
name: '亲爱版毕业证书',
image,
config: {
...DEFAULT_TEMPLATE_CONFIG,
templateWidth: image.width,
templateHeight: image.height
},
updatedAt: new Date().toISOString()
}
setTemplates([template])
setActiveTemplateId(template.id)
setTemplateDraftName(template.name)
setTemplateDraftImage(image)
setTemplateDraftConfig(template.config)
}
loadTemplates()
}, [])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !templateDraftImage || !renderedRects) {
return
}
canvas.width = templateDraftImage.width
canvas.height = templateDraftImage.height
const context = canvas.getContext('2d')
if (!context) {
return
}
const image = new Image()
image.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(image, 0, 0, canvas.width, canvas.height)
context.strokeStyle = activeTool === 'image' ? '#2563eb' : '#16a34a'
context.lineWidth = 4
context.setLineDash([14, 10])
const rect = activeTool === 'image' ? renderedRects.image : renderedRects.name
context.strokeRect(rect.x, rect.y, rect.width, rect.height)
context.setLineDash([])
}
image.src = templateDraftImage.dataUrl
}, [activeTool, renderedRects, templateDraftImage])
function canvasPoint(event: PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas || !templateDraftImage) {
return null
}
const bounds = canvas.getBoundingClientRect()
return {
x: ((event.clientX - bounds.left) / bounds.width) * templateDraftImage.width,
y: ((event.clientY - bounds.top) / bounds.height) * templateDraftImage.height
}
}
function updateActiveRect(rect: Rect) {
if (!templateDraftImage) {
return
}
const normalized = normalizeRect(rect, templateDraftImage.width, templateDraftImage.height)
setTemplateDraftConfig((current) => ({
...current,
[activeTool === 'image' ? 'imageRect' : 'nameRect']: normalized
}))
}
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
const point = canvasPoint(event)
if (point) {
setDragStart(point)
}
}
function handlePointerUp(event: PointerEvent<HTMLCanvasElement>) {
if (!templateDraftImage || !dragStart) {
return
}
const end = canvasPoint(event)
setDragStart(null)
if (!end) {
return
}
const x = clamp(Math.min(dragStart.x, end.x), 0, templateDraftImage.width)
const y = clamp(Math.min(dragStart.y, end.y), 0, templateDraftImage.height)
const width = clamp(Math.abs(end.x - dragStart.x), 1, templateDraftImage.width - x)
const height = clamp(Math.abs(end.y - dragStart.y), 1, templateDraftImage.height - y)
if (width >= 12 && height >= 12) {
updateActiveRect({ x, y, width, height })
}
}
async function handleTemplateImageChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) {
return
}
const image = await loadImageSource(file, file.name)
setTemplateDraftImage(image)
setTemplateDraftConfig((current) => ({
...current,
templateWidth: image.width,
templateHeight: image.height
}))
}
async function saveTemplate() {
if (!templateDraftImage || !templateDraftName.trim()) {
return
}
const nextTemplate: TemplateItem = {
id: activeTemplateId || createId('template'),
name: templateDraftName.trim(),
image: templateDraftImage,
config: templateDraftConfig,
updatedAt: new Date().toISOString()
}
const nextTemplates = templates.some((template) => template.id === nextTemplate.id)
? templates.map((template) => (template.id === nextTemplate.id ? nextTemplate : template))
: [...templates, nextTemplate]
setTemplates(nextTemplates)
setActiveTemplateId(nextTemplate.id)
await saveAppState({
templates: nextTemplates.map((template) => ({
...template,
image: persistImageSource(template.image)
})),
activeTemplateId: nextTemplate.id,
classes: storedState?.classes ?? [],
activeClassId: storedState?.activeClassId ?? '',
certificates: storedState?.certificates ?? []
})
}
function loadTemplateIntoEditor(template: TemplateItem) {
setActiveTemplateId(template.id)
setTemplateDraftName(template.name)
setTemplateDraftImage(template.image)
setTemplateDraftConfig(template.config)
}
async function importConfig(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) {
return
}
setTemplateDraftConfig(JSON.parse(await file.text()) as CertificateTemplateConfig)
}
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">Template Management</p>
<h2 className="text-2xl font-semibold tracking-normal"></h2>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => downloadText(JSON.stringify(templateDraftConfig, null, 2), 'certificate-template-config.json')}
>
<FileJson />
</Button>
<Input
id="template-config-import"
type="file"
accept="application/json,.json"
className="hidden"
onChange={importConfig}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('template-config-import')?.click()}
>
<Upload />
</Button>
<Button type="button" onClick={saveTemplate}>
</Button>
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<CardContent className="p-3">
<div className="mb-3 flex gap-2">
<Button
type="button"
variant={activeTool === 'image' ? 'default' : 'outline'}
onClick={() => setActiveTool('image')}
>
<ImagePlus />
</Button>
<Button
type="button"
variant={activeTool === 'name' ? 'default' : 'outline'}
onClick={() => setActiveTool('name')}
>
<MousePointer2 />
</Button>
</div>
<div className="max-h-[calc(100vh-220px)] overflow-auto rounded-md bg-muted p-3">
<canvas
ref={canvasRef}
className="mx-auto block h-auto max-w-full cursor-crosshair rounded-sm border bg-white"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
/>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="template-name"></Label>
<Input
id="template-name"
value={templateDraftName}
onChange={(event) => setTemplateDraftName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="template-image"></Label>
<Input
id="template-image"
type="file"
accept="image/jpeg,image/png"
onChange={handleTemplateImageChange}
/>
</div>
<div className="space-y-2">
<Label></Label>
{templates.map((template) => (
<button
key={template.id}
type="button"
className={cn(
'block w-full rounded-md border px-3 py-2 text-left text-sm',
activeTemplate?.id === template.id && 'border-primary bg-accent'
)}
onClick={() => loadTemplateIntoEditor(template)}
>
{template.name}
</button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="font-family"></Label>
<Input
id="font-family"
value={templateDraftConfig.textStyle.fontFamily}
onChange={(event) =>
setTemplateDraftConfig((current) => ({
...current,
textStyle: { ...current.textStyle, fontFamily: event.target.value }
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="font-size"></Label>
<Input
id="font-size"
type="number"
min={12}
max={180}
value={templateDraftConfig.textStyle.fontSize}
onChange={(event) =>
setTemplateDraftConfig((current) => ({
...current,
textStyle: { ...current.textStyle, fontSize: Number(event.target.value) }
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="font-color"></Label>
<Input
id="font-color"
type="color"
value={templateDraftConfig.textStyle.color}
onChange={(event) =>
setTemplateDraftConfig((current) => ({
...current,
textStyle: { ...current.textStyle, color: event.target.value }
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="font-weight"></Label>
<select
id="font-weight"
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={templateDraftConfig.textStyle.fontWeight}
onChange={(event) =>
setTemplateDraftConfig((current) => ({
...current,
textStyle: {
...current.textStyle,
fontWeight: event.target.value as CertificateTemplateConfig['textStyle']['fontWeight']
}
}))
}
>
<option value="400"></option>
<option value="600"></option>
<option value="700"></option>
</select>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
export function useRouter() {
return {
push(href: string) {
window.history.pushState(null, '', href)
window.dispatchEvent(new PopStateEvent('popstate'))
}
}
}
export function usePathname() {
return window.location.pathname
}
+52
View File
@@ -0,0 +1,52 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class'],
content: [
'./src/**/*.{ts,tsx}',
'../../app/**/*.{ts,tsx}'
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: []
}
export default config
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"],
"next/navigation": ["./src/shims/next-navigation.ts"]
}
},
"include": ["src", "../../app/**/*.tsx"],
"references": []
}
+36
View File
@@ -0,0 +1,36 @@
import path from 'node:path'
import { defineConfig, searchForWorkspaceRoot } from 'vite'
import react from '@vitejs/plugin-react'
const workspaceRoot = path.resolve(__dirname, '../..')
const webSrc = path.resolve(__dirname, 'src')
const crossOriginIsolationHeaders = {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Resource-Policy': 'same-origin'
}
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': webSrc,
'next/navigation': path.resolve(__dirname, 'src/shims/next-navigation.ts')
}
},
server: {
headers: crossOriginIsolationHeaders,
fs: {
allow: [searchForWorkspaceRoot(process.cwd()), workspaceRoot]
},
proxy: {
'/api': {
target: 'http://127.0.0.1:4000',
changeOrigin: true
}
}
},
preview: {
headers: crossOriginIsolationHeaders
}
})