fix:第一次提交,当前还有很多BUG
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 780 KiB |
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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'
|
||||
})
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user