Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07ead3ee73 | |||
| e999f8b606 |
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.
@@ -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",
|
||||
|
||||
Generated
+1468
-1094
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,11 @@ allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- esbuild
|
||||
- lzma-native
|
||||
|
||||
registries:
|
||||
default: https://registry.npmmirror.com/
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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 格式')
|
||||
}
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Vendored
+9
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+36
-1
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
actionLabel: canControl ? (isPaused ? '继续' : '暂停') : undefined,
|
||||
onAction: canControl
|
||||
? () => {
|
||||
void (isPaused
|
||||
? window.api.resumePhotoImport(batchId)
|
||||
: window.api.pausePhotoImport(batchId))
|
||||
}
|
||||
|
||||
showPhotoImportProgress(input)
|
||||
},
|
||||
secondaryActionLabel: isStopped ? undefined : '停止',
|
||||
onSecondaryAction: isStopped
|
||||
? undefined
|
||||
: () => {
|
||||
importControl.stopped = true
|
||||
resumePhotoImport()
|
||||
showPhotoImportProgress(input)
|
||||
: 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 (!zipPath) {
|
||||
throw new Error('无法读取 ZIP 文件路径,请重新选择文件')
|
||||
}
|
||||
|
||||
if (!latestStudentData.ok) {
|
||||
showProgressError(toastId, '读取学生数据失败', latestStudentData.message)
|
||||
const response = await window.api.importStudentPhotos({
|
||||
batchId,
|
||||
classId: targetClass.id,
|
||||
zipPath
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
showProgressError(toastId, '导入照片 ZIP 失败', response.message)
|
||||
return
|
||||
}
|
||||
|
||||
await waitForPhotoImportResume()
|
||||
|
||||
if (importControl.stopped) {
|
||||
if (response.stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
showPhotoImportProgress({
|
||||
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) {
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user