feat: add bulk photo import and image format handling

This commit is contained in:
2026-07-17 12:43:40 +08:00
parent 0015063a03
commit e999f8b606
21 changed files with 2161 additions and 1421 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -36,6 +36,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-log": "^5.4.4", "electron-log": "^5.4.4",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"heic-convert": "^2.1.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"node-pptx-templater": "^1.1.8", "node-pptx-templater": "^1.1.8",
@@ -59,6 +60,7 @@
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6", "electron": "^39.2.6",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-rebuild": "^3.2.9",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
+1468 -1094
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -5,8 +5,11 @@ allowBuilds:
esbuild: true esbuild: true
onlyBuiltDependencies: onlyBuiltDependencies:
- better-sqlite3
- electron - electron
- electron-winstaller
- esbuild - esbuild
- lzma-native
registries: registries:
default: https://registry.npmmirror.com/ default: https://registry.npmmirror.com/
+49
View File
@@ -18,6 +18,12 @@ import {
stopCommentGeneration, stopCommentGeneration,
updateStudentProfile updateStudentProfile
} from '../services/studentService' } from '../services/studentService'
import {
importStudentPhotos,
pausePhotoImport,
resumePhotoImport,
stopPhotoImport
} from '../services/photoImportService'
import type { import type {
AddClassZodiacsInput, AddClassZodiacsInput,
AddClassZodiacsResponse, AddClassZodiacsResponse,
@@ -30,6 +36,8 @@ import type {
GenerateStudentCommentsInput, GenerateStudentCommentsInput,
GenerateStudentCommentsResponse, GenerateStudentCommentsResponse,
GetStudentProfileResponse, GetStudentProfileResponse,
ImportStudentPhotosInput,
ImportStudentPhotosResponse,
LoadStudentDataInput, LoadStudentDataInput,
ListStudentProfilesInput, ListStudentProfilesInput,
ListStudentProfilesResponse, ListStudentProfilesResponse,
@@ -119,6 +127,47 @@ export function registerStudentIpc(): void {
} }
) )
ipcMain.handle(
'photos:import',
async (_, payload: ImportStudentPhotosInput): Promise<ImportStudentPhotosResponse> => {
try {
return {
ok: true,
...(await importStudentPhotos(payload))
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'photos:pause-import',
async (_, batchId: string): Promise<TaskControlResponse> => {
return pausePhotoImport(batchId)
? { ok: true }
: { ok: false, message: '当前没有可暂停的照片导入任务' }
}
)
ipcMain.handle(
'photos:resume-import',
async (_, batchId: string): Promise<TaskControlResponse> => {
return resumePhotoImport(batchId)
? { ok: true }
: { ok: false, message: '当前没有可继续的照片导入任务' }
}
)
ipcMain.handle('photos:stop-import', async (_, batchId: string): Promise<TaskControlResponse> => {
return stopPhotoImport(batchId)
? { ok: true }
: { ok: false, message: '当前没有可停止的照片导入任务' }
})
ipcMain.handle( ipcMain.handle(
'student:generate-comment', 'student:generate-comment',
async (_, payload: GenerateStudentCommentInput): Promise<GenerateStudentCommentResponse> => { async (_, payload: GenerateStudentCommentInput): Promise<GenerateStudentCommentResponse> => {
+62
View File
@@ -0,0 +1,62 @@
import heicConvert from 'heic-convert'
export type NormalizedImage = {
buffer: Buffer
mimeType: string
}
function startsWithBytes(buffer: Buffer, bytes: number[]): boolean {
return bytes.every((byte, index) => buffer[index] === byte)
}
function isIsoBaseMediaImage(buffer: Buffer, brands: string[]): boolean {
if (buffer.length < 12 || buffer.subarray(4, 8).toString('ascii') !== 'ftyp') {
return false
}
const header = buffer.subarray(8, Math.min(buffer.length, 64)).toString('ascii')
return brands.some((brand) => header.includes(brand))
}
export async function normalizeImageBuffer(buffer: Buffer): Promise<NormalizedImage> {
if (startsWithBytes(buffer, [0xff, 0xd8, 0xff])) {
return { buffer, mimeType: 'image/jpeg' }
}
if (startsWithBytes(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return { buffer, mimeType: 'image/png' }
}
if (
buffer.subarray(0, 6).toString('ascii') === 'GIF87a' ||
buffer.subarray(0, 6).toString('ascii') === 'GIF89a'
) {
return { buffer, mimeType: 'image/gif' }
}
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { buffer, mimeType: 'image/webp' }
}
if (startsWithBytes(buffer, [0x42, 0x4d])) {
return { buffer, mimeType: 'image/bmp' }
}
if (isIsoBaseMediaImage(buffer, ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'mif1'])) {
const converted = await heicConvert({
buffer,
format: 'JPEG',
quality: 0.9
})
return {
buffer: Buffer.from(converted),
mimeType: 'image/jpeg'
}
}
throw new Error('图片内容不是受支持的 JPEG、PNG、GIF、WebP、BMP 或 HEIC 格式')
}
+20 -1
View File
@@ -25,7 +25,9 @@ const EXTENSION_MIME_MAP: Record<string, string> = {
gif: 'image/gif' gif: 'image/gif'
} }
function parseDataUrl(value: string): { mimeType: string; extension: string; buffer: Buffer } | null { function parseDataUrl(
value: string
): { mimeType: string; extension: string; buffer: Buffer } | null {
const match = value.match(/^data:(image\/[^;]+);base64,(.+)$/) const match = value.match(/^data:(image\/[^;]+);base64,(.+)$/)
if (!match) { if (!match) {
@@ -103,6 +105,23 @@ export async function storeImageValue(
return relativePath return relativePath
} }
export async function storeImageBuffer(
scope: ImageScope,
ownerId: string,
fieldName: string,
mimeType: string,
buffer: Buffer
): Promise<string> {
const extension = MIME_EXTENSION_MAP[mimeType.toLowerCase()] ?? 'jpg'
const relativePath = getRelativeImagePath(scope, ownerId, fieldName, extension)
const absolutePath = resolveImagePath(relativePath)
await mkdir(dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, buffer)
return relativePath
}
export async function readImageAsDataUrl(value?: string | null): Promise<string> { export async function readImageAsDataUrl(value?: string | null): Promise<string> {
if (!value) { if (!value) {
return '' return ''
+385
View File
@@ -0,0 +1,385 @@
import { extname } from 'path'
import { readFile, stat } from 'fs/promises'
import { BrowserWindow } from 'electron'
import JSZip from 'jszip'
import {
StudentProfileEntitySchema,
type StudentProfileEntity
} from '../entities/StudentProfileEntity'
import { ClassEntitySchema } from '../entities/ClassEntity'
import type { ImportStudentPhotosInput, PhotoImportProgress } from '../types/student'
import { getAppDataSource } from './databaseService'
import { normalizeImageBuffer } from './imageFormatService'
import { storeImageBuffer } from './imageStorageService'
type PhotoField = 'meImage' | 'workImage1' | 'workImage2'
type PhotoImportController = {
paused: boolean
stopped: boolean
lastProgress: PhotoImportProgress
resumeWaiters: Array<() => void>
}
type ZipObjectWithSize = JSZip.JSZipObject & {
_data?: {
uncompressedSize?: number
}
}
const MAX_ZIP_SIZE = 512 * 1024 * 1024
const MAX_IMAGE_SIZE = 50 * 1024 * 1024
const MAX_TOTAL_IMAGE_SIZE = 2 * 1024 * 1024 * 1024
const photoImportControllers = new Map<string, PhotoImportController>()
const PHOTO_FILE_NAME_MAP: Record<string, PhotoField> = {
me: 'meImage',
me_image: 'meImage',
'1': 'workImage1',
work_1: 'workImage1',
work_image_1: 'workImage1',
'2': 'workImage2',
work_2: 'workImage2',
work_image_2: 'workImage2'
}
const IMAGE_EXTENSION_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.heic': 'image/heic',
'.heif': 'image/heif'
}
function normalizeMatchName(value: string): string {
return value
.trim()
.replace(/\.[^.]+$/, '')
.replace(/-\d+$/, '')
.replace(/\s+/g, '')
.toLowerCase()
}
function getZipPathParts(path: string): string[] {
return path.split('/').filter((part) => part && part !== '__MACOSX' && !part.startsWith('.'))
}
function getPhotoField(fileName: string): PhotoField | null {
const baseName = fileName
.replace(/\.[^.]+$/, '')
.trim()
.toLowerCase()
.replace(/[\s-]+/g, '_')
if (PHOTO_FILE_NAME_MAP[baseName]) {
return PHOTO_FILE_NAME_MAP[baseName]
}
if (/(^|_)me(_image)?$/.test(baseName)) {
return 'meImage'
}
if (/(^|_)(1|work_?1|work_image_1)$/.test(baseName)) {
return 'workImage1'
}
if (/(^|_)(2|work_?2|work_image_2)$/.test(baseName)) {
return 'workImage2'
}
return null
}
function findProfileForZipPath(
pathParts: string[],
classId: string,
profileByName: Map<string, StudentProfileEntity>
): StudentProfileEntity | null {
const classRootIndex = pathParts.findIndex(
(part) => normalizeMatchName(part) === normalizeMatchName(classId)
)
const relativeParts = classRootIndex >= 0 ? pathParts.slice(classRootIndex + 1) : pathParts
const folderParts = relativeParts
.slice(0, -1)
.filter((part) => normalizeMatchName(part) !== 'images')
for (let index = folderParts.length - 1; index >= 0; index -= 1) {
const matchedProfile = profileByName.get(normalizeMatchName(folderParts[index]))
if (matchedProfile) {
return matchedProfile
}
}
return null
}
function sendPhotoImportProgress(progress: PhotoImportProgress): void {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('photos:import-progress', progress)
}
}
function updateProgress(controller: PhotoImportController, progress: PhotoImportProgress): void {
controller.lastProgress = progress
sendPhotoImportProgress(progress)
}
async function waitForResume(controller: PhotoImportController): Promise<void> {
if (!controller.paused || controller.stopped) {
return
}
await new Promise<void>((resolve) => {
controller.resumeWaiters.push(resolve)
})
}
export function pausePhotoImport(batchId: string): boolean {
const controller = photoImportControllers.get(batchId)
if (!controller || controller.stopped) {
return false
}
controller.paused = true
updateProgress(controller, { ...controller.lastProgress, status: 'paused' })
return true
}
export function resumePhotoImport(batchId: string): boolean {
const controller = photoImportControllers.get(batchId)
if (!controller || controller.stopped) {
return false
}
controller.paused = false
const waiters = controller.resumeWaiters.splice(0)
for (const resolve of waiters) {
resolve()
}
updateProgress(controller, { ...controller.lastProgress, status: 'started' })
return true
}
export function stopPhotoImport(batchId: string): boolean {
const controller = photoImportControllers.get(batchId)
if (!controller) {
return false
}
controller.stopped = true
controller.paused = false
const waiters = controller.resumeWaiters.splice(0)
for (const resolve of waiters) {
resolve()
}
updateProgress(controller, { ...controller.lastProgress, status: 'stopped' })
return true
}
export async function importStudentPhotos(input: ImportStudentPhotosInput): Promise<{
updatedProfileCount: number
matchedImageCount: number
skippedImageCount: number
stopped: boolean
}> {
if (!input.batchId || !input.classId || !input.zipPath) {
throw new Error('照片导入参数不完整')
}
if (extname(input.zipPath).toLowerCase() !== '.zip') {
throw new Error('请选择 ZIP 压缩包')
}
const zipStat = await stat(input.zipPath)
if (!zipStat.isFile() || zipStat.size === 0) {
throw new Error('ZIP 压缩包为空')
}
if (zipStat.size > MAX_ZIP_SIZE) {
throw new Error('ZIP 压缩包不能超过 512 MB')
}
const source = await getAppDataSource()
const classRepository = source.getRepository(ClassEntitySchema)
const profileRepository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const classItem = await classRepository.findOneBy({ id: input.classId })
if (!classItem) {
throw new Error('班级不存在')
}
const controller: PhotoImportController = {
paused: false,
stopped: false,
resumeWaiters: [],
lastProgress: {
batchId: input.batchId,
status: 'parsing',
classId: classItem.id,
className: classItem.name,
current: 0,
total: 0,
matchedImageCount: 0,
skippedImageCount: 0
}
}
photoImportControllers.set(input.batchId, controller)
updateProgress(controller, controller.lastProgress)
try {
const profiles = await profileRepository.findBy({ classId: input.classId })
const profileByName = new Map<string, StudentProfileEntity>()
for (const profile of profiles) {
for (const name of [profile.name, profile.englishName, profile.id]) {
const normalizedName = normalizeMatchName(name)
if (normalizedName) {
profileByName.set(normalizedName, profile)
}
}
}
const zipBuffer = await readFile(input.zipPath)
const zip = await JSZip.loadAsync(zipBuffer)
const imageEntries = Object.entries(zip.files).flatMap(([zipPath, entry]) => {
if (entry.dir) {
return []
}
const pathParts = getZipPathParts(zipPath)
const fileName = pathParts.at(-1) ?? ''
const mimeType = IMAGE_EXTENSION_MIME_TYPES[extname(fileName).toLowerCase()]
const photoField = getPhotoField(fileName)
if (!mimeType || !photoField) {
return []
}
return [{ entry, pathParts, photoField }]
})
if (imageEntries.length === 0) {
throw new Error('压缩包内没有可导入的 me.jpg、1.jpg 或 2.jpg 照片')
}
let declaredTotalSize = 0
for (const { entry } of imageEntries) {
const uncompressedSize = (entry as ZipObjectWithSize)._data?.uncompressedSize
if (typeof uncompressedSize === 'number') {
if (uncompressedSize > MAX_IMAGE_SIZE) {
throw new Error(`单张照片不能超过 50 MB${entry.name}`)
}
declaredTotalSize += uncompressedSize
}
}
if (declaredTotalSize > MAX_TOTAL_IMAGE_SIZE) {
throw new Error('压缩包内照片解压后总大小不能超过 2 GB')
}
if (!controller.stopped) {
updateProgress(controller, {
...controller.lastProgress,
status: controller.paused ? 'paused' : 'started',
total: imageEntries.length
})
}
const updatedProfileIds = new Set<string>()
let matchedImageCount = 0
let skippedImageCount = 0
for (const [entryIndex, imageEntry] of imageEntries.entries()) {
await waitForResume(controller)
if (controller.stopped) {
break
}
const matchedProfile = findProfileForZipPath(
imageEntry.pathParts,
input.classId,
profileByName
)
if (!matchedProfile) {
skippedImageCount += 1
} else {
const imageBuffer = await imageEntry.entry.async('nodebuffer')
if (imageBuffer.length > MAX_IMAGE_SIZE) {
throw new Error(`单张照片不能超过 50 MB${imageEntry.entry.name}`)
}
if (controller.stopped) {
break
}
let normalizedImage
try {
normalizedImage = await normalizeImageBuffer(imageBuffer)
} catch (error) {
const message = error instanceof Error ? error.message : '图片格式无法识别'
throw new Error(`无法导入照片 ${imageEntry.entry.name}${message}`)
}
matchedProfile[imageEntry.photoField] = await storeImageBuffer(
'students',
matchedProfile.id,
imageEntry.photoField,
normalizedImage.mimeType,
normalizedImage.buffer
)
await profileRepository.save(matchedProfile)
updatedProfileIds.add(matchedProfile.id)
matchedImageCount += 1
}
updateProgress(controller, {
...controller.lastProgress,
status: 'image-finished',
studentId: matchedProfile?.id,
studentName: matchedProfile?.name,
current: entryIndex + 1,
total: imageEntries.length,
matchedImageCount,
skippedImageCount
})
}
const result = {
updatedProfileCount: updatedProfileIds.size,
matchedImageCount,
skippedImageCount,
stopped: controller.stopped
}
updateProgress(controller, {
...controller.lastProgress,
status: controller.stopped ? 'stopped' : 'finished',
studentId: undefined,
studentName: undefined,
matchedImageCount,
skippedImageCount
})
return result
} finally {
photoImportControllers.delete(input.batchId)
}
}
+8 -3
View File
@@ -25,6 +25,7 @@ import type {
} from '../types/report' } from '../types/report'
import type { ClassTypeConfig } from '../types/settings' import type { ClassTypeConfig } from '../types/settings'
import { getAppDataSource } from './databaseService' import { getAppDataSource } from './databaseService'
import { normalizeImageBuffer } from './imageFormatService'
import { readImageBuffer } from './imageStorageService' import { readImageBuffer } from './imageStorageService'
import { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService' import { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService'
@@ -765,16 +766,20 @@ async function replaceImageIfExists(
} }
try { try {
const normalizedImage = await normalizeImageBuffer(imageBuffer)
await ppt await ppt
.useSlide(replacementPlan.target.slideNumber) .useSlide(replacementPlan.target.slideNumber)
.replaceImage(replacementPlan.target.imageIdentifier, imageBuffer) .replaceImage(replacementPlan.target.imageIdentifier, normalizedImage.buffer)
usedImageKeys.add(replacementPlan.target.imageKey) usedImageKeys.add(replacementPlan.target.imageKey)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '' const message = error instanceof Error ? error.message : ''
if (!message.includes('not found')) { if (message.includes('not found')) {
throw error return
} }
throw new Error(`替换报告图片失败:${replacementPlan.imageValue}${message}`)
} }
} }
+9
View File
@@ -0,0 +1,9 @@
declare module 'heic-convert' {
type HeicConvertOptions = {
buffer: Buffer | ArrayBuffer | Uint8Array
format: 'JPEG' | 'PNG'
quality?: number
}
export default function heicConvert(options: HeicConvertOptions): Promise<ArrayBuffer>
}
+32
View File
@@ -175,6 +175,38 @@ export type TaskControlResponse =
message: string message: string
} }
export type ImportStudentPhotosInput = {
batchId: string
classId: string
zipPath: string
}
export type PhotoImportProgress = {
batchId: string
status: 'parsing' | 'started' | 'image-finished' | 'paused' | 'stopped' | 'finished'
classId: string
className: string
studentId?: string
studentName?: string
current: number
total: number
matchedImageCount: number
skippedImageCount: number
}
export type ImportStudentPhotosResponse =
| {
ok: true
updatedProfileCount: number
matchedImageCount: number
skippedImageCount: number
stopped: boolean
}
| {
ok: false
message: string
}
export type ReplaceClassProfilesResponse = export type ReplaceClassProfilesResponse =
| { | {
ok: true ok: true
+35
View File
@@ -211,6 +211,32 @@ type LoadStudentDataInput = {
includeClassImages?: boolean includeClassImages?: boolean
} }
type PhotoImportProgress = {
batchId: string
status: 'parsing' | 'started' | 'image-finished' | 'paused' | 'stopped' | 'finished'
classId: string
className: string
studentId?: string
studentName?: string
current: number
total: number
matchedImageCount: number
skippedImageCount: number
}
type ImportStudentPhotosResponse =
| {
ok: true
updatedProfileCount: number
matchedImageCount: number
skippedImageCount: number
stopped: boolean
}
| {
ok: false
message: string
}
type ListStudentProfilesResponse = type ListStudentProfilesResponse =
| { | {
ok: true ok: true
@@ -394,6 +420,15 @@ type AppAPI = {
}) => Promise<ListStudentProfilesResponse> }) => Promise<ListStudentProfilesResponse>
getStudentProfile: (profileId: string) => Promise<StudentProfileMutationResponse> getStudentProfile: (profileId: string) => Promise<StudentProfileMutationResponse>
updateStudentProfile: (profile: ChildProfile) => Promise<StudentProfileMutationResponse> updateStudentProfile: (profile: ChildProfile) => Promise<StudentProfileMutationResponse>
importStudentPhotos: (payload: {
batchId: string
classId: string
zipPath: string
}) => Promise<ImportStudentPhotosResponse>
pausePhotoImport: (batchId: string) => Promise<BasicMutationResponse>
resumePhotoImport: (batchId: string) => Promise<BasicMutationResponse>
stopPhotoImport: (batchId: string) => Promise<BasicMutationResponse>
onPhotoImportProgress: (callback: (progress: PhotoImportProgress) => void) => () => void
generateStudentComment: (payload: { generateStudentComment: (payload: {
profileId: string profileId: string
}) => Promise<StudentProfileMutationResponse> }) => Promise<StudentProfileMutationResponse>
+27
View File
@@ -42,6 +42,33 @@ const api = {
workImage2: string workImage2: string
importedAt: string importedAt: string
}) => ipcRenderer.invoke('student:update-profile', profile), }) => ipcRenderer.invoke('student:update-profile', profile),
importStudentPhotos: (payload: { batchId: string; classId: string; zipPath: string }) =>
ipcRenderer.invoke('photos:import', payload),
pausePhotoImport: (batchId: string) => ipcRenderer.invoke('photos:pause-import', batchId),
resumePhotoImport: (batchId: string) => ipcRenderer.invoke('photos:resume-import', batchId),
stopPhotoImport: (batchId: string) => ipcRenderer.invoke('photos:stop-import', batchId),
onPhotoImportProgress: (
callback: (progress: {
batchId: string
status: 'parsing' | 'started' | 'image-finished' | 'paused' | 'stopped' | 'finished'
classId: string
className: string
studentId?: string
studentName?: string
current: number
total: number
matchedImageCount: number
skippedImageCount: number
}) => void
): (() => void) => {
const listener = (
_: Electron.IpcRendererEvent,
progress: Parameters<typeof callback>[0]
): void => callback(progress)
ipcRenderer.on('photos:import-progress', listener)
return () => ipcRenderer.removeListener('photos:import-progress', listener)
},
generateStudentComment: (payload: { profileId: string }) => generateStudentComment: (payload: { profileId: string }) =>
ipcRenderer.invoke('student:generate-comment', payload), ipcRenderer.invoke('student:generate-comment', payload),
generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) => generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) =>
+60 -322
View File
@@ -17,7 +17,6 @@ import {
Upload, Upload,
X X
} from 'lucide-react' } from 'lucide-react'
import JSZip from 'jszip'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter' import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
@@ -96,29 +95,7 @@ type EditingClass = {
familyPhoto?: string familyPhoto?: string
} }
type PhotoImportControl = {
paused: boolean
stopped: boolean
resumeWaiters: Array<() => void>
}
const MAX_CLASS_TEACHERS = 3 const MAX_CLASS_TEACHERS = 3
const PHOTO_FILE_NAME_MAP: Record<string, 'meImage' | 'workImage1' | 'workImage2'> = {
me: 'meImage',
me_image: 'meImage',
'1': 'workImage1',
work_1: 'workImage1',
work_image_1: 'workImage1',
'2': 'workImage2',
work_2: 'workImage2',
work_image_2: 'workImage2'
}
const IMAGE_EXTENSION_MIME_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
}
function normalizeTeacherNames(teacherNames: string[]): string[] { function normalizeTeacherNames(teacherNames: string[]): string[] {
return Array.from( return Array.from(
@@ -130,83 +107,13 @@ function formatTeacherNames(teacherNames: string[]): string {
return normalizeTeacherNames(teacherNames).join(' ') return normalizeTeacherNames(teacherNames).join(' ')
} }
function normalizeMatchName(value: string): string {
return value
.trim()
.replace(/\.[^.]+$/, '')
.replace(/-\d+$/, '')
.replace(/\s+/g, '')
.toLowerCase()
}
function getImageMimeType(fileName: string): string | null {
const extension = fileName.split('.').pop()?.toLowerCase() ?? ''
return IMAGE_EXTENSION_MIME_TYPES[extension] ?? null
}
function getPhotoFieldFromFileName(
fileName: string
): 'meImage' | 'workImage1' | 'workImage2' | null {
const baseName = fileName
.replace(/\.[^.]+$/, '')
.trim()
.toLowerCase()
.replace(/[\s-]+/g, '_')
if (PHOTO_FILE_NAME_MAP[baseName]) {
return PHOTO_FILE_NAME_MAP[baseName]
}
if (/(^|_)me(_image)?$/.test(baseName)) {
return 'meImage'
}
if (/(^|_)(1|work_?1|work_image_1)$/.test(baseName)) {
return 'workImage1'
}
if (/(^|_)(2|work_?2|work_image_2)$/.test(baseName)) {
return 'workImage2'
}
return null
}
function getZipPathParts(path: string): string[] {
return path.split('/').filter((part) => part && part !== '__MACOSX' && !part.startsWith('.'))
}
function findProfileForZipPath(
pathParts: string[],
targetClass: ClassProfile,
profileByName: Map<string, ChildProfile>
): ChildProfile | null {
const classRootIndex = pathParts.findIndex(
(part) => normalizeMatchName(part) === normalizeMatchName(targetClass.id)
)
const relativeParts = classRootIndex >= 0 ? pathParts.slice(classRootIndex + 1) : pathParts
const folderParts = relativeParts
.slice(0, -1)
.filter((part) => normalizeMatchName(part) !== 'images')
for (let index = folderParts.length - 1; index >= 0; index -= 1) {
const matchedProfile = profileByName.get(normalizeMatchName(folderParts[index]))
if (matchedProfile) {
return matchedProfile
}
}
return null
}
export function ClassPage(): React.JSX.Element { export function ClassPage(): React.JSX.Element {
const navigate = useNavigate() const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const photoZipInputRef = useRef<HTMLInputElement>(null) const photoZipInputRef = useRef<HTMLInputElement>(null)
const classPhotoInputRef = useRef<HTMLInputElement>(null) const classPhotoInputRef = useRef<HTMLInputElement>(null)
const editPhotoInputRef = useRef<HTMLInputElement>(null) const editPhotoInputRef = useRef<HTMLInputElement>(null)
const photoImportControlRef = useRef<PhotoImportControl | null>(null) const photoImportControlRef = useRef<string | null>(null)
const [classes, setClasses] = useState<ClassProfile[]>([]) const [classes, setClasses] = useState<ClassProfile[]>([])
const [profiles, setProfiles] = useState<ChildProfile[]>([]) const [profiles, setProfiles] = useState<ChildProfile[]>([])
const [classTypeConfigs, setClassTypeConfigs] = useState<ClassTypeConfig[]>( const [classTypeConfigs, setClassTypeConfigs] = useState<ClassTypeConfig[]>(
@@ -731,46 +638,35 @@ export function ClassPage(): React.JSX.Element {
} }
const toastId = `photo-import-${targetClass.id}` const toastId = `photo-import-${targetClass.id}`
const toastTitle = `正在导入「${targetClass.name}」照片` const batchId = `${toastId}-${createUuid()}`
const importControl: PhotoImportControl = { const zipPath = window.api.getDroppedFilePath(file)
paused: false, photoImportControlRef.current = batchId
stopped: false,
resumeWaiters: []
}
photoImportControlRef.current = importControl showProgressToast({
id: toastId,
icon: Images,
title: `正在导入「${targetClass.name}」照片`,
description: '准备解析 ZIP 文件',
current: 0,
total: 1,
status: 'running',
statusLabel: '准备中'
})
function resumePhotoImport(): void { const unsubscribe = window.api.onPhotoImportProgress((progress) => {
importControl.paused = false if (progress.batchId !== batchId) {
const waiters = importControl.resumeWaiters.splice(0)
for (const resolve of waiters) {
resolve()
}
}
function countUpdateImages(update: Partial<ChildProfile>): number {
return [update.meImage, update.workImage1, update.workImage2].filter(Boolean).length
}
async function waitForPhotoImportResume(): Promise<void> {
if (!importControl.paused || importControl.stopped) {
return return
} }
await new Promise<void>((resolve) => { const isPaused = progress.status === 'paused'
importControl.resumeWaiters.push(resolve) const isStopped = progress.status === 'stopped'
}) const isFinished = progress.status === 'finished'
} const canControl = !isStopped && !isFinished
const total = Math.max(progress.total, 1)
function showPhotoImportProgress(input: { const description =
title?: string progress.status === 'parsing'
description: string ? '正在解析 ZIP 文件'
current: number : `处理照片 · ${progress.current}/${progress.total}`
total: number
}): void {
const isStopped = importControl.stopped
const isPaused = importControl.paused
showProgressToast({ showProgressToast({
id: toastId, id: toastId,
@@ -779,224 +675,64 @@ export function ClassPage(): React.JSX.Element {
? '照片导入已停止' ? '照片导入已停止'
: isPaused : isPaused
? '照片导入已暂停' ? '照片导入已暂停'
: (input.title ?? toastTitle), : `正在导入「${targetClass.name}」照片`,
description: input.description, description,
current: input.current, current: progress.current,
total: input.total, total,
status: isStopped ? 'stopped' : isPaused ? 'paused' : 'running', status: isStopped ? 'stopped' : isPaused ? 'paused' : 'running',
statusLabel: isStopped ? '已停止' : isPaused ? '已暂停' : '导入中', statusLabel: isStopped ? '已停止' : isPaused ? '已暂停' : '导入中',
duration: isStopped ? 5000 : undefined, duration: isStopped ? 5000 : undefined,
actionLabel: isStopped ? undefined : isPaused ? '继续' : '暂停', actionLabel: canControl ? (isPaused ? '继续' : '暂停') : undefined,
onAction: isStopped onAction: canControl
? undefined ? () => {
: () => { void (isPaused
if (importControl.paused) { ? window.api.resumePhotoImport(batchId)
resumePhotoImport() : window.api.pausePhotoImport(batchId))
} else {
importControl.paused = true
} }
: undefined,
showPhotoImportProgress(input) secondaryActionLabel: canControl ? '停止' : undefined,
}, onSecondaryAction: canControl
secondaryActionLabel: isStopped ? undefined : '停止', ? () => {
onSecondaryAction: isStopped void window.api.stopPhotoImport(batchId)
? undefined
: () => {
importControl.stopped = true
resumePhotoImport()
showPhotoImportProgress(input)
} }
: undefined
}) })
}
showPhotoImportProgress({
description: '读取学生数据 · 1/4',
current: 1,
total: 4
}) })
try { try {
const latestStudentData = await window.api.loadStudentData() if (!zipPath) {
throw new Error('无法读取 ZIP 文件路径,请重新选择文件')
}
if (!latestStudentData.ok) { const response = await window.api.importStudentPhotos({
showProgressError(toastId, '读取学生数据失败', latestStudentData.message) batchId,
classId: targetClass.id,
zipPath
})
if (!response.ok) {
showProgressError(toastId, '导入照片 ZIP 失败', response.message)
return return
} }
await waitForPhotoImportResume() if (response.stopped) {
if (importControl.stopped) {
return return
} }
showPhotoImportProgress({ if (response.matchedImageCount === 0) {
description: '解析 ZIP 文件 · 2/4',
current: 2,
total: 4
})
const zip = await JSZip.loadAsync(await file.arrayBuffer())
const latestProfiles = latestStudentData.profiles
const classProfiles = latestProfiles.filter((profile) => profile.classId === targetClass.id)
const profileByName = new Map<string, ChildProfile>()
for (const profile of classProfiles) {
for (const name of [profile.name, profile.englishName, profile.id]) {
const normalizedName = normalizeMatchName(name)
if (normalizedName) {
profileByName.set(normalizedName, profile)
}
}
}
const updatesByProfileId = new Map<string, Partial<ChildProfile>>()
let matchedImageCount = 0
let skippedImageCount = 0
const imageEntries = Object.entries(zip.files).flatMap(([zipPath, entry]) => {
if (entry.dir) {
return []
}
const pathParts = getZipPathParts(zipPath)
const fileName = pathParts.at(-1) ?? ''
const mimeType = getImageMimeType(fileName)
const photoField = getPhotoFieldFromFileName(fileName)
if (!mimeType || !photoField) {
return []
}
return [
{
entry,
pathParts,
mimeType,
photoField
}
]
})
for (const [entryIndex, imageEntry] of imageEntries.entries()) {
await waitForPhotoImportResume()
if (importControl.stopped) {
break
}
const matchedProfile = findProfileForZipPath(
imageEntry.pathParts,
targetClass,
profileByName
)
if (!matchedProfile) {
skippedImageCount += 1
showPhotoImportProgress({
description: `匹配照片 · ${entryIndex + 1}/${Math.max(imageEntries.length, 1)}`,
current: entryIndex + 1,
total: Math.max(imageEntries.length, 1)
})
continue
}
const base64 = await imageEntry.entry.async('base64')
const currentUpdate = updatesByProfileId.get(matchedProfile.id) ?? {}
currentUpdate[imageEntry.photoField] = `data:${imageEntry.mimeType};base64,${base64}`
updatesByProfileId.set(matchedProfile.id, currentUpdate)
matchedImageCount += 1
showPhotoImportProgress({
description: `匹配照片 · ${entryIndex + 1}/${Math.max(imageEntries.length, 1)}`,
current: entryIndex + 1,
total: Math.max(imageEntries.length, 1)
})
}
if (importControl.stopped) {
showPhotoImportProgress({
title: '照片导入已停止',
description: '尚未写入幼儿资料',
current: 0,
total: Math.max(imageEntries.length, 1)
})
return
}
if (updatesByProfileId.size === 0) {
showProgressError( showProgressError(
toastId, toastId,
'没有匹配到可导入照片', '没有匹配到可导入照片',
'请确认 ZIP 内是 班级UUID/images/学生姓名/me.jpg、1.jpg、2.jpg' '请确认 ZIP 内是 班级UUID/学生姓名/me.jpg、1.jpg、2.jpg'
) )
return return
} }
const nextProfiles = latestProfiles.map((profile) => ({
...profile,
...(updatesByProfileId.get(profile.id) ?? {})
}))
const profileIdsToUpdate = Array.from(updatesByProfileId.keys())
const writtenProfileIds = new Set<string>()
for (const [profileIndex, profileId] of profileIdsToUpdate.entries()) {
await waitForPhotoImportResume()
if (importControl.stopped) {
break
}
const nextProfile = nextProfiles.find((profile) => profile.id === profileId)
if (nextProfile) {
const response = await window.api.updateStudentProfile(nextProfile)
if (!response.ok) {
throw new Error(response.message)
}
writtenProfileIds.add(profileId)
}
showPhotoImportProgress({
description: `写入幼儿资料 · ${profileIndex + 1}/${profileIdsToUpdate.length}`,
current: profileIndex + 1,
total: profileIdsToUpdate.length
})
}
const appliedUpdateProfileIds = importControl.stopped
? writtenProfileIds
: new Set(updatesByProfileId.keys())
const appliedImageCount = Array.from(appliedUpdateProfileIds).reduce(
(totalCount, profileId) =>
totalCount + countUpdateImages(updatesByProfileId.get(profileId) ?? {}),
0
)
const appliedProfiles = latestProfiles.map((profile) => ({
...profile,
...(appliedUpdateProfileIds.has(profile.id) ? updatesByProfileId.get(profile.id) : {})
}))
setClasses(latestStudentData.classes)
setProfiles(appliedProfiles)
if (importControl.stopped) {
showPhotoImportProgress({
title: '照片导入已停止',
description: `已写入 ${writtenProfileIds.size} 名幼儿,${appliedImageCount} 张照片`,
current: writtenProfileIds.size,
total: profileIdsToUpdate.length
})
return
}
showProgressSuccess( showProgressSuccess(
toastId, toastId,
'已导入幼儿照片', '已导入幼儿照片',
`更新 ${updatesByProfileId.size} 名幼儿,写入 ${matchedImageCount} 张照片${ `更新 ${response.updatedProfileCount} 名幼儿,写入 ${response.matchedImageCount} 张照片${
skippedImageCount > 0 ? `,跳过 ${skippedImageCount} 张未匹配照片` : '' response.skippedImageCount > 0 ? `,跳过 ${response.skippedImageCount} 张未匹配照片` : ''
}` }`
) )
} catch (error) { } catch (error) {
@@ -1006,7 +742,9 @@ export function ClassPage(): React.JSX.Element {
error instanceof Error ? error.message : '请检查压缩包结构' error instanceof Error ? error.message : '请检查压缩包结构'
) )
} finally { } finally {
if (photoImportControlRef.current === importControl) { unsubscribe()
if (photoImportControlRef.current === batchId) {
photoImportControlRef.current = null photoImportControlRef.current = null
} }