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",
|
"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",
|
||||||
|
|||||||
Generated
+1468
-1094
File diff suppressed because it is too large
Load Diff
@@ -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/
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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'
|
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 ''
|
||||||
|
|||||||
@@ -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'
|
} 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'
|
||||||
|
|
||||||
@@ -128,6 +129,22 @@ function getReportDownloadFileName(report: ReportEntity | ReportItem): string {
|
|||||||
return `${sanitizeFileName(`${report.className}${report.studentName}`)}${report.fileExtension}`
|
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 {
|
function sendReportProgress(progress: ReportGenerationProgress): void {
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
window.webContents.send('reports:generation-progress', progress)
|
window.webContents.send('reports:generation-progress', progress)
|
||||||
@@ -765,16 +782,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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,18 +1149,24 @@ export async function downloadClassReports(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const className = reports[0]?.className || '班级报告'
|
const className = reports[0]?.className || '班级报告'
|
||||||
const result = await dialog.showSaveDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
title: '下载班级报告',
|
title: '选择班级报告导出目录',
|
||||||
defaultPath: `${sanitizeFileName(className)}成长报告.zip`,
|
defaultPath: app.getPath('downloads'),
|
||||||
filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }]
|
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 }
|
return { filePath: '', reportCount: reports.length, canceled: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchId = randomUUID()
|
const batchId = randomUUID()
|
||||||
const zip = new JSZip()
|
const exportPath = await createUniqueExportFolder(
|
||||||
|
parentPath,
|
||||||
|
`${sanitizeFileName(className)}成长报告`
|
||||||
|
)
|
||||||
const usedFileNames = new Map<string, number>()
|
const usedFileNames = new Map<string, number>()
|
||||||
let completedCount = 0
|
let completedCount = 0
|
||||||
|
|
||||||
@@ -1159,7 +1186,7 @@ export async function downloadClassReports(
|
|||||||
status: 'started',
|
status: 'started',
|
||||||
current: 0,
|
current: 0,
|
||||||
total: reports.length,
|
total: reports.length,
|
||||||
filePath: result.filePath
|
filePath: exportPath
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const report of reports) {
|
for (const report of reports) {
|
||||||
@@ -1169,7 +1196,7 @@ export async function downloadClassReports(
|
|||||||
reportTitle: report.title,
|
reportTitle: report.title,
|
||||||
current: completedCount,
|
current: completedCount,
|
||||||
total: reports.length,
|
total: reports.length,
|
||||||
filePath: result.filePath
|
filePath: exportPath
|
||||||
})
|
})
|
||||||
|
|
||||||
await access(report.filePath)
|
await access(report.filePath)
|
||||||
@@ -1181,7 +1208,7 @@ export async function downloadClassReports(
|
|||||||
? baseFileName
|
? baseFileName
|
||||||
: `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}`
|
: `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}`
|
||||||
|
|
||||||
zip.file(fileName, await readFile(report.filePath))
|
await copyFile(report.filePath, join(exportPath, fileName))
|
||||||
completedCount += 1
|
completedCount += 1
|
||||||
|
|
||||||
publishProgress({
|
publishProgress({
|
||||||
@@ -1190,41 +1217,28 @@ export async function downloadClassReports(
|
|||||||
reportTitle: report.title,
|
reportTitle: report.title,
|
||||||
current: completedCount,
|
current: completedCount,
|
||||||
total: reports.length,
|
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({
|
publishProgress({
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
current: reports.length,
|
current: reports.length,
|
||||||
total: reports.length,
|
total: reports.length,
|
||||||
filePath: result.filePath
|
filePath: exportPath
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
publishProgress({
|
publishProgress({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
current: completedCount,
|
current: completedCount,
|
||||||
total: reports.length,
|
total: reports.length,
|
||||||
filePath: result.filePath,
|
filePath: exportPath,
|
||||||
error: error instanceof Error ? error.message : '下载班级报告失败'
|
error: error instanceof Error ? error.message : '导出班级报告失败'
|
||||||
})
|
})
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return { filePath: result.filePath, reportCount: reports.length }
|
return { filePath: exportPath, reportCount: reports.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReport(id: string): Promise<void> {
|
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 = {
|
export type ReportDownloadProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
|
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
reportId?: string
|
reportId?: string
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+36
-1
@@ -108,7 +108,7 @@ type ReportGenerationProgress = {
|
|||||||
|
|
||||||
type ReportDownloadProgress = {
|
type ReportDownloadProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
|
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
reportId?: string
|
reportId?: string
|
||||||
@@ -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>
|
||||||
|
|||||||
+28
-1
@@ -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[] }) =>
|
||||||
@@ -183,7 +210,7 @@ const api = {
|
|||||||
onReportDownloadProgress: (
|
onReportDownloadProgress: (
|
||||||
callback: (progress: {
|
callback: (progress: {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
|
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
reportId?: string
|
reportId?: string
|
||||||
|
|||||||
@@ -160,28 +160,21 @@ export function GenerationProgressToasts(): null {
|
|||||||
const toastId = getToastId('report-downloads', progress.batchId)
|
const toastId = getToastId('report-downloads', progress.batchId)
|
||||||
const isFinished = progress.status === 'finished'
|
const isFinished = progress.status === 'finished'
|
||||||
const isFailed = progress.status === 'failed'
|
const isFailed = progress.status === 'failed'
|
||||||
const isWriting = progress.status === 'writing'
|
|
||||||
const activeReportTitle = progress.reportTitle
|
const activeReportTitle = progress.reportTitle
|
||||||
? `正在打包 ${progress.reportTitle}`
|
? `正在导出 ${progress.reportTitle}`
|
||||||
: '正在打包班级报告'
|
: '正在导出班级报告'
|
||||||
|
|
||||||
showProgressToast({
|
showProgressToast({
|
||||||
id: toastId,
|
id: toastId,
|
||||||
icon: Download,
|
icon: Download,
|
||||||
title: isFinished
|
title: isFinished ? '班级报告导出完成' : isFailed ? '班级报告导出失败' : activeReportTitle,
|
||||||
? '班级报告导出完成'
|
|
||||||
: isFailed
|
|
||||||
? '班级报告导出失败'
|
|
||||||
: isWriting
|
|
||||||
? '正在写入压缩包'
|
|
||||||
: activeReportTitle,
|
|
||||||
description: isFinished
|
description: isFinished
|
||||||
? progress.filePath
|
? progress.filePath
|
||||||
: `${progress.className} · ${progress.current}/${progress.total}`,
|
: `${progress.className} · ${progress.current}/${progress.total}`,
|
||||||
current: progress.current,
|
current: progress.current,
|
||||||
total: progress.total,
|
total: progress.total,
|
||||||
status: isFinished ? 'success' : isFailed ? 'error' : 'running',
|
status: isFinished ? 'success' : isFailed ? 'error' : 'running',
|
||||||
statusLabel: isFinished ? '已完成' : isFailed ? '失败' : isWriting ? '写入中' : '打包中',
|
statusLabel: isFinished ? '已完成' : isFailed ? '失败' : '导出中',
|
||||||
error: progress.error,
|
error: progress.error,
|
||||||
duration: isFinished || isFailed ? 5000 : undefined
|
duration: isFinished || isFailed ? 5000 : undefined
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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[]>(
|
||||||
@@ -694,13 +601,13 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (!response.canceled) {
|
if (!response.canceled) {
|
||||||
showError('下载班级报告失败', response.message)
|
showError('导出班级报告失败', response.message)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showSuccess(
|
showSuccess(
|
||||||
`已下载「${classItem.name}」报告`,
|
`已导出「${classItem.name}」报告`,
|
||||||
`${response.filePath},共 ${response.reportCount} 份`
|
`${response.filePath},共 ${response.reportCount} 份`
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const latestStudentData = await window.api.loadStudentData()
|
if (!zipPath) {
|
||||||
|
throw new Error('无法读取 ZIP 文件路径,请重新选择文件')
|
||||||
if (!latestStudentData.ok) {
|
|
||||||
showProgressError(toastId, '读取学生数据失败', latestStudentData.message)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitForPhotoImportResume()
|
const response = await window.api.importStudentPhotos({
|
||||||
|
batchId,
|
||||||
if (importControl.stopped) {
|
classId: targetClass.id,
|
||||||
return
|
zipPath
|
||||||
}
|
|
||||||
|
|
||||||
showPhotoImportProgress({
|
|
||||||
description: '解析 ZIP 文件 · 2/4',
|
|
||||||
current: 2,
|
|
||||||
total: 4
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const zip = await JSZip.loadAsync(await file.arrayBuffer())
|
if (!response.ok) {
|
||||||
const latestProfiles = latestStudentData.profiles
|
showProgressError(toastId, '导入照片 ZIP 失败', response.message)
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatesByProfileId.size === 0) {
|
if (response.stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.matchedImageCount === 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,8 +895,8 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Download />
|
<Download />
|
||||||
{downloadingReportClassId === classItem.id
|
{downloadingReportClassId === classItem.id
|
||||||
? '报告打包中'
|
? '报告导出中'
|
||||||
: '一键下载班级报告'}
|
: '一键导出班级报告'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export type ReportGenerationProgress = {
|
|||||||
|
|
||||||
export type ReportDownloadProgress = {
|
export type ReportDownloadProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed'
|
status: 'started' | 'file-started' | 'file-finished' | 'finished' | 'failed'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
reportId?: string
|
reportId?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user