Compare commits

2 Commits

Author SHA1 Message Date
hanhan 07ead3ee73 修改班级报告导出方式 2026-07-17 12:51:00 +08:00
hanhan e999f8b606 feat: add bulk photo import and image format handling 2026-07-17 12:43:40 +08:00
24 changed files with 2209 additions and 1467 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",
"electron-log": "^5.4.4",
"electron-updater": "^6.3.9",
"heic-convert": "^2.1.0",
"jszip": "^3.10.1",
"lucide-react": "^1.21.0",
"node-pptx-templater": "^1.1.8",
@@ -59,6 +60,7 @@
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-rebuild": "^3.2.9",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"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
onlyBuiltDependencies:
- better-sqlite3
- electron
- electron-winstaller
- esbuild
- lzma-native
registries:
default: https://registry.npmmirror.com/
+49
View File
@@ -18,6 +18,12 @@ import {
stopCommentGeneration,
updateStudentProfile
} from '../services/studentService'
import {
importStudentPhotos,
pausePhotoImport,
resumePhotoImport,
stopPhotoImport
} from '../services/photoImportService'
import type {
AddClassZodiacsInput,
AddClassZodiacsResponse,
@@ -30,6 +36,8 @@ import type {
GenerateStudentCommentsInput,
GenerateStudentCommentsResponse,
GetStudentProfileResponse,
ImportStudentPhotosInput,
ImportStudentPhotosResponse,
LoadStudentDataInput,
ListStudentProfilesInput,
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(
'student:generate-comment',
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'
}
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,(.+)$/)
if (!match) {
@@ -103,6 +105,23 @@ export async function storeImageValue(
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> {
if (!value) {
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)
}
}
+44 -30
View File
@@ -25,6 +25,7 @@ import type {
} from '../types/report'
import type { ClassTypeConfig } from '../types/settings'
import { getAppDataSource } from './databaseService'
import { normalizeImageBuffer } from './imageFormatService'
import { readImageBuffer } from './imageStorageService'
import { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService'
@@ -128,6 +129,22 @@ function getReportDownloadFileName(report: ReportEntity | ReportItem): string {
return `${sanitizeFileName(`${report.className}${report.studentName}`)}${report.fileExtension}`
}
async function createUniqueExportFolder(parentPath: string, folderName: string): Promise<string> {
for (let suffix = 1; ; suffix += 1) {
const candidateName = suffix === 1 ? folderName : `${folderName} (${suffix})`
const candidatePath = join(parentPath, candidateName)
try {
await mkdir(candidatePath)
return candidatePath
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error
}
}
}
}
function sendReportProgress(progress: ReportGenerationProgress): void {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('reports:generation-progress', progress)
@@ -765,16 +782,20 @@ async function replaceImageIfExists(
}
try {
const normalizedImage = await normalizeImageBuffer(imageBuffer)
await ppt
.useSlide(replacementPlan.target.slideNumber)
.replaceImage(replacementPlan.target.imageIdentifier, imageBuffer)
.replaceImage(replacementPlan.target.imageIdentifier, normalizedImage.buffer)
usedImageKeys.add(replacementPlan.target.imageKey)
} catch (error) {
const message = error instanceof Error ? error.message : ''
if (!message.includes('not found')) {
throw error
if (message.includes('not found')) {
return
}
throw new Error(`替换报告图片失败:${replacementPlan.imageValue}${message}`)
}
}
@@ -1128,18 +1149,24 @@ export async function downloadClassReports(
}
const className = reports[0]?.className || '班级报告'
const result = await dialog.showSaveDialog({
title: '下载班级报告',
defaultPath: `${sanitizeFileName(className)}成长报告.zip`,
filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }]
const result = await dialog.showOpenDialog({
title: '选择班级报告导出目录',
defaultPath: app.getPath('downloads'),
buttonLabel: '选择目录',
properties: ['openDirectory', 'createDirectory']
})
if (result.canceled || !result.filePath) {
const parentPath = result.filePaths[0]
if (result.canceled || !parentPath) {
return { filePath: '', reportCount: reports.length, canceled: true }
}
const batchId = randomUUID()
const zip = new JSZip()
const exportPath = await createUniqueExportFolder(
parentPath,
`${sanitizeFileName(className)}成长报告`
)
const usedFileNames = new Map<string, number>()
let completedCount = 0
@@ -1159,7 +1186,7 @@ export async function downloadClassReports(
status: 'started',
current: 0,
total: reports.length,
filePath: result.filePath
filePath: exportPath
})
for (const report of reports) {
@@ -1169,7 +1196,7 @@ export async function downloadClassReports(
reportTitle: report.title,
current: completedCount,
total: reports.length,
filePath: result.filePath
filePath: exportPath
})
await access(report.filePath)
@@ -1181,7 +1208,7 @@ export async function downloadClassReports(
? baseFileName
: `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}`
zip.file(fileName, await readFile(report.filePath))
await copyFile(report.filePath, join(exportPath, fileName))
completedCount += 1
publishProgress({
@@ -1190,41 +1217,28 @@ export async function downloadClassReports(
reportTitle: report.title,
current: completedCount,
total: reports.length,
filePath: result.filePath
filePath: exportPath
})
}
publishProgress({
status: 'writing',
current: reports.length,
total: reports.length,
filePath: result.filePath
})
const zipBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE'
})
await writeFile(result.filePath, zipBuffer)
publishProgress({
status: 'finished',
current: reports.length,
total: reports.length,
filePath: result.filePath
filePath: exportPath
})
} catch (error) {
publishProgress({
status: 'failed',
current: completedCount,
total: reports.length,
filePath: result.filePath,
error: error instanceof Error ? error.message : '下载班级报告失败'
filePath: exportPath,
error: error instanceof Error ? error.message : '导出班级报告失败'
})
throw error
}
return { filePath: result.filePath, reportCount: reports.length }
return { filePath: exportPath, reportCount: reports.length }
}
export async function deleteReport(id: string): Promise<void> {
+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>
}
+1 -1
View File
@@ -44,7 +44,7 @@ export type ReportGenerationProgress = {
export type ReportDownloadProgress = {
batchId: string
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
classId: string
className: string
reportId?: string
+32
View File
@@ -175,6 +175,38 @@ export type TaskControlResponse =
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 =
| {
ok: true
+36 -1
View File
@@ -108,7 +108,7 @@ type ReportGenerationProgress = {
type ReportDownloadProgress = {
batchId: string
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
classId: string
className: string
reportId?: string
@@ -211,6 +211,32 @@ type LoadStudentDataInput = {
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 =
| {
ok: true
@@ -394,6 +420,15 @@ type AppAPI = {
}) => Promise<ListStudentProfilesResponse>
getStudentProfile: (profileId: string) => 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: {
profileId: string
}) => Promise<StudentProfileMutationResponse>
+28 -1
View File
@@ -42,6 +42,33 @@ const api = {
workImage2: string
importedAt: string
}) => 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 }) =>
ipcRenderer.invoke('student:generate-comment', payload),
generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) =>
@@ -183,7 +210,7 @@ const api = {
onReportDownloadProgress: (
callback: (progress: {
batchId: string
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
classId: string
className: string
reportId?: string
@@ -160,28 +160,21 @@ export function GenerationProgressToasts(): null {
const toastId = getToastId('report-downloads', progress.batchId)
const isFinished = progress.status === 'finished'
const isFailed = progress.status === 'failed'
const isWriting = progress.status === 'writing'
const activeReportTitle = progress.reportTitle
? `正在打包 ${progress.reportTitle}`
: '正在打包班级报告'
? `正在导出 ${progress.reportTitle}`
: '正在导出班级报告'
showProgressToast({
id: toastId,
icon: Download,
title: isFinished
? '班级报告导出完成'
: isFailed
? '班级报告导出失败'
: isWriting
? '正在写入压缩包'
: activeReportTitle,
title: isFinished ? '班级报告导出完成' : isFailed ? '班级报告导出失败' : activeReportTitle,
description: isFinished
? progress.filePath
: `${progress.className} · ${progress.current}/${progress.total}`,
current: progress.current,
total: progress.total,
status: isFinished ? 'success' : isFailed ? 'error' : 'running',
statusLabel: isFinished ? '已完成' : isFailed ? '失败' : isWriting ? '写入中' : '打包中',
statusLabel: isFinished ? '已完成' : isFailed ? '失败' : '导出中',
error: progress.error,
duration: isFinished || isFailed ? 5000 : undefined
})
+65 -327
View File
@@ -17,7 +17,6 @@ import {
Upload,
X
} from 'lucide-react'
import JSZip from 'jszip'
import { useNavigate } from 'react-router-dom'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
@@ -96,29 +95,7 @@ type EditingClass = {
familyPhoto?: string
}
type PhotoImportControl = {
paused: boolean
stopped: boolean
resumeWaiters: Array<() => void>
}
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[] {
return Array.from(
@@ -130,83 +107,13 @@ function formatTeacherNames(teacherNames: string[]): string {
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 {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const photoZipInputRef = useRef<HTMLInputElement>(null)
const classPhotoInputRef = 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 [profiles, setProfiles] = useState<ChildProfile[]>([])
const [classTypeConfigs, setClassTypeConfigs] = useState<ClassTypeConfig[]>(
@@ -694,13 +601,13 @@ export function ClassPage(): React.JSX.Element {
if (!response.ok) {
if (!response.canceled) {
showError('下载班级报告失败', response.message)
showError('导出班级报告失败', response.message)
}
return
}
showSuccess(
`下载${classItem.name}」报告`,
`导出${classItem.name}」报告`,
`${response.filePath},共 ${response.reportCount}`
)
} finally {
@@ -731,46 +638,35 @@ export function ClassPage(): React.JSX.Element {
}
const toastId = `photo-import-${targetClass.id}`
const toastTitle = `正在导入「${targetClass.name}」照片`
const importControl: PhotoImportControl = {
paused: false,
stopped: false,
resumeWaiters: []
}
const batchId = `${toastId}-${createUuid()}`
const zipPath = window.api.getDroppedFilePath(file)
photoImportControlRef.current = batchId
photoImportControlRef.current = importControl
showProgressToast({
id: toastId,
icon: Images,
title: `正在导入「${targetClass.name}」照片`,
description: '准备解析 ZIP 文件',
current: 0,
total: 1,
status: 'running',
statusLabel: '准备中'
})
function resumePhotoImport(): void {
importControl.paused = false
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) {
const unsubscribe = window.api.onPhotoImportProgress((progress) => {
if (progress.batchId !== batchId) {
return
}
await new Promise<void>((resolve) => {
importControl.resumeWaiters.push(resolve)
})
}
function showPhotoImportProgress(input: {
title?: string
description: string
current: number
total: number
}): void {
const isStopped = importControl.stopped
const isPaused = importControl.paused
const isPaused = progress.status === 'paused'
const isStopped = progress.status === 'stopped'
const isFinished = progress.status === 'finished'
const canControl = !isStopped && !isFinished
const total = Math.max(progress.total, 1)
const description =
progress.status === 'parsing'
? '正在解析 ZIP 文件'
: `处理照片 · ${progress.current}/${progress.total}`
showProgressToast({
id: toastId,
@@ -779,224 +675,64 @@ export function ClassPage(): React.JSX.Element {
? '照片导入已停止'
: isPaused
? '照片导入已暂停'
: (input.title ?? toastTitle),
description: input.description,
current: input.current,
total: input.total,
: `正在导入「${targetClass.name}」照片`,
description,
current: progress.current,
total,
status: isStopped ? 'stopped' : isPaused ? 'paused' : 'running',
statusLabel: isStopped ? '已停止' : isPaused ? '已暂停' : '导入中',
duration: isStopped ? 5000 : undefined,
actionLabel: isStopped ? undefined : isPaused ? '继续' : '暂停',
onAction: isStopped
? undefined
: () => {
if (importControl.paused) {
resumePhotoImport()
} else {
importControl.paused = true
}
showPhotoImportProgress(input)
},
secondaryActionLabel: isStopped ? undefined : '停止',
onSecondaryAction: isStopped
? undefined
: () => {
importControl.stopped = true
resumePhotoImport()
showPhotoImportProgress(input)
actionLabel: canControl ? (isPaused ? '继续' : '暂停') : undefined,
onAction: canControl
? () => {
void (isPaused
? window.api.resumePhotoImport(batchId)
: window.api.pausePhotoImport(batchId))
}
: undefined,
secondaryActionLabel: canControl ? '停止' : undefined,
onSecondaryAction: canControl
? () => {
void window.api.stopPhotoImport(batchId)
}
: undefined
})
}
showPhotoImportProgress({
description: '读取学生数据 · 1/4',
current: 1,
total: 4
})
try {
const latestStudentData = await window.api.loadStudentData()
if (!latestStudentData.ok) {
showProgressError(toastId, '读取学生数据失败', latestStudentData.message)
return
if (!zipPath) {
throw new Error('无法读取 ZIP 文件路径,请重新选择文件')
}
await waitForPhotoImportResume()
if (importControl.stopped) {
return
}
showPhotoImportProgress({
description: '解析 ZIP 文件 · 2/4',
current: 2,
total: 4
const response = await window.api.importStudentPhotos({
batchId,
classId: targetClass.id,
zipPath
})
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)
})
if (!response.ok) {
showProgressError(toastId, '导入照片 ZIP 失败', response.message)
return
}
if (updatesByProfileId.size === 0) {
if (response.stopped) {
return
}
if (response.matchedImageCount === 0) {
showProgressError(
toastId,
'没有匹配到可导入照片',
'请确认 ZIP 内是 班级UUID/images/学生姓名/me.jpg、1.jpg、2.jpg'
'请确认 ZIP 内是 班级UUID/学生姓名/me.jpg、1.jpg、2.jpg'
)
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(
toastId,
'已导入幼儿照片',
`更新 ${updatesByProfileId.size} 名幼儿,写入 ${matchedImageCount} 张照片${
skippedImageCount > 0 ? `,跳过 ${skippedImageCount} 张未匹配照片` : ''
`更新 ${response.updatedProfileCount} 名幼儿,写入 ${response.matchedImageCount} 张照片${
response.skippedImageCount > 0 ? `,跳过 ${response.skippedImageCount} 张未匹配照片` : ''
}`
)
} catch (error) {
@@ -1006,7 +742,9 @@ export function ClassPage(): React.JSX.Element {
error instanceof Error ? error.message : '请检查压缩包结构'
)
} finally {
if (photoImportControlRef.current === importControl) {
unsubscribe()
if (photoImportControlRef.current === batchId) {
photoImportControlRef.current = null
}
@@ -1157,8 +895,8 @@ export function ClassPage(): React.JSX.Element {
>
<Download />
{downloadingReportClassId === classItem.id
? '报告打包中'
: '一键下载班级报告'}
? '报告导出中'
: '一键导出班级报告'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+1 -1
View File
@@ -128,7 +128,7 @@ export type ReportGenerationProgress = {
export type ReportDownloadProgress = {
batchId: string
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
classId: string
className: string
reportId?: string