From df3b6231449209e4a3ab8842b4e93c064d6257d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E5=AF=92?= <2596194220@qq.com> Date: Sat, 27 Jun 2026 01:59:31 +0800 Subject: [PATCH] feat: improve report and comment workflows --- .codex-logs/pptx-templater-poc/blue.png | Bin 0 -> 70 bytes .codex-logs/pptx-templater-poc/gray.png | Bin 0 -> 69 bytes .codex-logs/pptx-templater-poc/green.png | Bin 0 -> 70 bytes .codex-logs/pptx-templater-poc/output.pptx | Bin 0 -> 5460 bytes .codex-logs/pptx-templater-poc/red.png | Bin 0 -> 70 bytes .../pptx-templater-poc/template-alt.pptx | Bin 0 -> 8421 bytes .codex-logs/pptx-templater-poc/template.pptx | Bin 0 -> 4899 bytes .idea/prettier.xml | 3 +- scripts/dev-api-runner.mjs | 88 ++++ src/main/ipc/reportIpc.ts | 53 ++- src/main/ipc/studentIpc.ts | 47 +- src/main/services/reportService.ts | 392 +++++------------ src/main/services/studentService.ts | 162 ++++++- src/main/types/report.ts | 10 + src/main/types/student.ts | 29 ++ src/preload/index.d.ts | 44 +- src/preload/index.ts | 59 ++- src/renderer/src/App.tsx | 2 +- .../src/components/layout/AppSidebar.tsx | 20 - src/renderer/src/pages/ClassPage.tsx | 147 ++++++- src/renderer/src/pages/ReportPage.tsx | 400 ++++++++++++------ src/renderer/src/pages/StudentPage.tsx | 125 +++++- src/renderer/src/pages/TemplatePage.tsx | 2 - src/renderer/src/student/menu.ts | 24 +- src/renderer/src/types/app.ts | 13 + tsconfig.server.json | 8 + 26 files changed, 1068 insertions(+), 560 deletions(-) create mode 100644 .codex-logs/pptx-templater-poc/blue.png create mode 100644 .codex-logs/pptx-templater-poc/gray.png create mode 100644 .codex-logs/pptx-templater-poc/green.png create mode 100644 .codex-logs/pptx-templater-poc/output.pptx create mode 100644 .codex-logs/pptx-templater-poc/red.png create mode 100644 .codex-logs/pptx-templater-poc/template-alt.pptx create mode 100644 .codex-logs/pptx-templater-poc/template.pptx create mode 100644 scripts/dev-api-runner.mjs create mode 100644 tsconfig.server.json diff --git a/.codex-logs/pptx-templater-poc/blue.png b/.codex-logs/pptx-templater-poc/blue.png new file mode 100644 index 0000000000000000000000000000000000000000..58c91e38e204048ccf20e7b9212afc5b7ad496fc GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2rrxHyJX+)7SK`0-z!ftiW%KciI8 QO`rsWr>mdKI;Vst0M{W99RL6T literal 0 HcmV?d00001 diff --git a/.codex-logs/pptx-templater-poc/gray.png b/.codex-logs/pptx-templater-poc/gray.png new file mode 100644 index 0000000000000000000000000000000000000000..fe59efc529c82078e3c975d4a1a626b92cc33a49 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2rrxHyJX+)Cb4_Ew64^&itewh6tx PKye07S3j3^P6FVdQ&MBb@0M6KnSQ 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 literal 0 HcmV?d00001 diff --git a/.codex-logs/pptx-templater-poc/red.png b/.codex-logs/pptx-templater-poc/red.png new file mode 100644 index 0000000000000000000000000000000000000000..556aa0aab949c7b3d994bc1dd65fe4498df8079e GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2rrxHyJX+)Dm){y+df18Xx={gkr_ QPk|B)p00i_>zopr0P$%NkpKVy literal 0 HcmV?d00001 diff --git a/.codex-logs/pptx-templater-poc/template-alt.pptx b/.codex-logs/pptx-templater-poc/template-alt.pptx new file mode 100644 index 0000000000000000000000000000000000000000..b43ff821a5e5944543b86b474ccb99ba6073a3f6 GIT binary patch literal 8421 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 - \ No newline at end of file diff --git a/scripts/dev-api-runner.mjs b/scripts/dev-api-runner.mjs new file mode 100644 index 0000000..852b389 --- /dev/null +++ b/scripts/dev-api-runner.mjs @@ -0,0 +1,88 @@ +import { spawn } from 'node:child_process' +import { watch } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +const require = createRequire(import.meta.url) +const electronPath = require('electron') + +let child = null +let stopping = false +let restartTimer = null + +function start() { + child = spawn(electronPath, ['--import', 'tsx', 'src/server/index.ts'], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + GROWTH_REPORT_USER_DATA: process.env.GROWTH_REPORT_USER_DATA || '.electron-dev-data' + }, + stdio: 'inherit', + windowsHide: true + }) + + child.once('exit', (code, signal) => { + child = null + + if (stopping) { + process.exit(0) + } + + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(code ?? 0) + }) +} + +function stop(signal = 'SIGTERM') { + if (child && !child.killed) { + child.kill(signal) + } +} + +function scheduleRestart() { + if (stopping) { + return + } + + clearTimeout(restartTimer) + restartTimer = setTimeout(() => { + if (!child) { + start() + return + } + + const currentChild = child + currentChild.once('exit', () => { + if (!stopping) { + start() + } + }) + currentChild.kill('SIGTERM') + }, 150) +} + +function shutdown(signal = 'SIGTERM') { + if (stopping) { + return + } + + stopping = true + clearTimeout(restartTimer) + stop(signal) +} + +start() + +const watchedRoots = ['src/server', 'src/main/services', 'src/main/entities', 'src/main/types'] + +for (const root of watchedRoots) { + watch(resolve(root), { recursive: true }, scheduleRestart) +} + +process.once('SIGINT', () => shutdown('SIGINT')) +process.once('SIGTERM', () => shutdown('SIGTERM')) +process.once('exit', () => stop()) diff --git a/src/main/ipc/reportIpc.ts b/src/main/ipc/reportIpc.ts index 261fdd0..33061da 100644 --- a/src/main/ipc/reportIpc.ts +++ b/src/main/ipc/reportIpc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { + deleteAllReports, deleteReport, downloadClassReports, downloadReport, @@ -9,6 +10,7 @@ import { openReport } from '../services/reportService' import type { + DeleteAllReportsResponse, DeleteReportResponse, DownloadClassReportsInput, DownloadClassReportsResponse, @@ -69,32 +71,29 @@ export function registerReportIpc(): void { } }) - ipcMain.handle( - 'reports:download', - async (_, id: string): Promise => { - try { - const result = await downloadReport(id) + ipcMain.handle('reports:download', async (_, id: string): Promise => { + try { + const result = await downloadReport(id) - if (result.canceled) { - return { - ok: false, - canceled: true, - message: '已取消下载' - } - } - - return { - ok: true, - filePath: result.filePath - } - } catch (error) { + if (result.canceled) { return { ok: false, - message: getErrorMessage(error) + canceled: true, + message: '已取消下载' } } + + return { + ok: true, + filePath: result.filePath + } + } catch (error) { + return { + ok: false, + message: getErrorMessage(error) + } } - ) + }) ipcMain.handle( 'reports:download-class', @@ -137,4 +136,18 @@ export function registerReportIpc(): void { } } }) + + ipcMain.handle('reports:delete-all', async (): Promise => { + try { + return { + ok: true, + deletedCount: await deleteAllReports() + } + } catch (error) { + return { + ok: false, + message: getErrorMessage(error) + } + } + }) } diff --git a/src/main/ipc/studentIpc.ts b/src/main/ipc/studentIpc.ts index 366d092..23891b8 100644 --- a/src/main/ipc/studentIpc.ts +++ b/src/main/ipc/studentIpc.ts @@ -5,6 +5,7 @@ import { deleteStudentProfile, getStudentProfile, generateStudentComment, + generateStudentComments, listStudentProfiles, loadStudentData, migrateStoredImagesToFiles, @@ -20,6 +21,8 @@ import type { DeleteStudentProfileResponse, GenerateStudentCommentInput, GenerateStudentCommentResponse, + GenerateStudentCommentsInput, + GenerateStudentCommentsResponse, GetStudentProfileResponse, LoadStudentDataInput, ListStudentProfilesInput, @@ -40,20 +43,23 @@ export function registerStudentIpc(): void { console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error) }) - ipcMain.handle('student:load', async (_, payload?: LoadStudentDataInput): Promise => { - try { - const studentData = await loadStudentData(payload) - return { - ok: true, - ...studentData - } - } catch (error) { - return { - ok: false, - message: getErrorMessage(error) + ipcMain.handle( + 'student:load', + async (_, payload?: LoadStudentDataInput): Promise => { + try { + const studentData = await loadStudentData(payload) + return { + ok: true, + ...studentData + } + } catch (error) { + return { + ok: false, + message: getErrorMessage(error) + } } } - }) + ) ipcMain.handle( 'student:list', @@ -123,6 +129,23 @@ export function registerStudentIpc(): void { } ) + ipcMain.handle( + 'student:generate-comments', + async (_, payload: GenerateStudentCommentsInput): Promise => { + try { + return { + ok: true, + ...(await generateStudentComments(payload)) + } + } catch (error) { + return { + ok: false, + message: getErrorMessage(error) + } + } + } + ) + ipcMain.handle( 'student:save-classes', async (_, classes: ClassProfile[]): Promise => { diff --git a/src/main/services/reportService.ts b/src/main/services/reportService.ts index 4e7a9c1..39cc697 100644 --- a/src/main/services/reportService.ts +++ b/src/main/services/reportService.ts @@ -6,7 +6,7 @@ import 'reflect-metadata' import { app, BrowserWindow, dialog, shell } from 'electron' import JSZip from 'jszip' import { PPTXTemplater } from 'node-pptx-templater' -import type { Repository } from 'typeorm' +import { In, type Repository } from 'typeorm' import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity' import { ReportEntitySchema, type ReportEntity } from '../entities/ReportEntity' @@ -24,11 +24,9 @@ import type { } from '../types/report' import { getAppDataSource } from './databaseService' import { readImageBuffer } from './imageStorageService' -import { getLogger } from './loggerService' const REPORT_FOLDER_NAME = 'reports' const TEMPLATE_FOLDER_NAME = 'templates' -const logger = getLogger('report') type ReportStudentData = { id: string @@ -48,15 +46,16 @@ type ReportStudentData = { game: string favoriteFoods: string food: string - meImage: string - workImage1: string - workImage2: string traits: string comment: string comments: string teacherName: string } +type StudentProfileWithAliases = StudentProfileEntity & { + studentName?: string +} + function getReportStoragePath(): string { return join(app.getPath('userData'), REPORT_FOLDER_NAME) } @@ -157,6 +156,12 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +function getStudentName(student: StudentProfileEntity): string { + const profile = student as StudentProfileWithAliases + + return profile.studentName || profile.name || profile.englishName || '' +} + function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData { const friends = parseJsonList(student.friends).join('、') || ' ' const hobbies = parseJsonList(student.hobbies).join('、') || ' ' @@ -169,7 +174,7 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): id: student.id, classId: student.classId, className: student.className, - name: student.name || '未填写', + name: getStudentName(student) || '未填写', englishName: student.englishName || ' ', sex: student.gender || '男', gender: student.gender || '男', @@ -183,9 +188,6 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): game: favoriteGames, favoriteFoods, food: favoriteFoods, - meImage: student.meImage || ' ', - workImage1: student.workImage1 || ' ', - workImage2: student.workImage2 || ' ', traits: student.traits || ' ', comment: student.comment || '暂无评语', comments: student.comment || '暂无评语', @@ -210,7 +212,10 @@ function parseTeacherNames(value: string): string[] { return value.trim() ? [value.trim()] : [] } -function buildTextReplacements(studentData: ReportStudentData, classItem: ClassEntity): Record { +function buildTextReplacements( + studentData: ReportStudentData, + classItem: ClassEntity +): Record { return { name: studentData.name, class: classItem.name, @@ -233,9 +238,7 @@ function buildTextReplacements(studentData: ReportStudentData, classItem: ClassE game: studentData.game, favoriteFoods: studentData.favoriteFoods, food: studentData.food, - me_image: studentData.meImage, - work_image_1: studentData.workImage1, - work_image_2: studentData.workImage2 + traits: studentData.traits } } @@ -300,8 +303,6 @@ type PptImageReplacementTarget = { } type PptImageReplacementPlan = { - placeholderName: string - fieldKey: string imageValue?: string | null target: PptImageReplacementTarget } @@ -370,56 +371,6 @@ function getImageIdentifier(image: PptImageInfo, fallback: string): string { return String(image.id ?? image.name ?? image.relationshipId ?? fallback) } -function describeImageValue(value?: string | null): Record { - if (!value) { - return { exists: false } - } - - return { - exists: true, - source: value.startsWith('data:image/') ? 'data-url' : 'file-path', - length: value.length, - preview: value.startsWith('data:image/') ? value.slice(0, 40) : value - } -} - -function describePosition(position?: PptPosition | null): Record | null { - if (!position) { - return null - } - - return { - x: position.x, - y: position.y, - cx: position.cx, - cy: position.cy - } -} - -function logSlideObjects(ppt: PPTXTemplater, traceId: string): void { - for (let slideNumber = 1; slideNumber <= ppt.slideCount; slideNumber += 1) { - const images = getSlideImages(ppt, slideNumber).map((image) => ({ - id: image.id, - name: image.name, - relationshipId: image.relationshipId, - targetPath: image.targetPath, - position: describePosition(image.position) - })) - const shapes = getSlideShapes(ppt, slideNumber).map((shape) => ({ - id: shape.id, - name: shape.name, - position: describePosition(shape.position) - })) - - logger.debug(`[${traceId}] PPTX 第 ${slideNumber} 页对象扫描`, { - imageCount: images.length, - images, - shapeCount: shapes.length, - shapes - }) - } -} - function getSlidePaths(zip: JSZip): string[] { return Object.keys(zip.files) .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)) @@ -468,7 +419,11 @@ function replaceTextNodeValue( return `${match[1]}${nextValue}${match[3]}` } -function replaceShapeTextPreservingStyle(slideXml: string, shapeName: string, text: string): string { +function replaceShapeTextPreservingStyle( + slideXml: string, + shapeName: string, + text: string +): string { return slideXml.replace(//g, (shapeXml: string) => { if (!hasShapeName(shapeXml, shapeName)) { return shapeXml @@ -515,6 +470,24 @@ function replaceInlineTextPlaceholderPreservingStyle( ) } +function replaceExactTextPlaceholderPreservingStyle( + slideXml: string, + placeholder: string, + text: string +): string { + const normalizedPlaceholder = normalizePptObjectName(placeholder) + + return slideXml.replace(/]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) => + replaceTextNodeValue(textNode, (value) => { + if (normalizePptObjectName(decodeXml(value)) !== normalizedPlaceholder) { + return null + } + + return escapeXml(text) + }) + ) +} + function getTextNodeValue(textNode: string): string { return textNode.match(/^]*)?>([\s\S]*?)<\/a:t>$/)?.[1] ?? '' } @@ -534,10 +507,7 @@ function replaceShapeInlinePlaceholdersPreservingStyle( let nextCombinedText = combinedText for (const [placeholder, text] of Object.entries(replacements)) { - const placeholderPattern = new RegExp( - `\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`, - 'g' - ) + const placeholderPattern = new RegExp(`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`, 'g') nextCombinedText = nextCombinedText.replace(placeholderPattern, escapeXml(text)) } @@ -560,12 +530,10 @@ function replaceShapeInlinePlaceholdersPreservingStyle( async function replacePptxTextPreservingStyle( filePath: string, - replacements: Record, - traceId: string + replacements: Record ): Promise { const fileBuffer = await readFile(filePath) const zip = await JSZip.loadAsync(fileBuffer) - let replacementCount = 0 for (const slidePath of getSlidePaths(zip)) { const slideFile = zip.file(slidePath) @@ -580,11 +548,11 @@ async function replacePptxTextPreservingStyle( for (const [placeholder, text] of Object.entries(replacements)) { slideXml = replaceInlineTextPlaceholderPreservingStyle(slideXml, placeholder, text) slideXml = replaceShapeTextPreservingStyle(slideXml, placeholder, text) + slideXml = replaceExactTextPlaceholderPreservingStyle(slideXml, placeholder, text) } slideXml = replaceShapeInlinePlaceholdersPreservingStyle(slideXml, replacements) if (slideXml !== originalSlideXml) { - replacementCount += 1 zip.file(slidePath, slideXml) } } @@ -594,19 +562,13 @@ async function replacePptxTextPreservingStyle( compression: 'DEFLATE' }) await writeFile(filePath, outputBuffer) - - logger.debug(`[${traceId}] 保留样式的 PPTX 文本替换完成`, { - touchedSlideCount: replacementCount, - replacementKeys: Object.keys(replacements) - }) } function findImageReplacementTargets( ppt: PPTXTemplater, slideNumber: number, placeholderName: string, - usedImageKeys: Set, - traceId: string + usedImageKeys: Set ): PptImageReplacementTarget[] { const normalizedPlaceholderName = normalizePptObjectName(placeholderName) const slideImages = getSlideImages(ppt, slideNumber) @@ -626,11 +588,6 @@ function findImageReplacementTargets( .filter((target) => !usedImageKeys.has(target.imageKey)) if (directMatchingTargets.length > 0) { - logger.debug(`[${traceId}] 图片占位符直接命中图片对象`, { - slideNumber, - placeholderName, - targets: directMatchingTargets - }) return directMatchingTargets } @@ -664,92 +621,42 @@ function findImageReplacementTargets( return first.centerDistance - second.centerDistance }) - logger.debug(`[${traceId}] 图片占位符按位置匹配图片对象`, { - slideNumber, - placeholderName, - placeholderShapes: placeholderShapes.map((shape) => ({ - id: shape.id, - name: shape.name, - position: describePosition(shape.position) - })), - candidateCount: fallbackTargets.length, - candidates: fallbackTargets.slice(0, 8) - }) - return fallbackTargets } async function replaceImageIfExists( ppt: PPTXTemplater, replacementPlan: PptImageReplacementPlan, - usedImageKeys: Set, - traceId: string + usedImageKeys: Set ): Promise { const imageBuffer = await readImageBuffer(replacementPlan.imageValue) if (!imageBuffer) { - logger.warn(`[${traceId}] 图片替换跳过:没有可读取的图片数据`, { - placeholderName: replacementPlan.placeholderName, - fieldKey: replacementPlan.fieldKey, - imageValue: describeImageValue(replacementPlan.imageValue) - }) return } if (usedImageKeys.has(replacementPlan.target.imageKey)) { - logger.warn(`[${traceId}] 图片替换跳过:目标图片对象已经替换过`, { - placeholderName: replacementPlan.placeholderName, - fieldKey: replacementPlan.fieldKey, - target: replacementPlan.target - }) return } try { - logger.debug(`[${traceId}] 开始使用 node-pptx-templater.replaceImage 替换图片`, { - placeholderName: replacementPlan.placeholderName, - fieldKey: replacementPlan.fieldKey, - imageValue: describeImageValue(replacementPlan.imageValue), - bufferBytes: imageBuffer.length, - target: replacementPlan.target - }) - await ppt .useSlide(replacementPlan.target.slideNumber) .replaceImage(replacementPlan.target.imageIdentifier, imageBuffer) usedImageKeys.add(replacementPlan.target.imageKey) - logger.info(`[${traceId}] node-pptx-templater.replaceImage 图片替换成功`, { - placeholderName: replacementPlan.placeholderName, - fieldKey: replacementPlan.fieldKey, - target: replacementPlan.target - }) } catch (error) { const message = error instanceof Error ? error.message : '' if (!message.includes('not found')) { - logger.error(`[${traceId}] node-pptx-templater.replaceImage 图片替换失败`, { - placeholderName: replacementPlan.placeholderName, - fieldKey: replacementPlan.fieldKey, - target: replacementPlan.target, - error - }) throw error } - - logger.warn(`[${traceId}] 图片替换跳过:目标在当前页未找到`, { - placeholderName: replacementPlan.placeholderName, - fieldKey: replacementPlan.fieldKey, - target: replacementPlan.target, - message - }) } } function buildImageReplacementPlans( ppt: PPTXTemplater, replacements: Record, - getImageValue: (fieldKey: string) => string | null | undefined, - traceId: string + getImageValue: (fieldKey: string) => string | null | undefined ): PptImageReplacementPlan[] { const plans: PptImageReplacementPlan[] = [] const usedImageKeys = new Set() @@ -758,17 +665,7 @@ function buildImageReplacementPlans( for (const [placeholderName, fieldKey] of Object.entries(replacements)) { const imageValue = getImageValue(fieldKey) - logger.debug(`[${traceId}] 开始规划图片占位符`, { - placeholderName, - fieldKey, - imageValue: describeImageValue(imageValue) - }) - if (!imageValue) { - logger.warn(`[${traceId}] 图片占位符跳过:字段没有图片`, { - placeholderName, - fieldKey - }) continue } @@ -783,16 +680,10 @@ function buildImageReplacementPlans( ppt, slideNumber, placeholderName, - usedImageKeys, - traceId + usedImageKeys )[0] if (!target) { - logger.debug(`[${traceId}] 当前页没有找到可替换图片目标`, { - slideNumber, - placeholderName, - fieldKey - }) continue } @@ -801,50 +692,14 @@ function buildImageReplacementPlans( // 如果边扫描边替换,后续 getImages() 看到的 targetPath 可能已经变了, // 容易让第二个占位符匹配到刚被第一个占位符替换过的图片。 plans.push({ - placeholderName, - fieldKey, imageValue, target }) usedImageKeys.add(target.imageKey) usedFieldSlideKeys.add(fieldSlideKey) - logger.info(`[${traceId}] 图片替换计划已生成`, { - placeholderName, - fieldKey, - slideNumber, - target - }) } } - logger.info(`[${traceId}] 图片替换计划汇总`, { - planCount: plans.length, - plans: plans.map((plan) => ({ - placeholderName: plan.placeholderName, - fieldKey: plan.fieldKey, - imageValue: describeImageValue(plan.imageValue), - target: plan.target - })) - }) - - const relationshipKeyCounts = new Map() - - for (const plan of plans) { - const relationshipKey = plan.target.imageKey.split('|').slice(3).join('|') - relationshipKeyCounts.set(relationshipKey, (relationshipKeyCounts.get(relationshipKey) ?? 0) + 1) - } - - const sharedRelationshipKeys = Array.from(relationshipKeyCounts.entries()).filter( - ([, count]) => count > 1 - ) - - if (sharedRelationshipKeys.length > 0) { - logger.warn( - `[${traceId}] 图片替换计划发现共享 relationship,请确认模板中的占位图片不是复制同一张图片资源`, - { sharedRelationshipKeys } - ) - } - return plans } @@ -852,20 +707,9 @@ async function buildPptxReport( template: TemplateEntity, outputPath: string, student: StudentProfileEntity, - classItem: ClassEntity, - traceId: string + classItem: ClassEntity ): Promise { const templateFilePath = resolveTemplateFilePath(template.filePath) - logger.info(`[${traceId}] 开始加载 PPTX 模板`, { - templateId: template.id, - templateName: template.name, - templateFilePath, - outputPath, - studentId: student.id, - studentName: student.name, - classId: classItem.id, - className: classItem.name - }) const ppt = await PPTXTemplater.load(templateFilePath, { logLevel: 'silent' @@ -874,33 +718,21 @@ async function buildPptxReport( const textReplacements = buildTextReplacements(studentData, classItem) const replacedImageKeys = new Set() - logger.info(`[${traceId}] PPTX 模板加载完成`, { - slideCount: ppt.slideCount, - textReplacementKeys: Object.keys(textReplacements), - imageReplacementAliases: buildImageReplacements() - }) - logSlideObjects(ppt, traceId) - const imageReplacementPlans = buildImageReplacementPlans( ppt, buildImageReplacements(), (fieldKey) => fieldKey === 'familyPhoto' ? classItem.familyPhoto - : student[fieldKey as 'meImage' | 'workImage1' | 'workImage2'], - traceId + : student[fieldKey as 'meImage' | 'workImage1' | 'workImage2'] ) for (const imageReplacementPlan of imageReplacementPlans) { - await replaceImageIfExists(ppt, imageReplacementPlan, replacedImageKeys, traceId) + await replaceImageIfExists(ppt, imageReplacementPlan, replacedImageKeys) } await ppt.saveToFile(outputPath) - await replacePptxTextPreservingStyle(outputPath, textReplacements, traceId) - logger.info(`[${traceId}] PPTX 报告保存完成`, { - outputPath, - replacedImageCount: replacedImageKeys.size - }) + await replacePptxTextPreservingStyle(outputPath, textReplacements) } async function removeFileIfExists(filePath: string): Promise { @@ -953,6 +785,8 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ throw new Error('请选择班级') } + const reportTemplate = template + const reportClass = classItem const selectedIds = new Set(input.studentIds ?? []) const students = await studentRepository.find({ where: { @@ -976,71 +810,50 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ const reports: ReportItem[] = [] const skipped: Array<{ studentName: string; reason: string }> = [] const batchTraceId = randomUUID() + let completedCount = 0 - logger.info(`[${batchTraceId}] 开始批量生成报告`, { - templateId: template.id, - templateName: template.name, - templateFilePath: resolveTemplateFilePath(template.filePath), - classId: classItem.id, - className: classItem.name, - selectedStudentCount: targetStudents.length, - selectedIds: Array.from(selectedIds) - }) sendReportProgress({ batchId: batchTraceId, status: 'started', - classId: classItem.id, - className: classItem.name, + classId: reportClass.id, + className: reportClass.name, current: 0, total: targetStudents.length }) - for (const [studentIndex, student] of targetStudents.entries()) { - const studentName = student.name || student.englishName || '未命名学生' - const title = `${classItem.name} ${studentName} 幼儿成长报告` + async function generateStudentReport(student: StudentProfileEntity): Promise { + const studentName = getStudentName(student) || '未命名学生' + const title = `${reportClass.name} ${studentName} 幼儿成长报告` const reportId = randomUUID() - const fileName = `${sanitizeFileName(title)}${template.fileExtension}` - const outputPath = join(getReportStoragePath(), `${reportId}${template.fileExtension}`) - const traceId = `${batchTraceId}:${student.id}` + const fileName = `${sanitizeFileName(title)}${reportTemplate.fileExtension}` + const outputPath = join(getReportStoragePath(), `${reportId}${reportTemplate.fileExtension}`) try { sendReportProgress({ batchId: batchTraceId, status: 'student-started', - classId: classItem.id, - className: classItem.name, + classId: reportClass.id, + className: reportClass.name, studentId: student.id, studentName, - current: studentIndex + 1, + current: completedCount, total: targetStudents.length }) - logger.info(`[${traceId}] 开始生成单个学生报告`, { - studentId: student.id, - studentName, - reportId, - outputPath, - imageFields: { - meImage: describeImageValue(student.meImage), - workImage1: describeImageValue(student.workImage1), - workImage2: describeImageValue(student.workImage2), - familyPhoto: describeImageValue(classItem.familyPhoto) - } - }) - await buildPptxReport(template, outputPath, student, classItem, traceId) + await buildPptxReport(reportTemplate, outputPath, student, reportClass) const entity: ReportEntity = { id: reportId, title, - classId: classItem.id, - className: classItem.name, + classId: reportClass.id, + className: reportClass.name, studentId: student.id, studentName, - format: getFormatFromExtension(template.fileExtension), + format: getFormatFromExtension(reportTemplate.fileExtension), originalFileName: fileName, filePath: outputPath, - fileExtension: template.fileExtension, - templateId: template.id, + fileExtension: reportTemplate.fileExtension, + templateId: reportTemplate.id, createdAt: now, updatedAt: now } @@ -1049,42 +862,34 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ await studentRepository.update({ id: student.id }, { reportGenerated: true }) const reportItem = mapReportEntity(entity) reports.push(reportItem) + completedCount += 1 sendReportProgress({ batchId: batchTraceId, status: 'student-finished', - classId: classItem.id, - className: classItem.name, + classId: reportClass.id, + className: reportClass.name, studentId: student.id, studentName, - current: studentIndex + 1, + current: completedCount, total: targetStudents.length, report: reportItem }) - logger.info(`[${traceId}] 单个学生报告生成成功`, { - reportId, - outputPath - }) } catch (error) { const reason = error instanceof Error ? error.message : '生成失败' - logger.error(`[${traceId}] 单个学生报告生成失败`, { - studentId: student.id, - studentName, - reportId, - outputPath, - error - }) + skipped.push({ studentName, reason }) + completedCount += 1 sendReportProgress({ batchId: batchTraceId, status: 'student-failed', - classId: classItem.id, - className: classItem.name, + classId: reportClass.id, + className: reportClass.name, studentId: student.id, studentName, - current: studentIndex + 1, + current: completedCount, total: targetStudents.length, error: reason }) @@ -1092,16 +897,15 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ } } - logger.info(`[${batchTraceId}] 批量生成报告完成`, { - successCount: reports.length, - skippedCount: skipped.length, - skipped - }) + for (const student of targetStudents) { + await generateStudentReport(student) + } + sendReportProgress({ batchId: batchTraceId, status: 'finished', - classId: classItem.id, - className: classItem.name, + classId: reportClass.id, + className: reportClass.name, current: targetStudents.length, total: targetStudents.length }) @@ -1119,7 +923,9 @@ export async function openReport(filePath: string): Promise { } } -export async function downloadReport(id: string): Promise<{ filePath: string; canceled?: boolean }> { +export async function downloadReport( + id: string +): Promise<{ filePath: string; canceled?: boolean }> { const repository = await getReportRepository() const report = await repository.findOneBy({ id }) @@ -1221,3 +1027,25 @@ export async function deleteReport(id: string): Promise { await studentRepository.update({ id: report.studentId }, { reportGenerated: false }) } } + +export async function deleteAllReports(): Promise { + const source = await getAppDataSource() + const repository = source.getRepository(ReportEntitySchema) + const studentRepository = source.getRepository(StudentProfileEntitySchema) + const reports = await repository.find() + + if (reports.length === 0) { + return 0 + } + + const studentIds = Array.from(new Set(reports.map((report) => report.studentId).filter(Boolean))) + + if (studentIds.length > 0) { + await studentRepository.update({ id: In(studentIds) }, { reportGenerated: false }) + } + + await repository.clear() + await Promise.all(reports.map((report) => removeFileIfExists(report.filePath))) + + return reports.length +} diff --git a/src/main/services/studentService.ts b/src/main/services/studentService.ts index d95b5bc..b67cc73 100644 --- a/src/main/services/studentService.ts +++ b/src/main/services/studentService.ts @@ -1,6 +1,8 @@ import 'reflect-metadata' +import { randomUUID } from 'crypto' import { Brackets, type Repository } from 'typeorm' +import { BrowserWindow } from 'electron' import { StudentProfileEntitySchema, @@ -10,6 +12,8 @@ import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity' import type { ChildProfile, ClassProfile, + CommentGenerationProgress, + GenerateStudentCommentsInput, ListStudentProfilesInput, LoadStudentDataInput } from '../types/student' @@ -118,11 +122,14 @@ function mapClassEntity(entity: ClassEntity): ClassProfile { name: entity.name, type: normalizeClassType(entity.type), teacherNames: parseTeacherNames(entity.teacherNames || entity.teacherName || ''), - familyPhoto: entity.familyPhoto ?? undefined + familyPhoto: entity.familyPhoto || undefined } } -function mapChildEntity(entity: StudentProfileEntity, options: { includeImages?: boolean } = {}): ChildProfile { +function mapChildEntity( + entity: StudentProfileEntity, + options: { includeImages?: boolean } = {} +): ChildProfile { return { id: entity.id, classId: entity.classId, @@ -154,7 +161,8 @@ async function mapClassProfile(profile: ClassProfile): Promise { type: profile.type, teacherName: (profile.teacherNames ?? []).join(' ') || null, teacherNames: stringifyTeacherNames(profile.teacherNames ?? []), - familyPhoto: (await storeImageValue('classes', profile.id, 'familyPhoto', profile.familyPhoto)) || null + familyPhoto: + (await storeImageValue('classes', profile.id, 'familyPhoto', profile.familyPhoto)) || null } } @@ -205,6 +213,16 @@ function buildStudentCommentPrompt(profile: ChildProfile): string { ].join('\n') } +function getStudentDisplayName(profile: ChildProfile | StudentProfileEntity): string { + return profile.name || profile.englishName || '未命名学生' +} + +function sendCommentProgress(progress: CommentGenerationProgress): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('comments:generation-progress', progress) + } +} + async function getRepositories(): Promise<{ classRepository: Repository childRepository: Repository @@ -268,8 +286,12 @@ export async function loadStudentData(input: LoadStudentDataInput = {}): Promise } } + const hydratedClasses = includeImages + ? await Promise.all(normalizedClasses.map(hydrateClassImages)) + : normalizedClasses + return { - classes: (await Promise.all(normalizedClasses.map(hydrateClassImages))).map(mapClassEntity), + classes: hydratedClasses.map(mapClassEntity), profiles: hydratedProfiles.map((profile) => mapChildEntity(profile, { includeImages })) } } @@ -369,15 +391,10 @@ export async function updateStudentProfile(profile: ChildProfile): Promise { - const source = await getAppDataSource() - const repository = source.getRepository(StudentProfileEntitySchema) - const entity = await repository.findOneBy({ id: profileId }) - - if (!entity) { - throw new Error('学生信息不存在') - } - +async function generateStudentCommentForEntity( + entity: StudentProfileEntity, + repository: Repository +): Promise { const profile = mapChildEntity(entity) const settings = await loadSettings() @@ -451,6 +468,117 @@ export async function generateStudentComment(profileId: string): Promise { + const source = await getAppDataSource() + const repository = source.getRepository(StudentProfileEntitySchema) + const entity = await repository.findOneBy({ id: profileId }) + + if (!entity) { + throw new Error('学生信息不存在') + } + + return generateStudentCommentForEntity(entity, repository) +} + +export async function generateStudentComments(input: GenerateStudentCommentsInput): Promise<{ + profiles: ChildProfile[] + skipped: Array<{ studentName: string; reason: string }> +}> { + const source = await getAppDataSource() + const repository = source.getRepository(StudentProfileEntitySchema) + const selectedIds = new Set(input.profileIds ?? []) + const allProfiles = await repository.find({ + where: input.classId ? { classId: input.classId } : undefined, + order: { + name: 'ASC', + id: 'ASC' + } + }) + const targetProfiles = + selectedIds.size > 0 + ? allProfiles.filter((profile) => selectedIds.has(profile.id)) + : allProfiles + + if (targetProfiles.length === 0) { + throw new Error('没有可生成评语的学生') + } + + const batchId = randomUUID() + const className = targetProfiles[0]?.className + const profiles: ChildProfile[] = [] + const skipped: Array<{ studentName: string; reason: string }> = [] + + sendCommentProgress({ + batchId, + status: 'started', + classId: input.classId, + className, + current: 0, + total: targetProfiles.length + }) + + for (const [profileIndex, profile] of targetProfiles.entries()) { + const studentName = getStudentDisplayName(profile) + + try { + sendCommentProgress({ + batchId, + status: 'student-started', + classId: profile.classId, + className: profile.className, + studentId: profile.id, + studentName, + current: profileIndex + 1, + total: targetProfiles.length + }) + + const nextProfile = await generateStudentCommentForEntity(profile, repository) + profiles.push(nextProfile) + + sendCommentProgress({ + batchId, + status: 'student-finished', + classId: profile.classId, + className: profile.className, + studentId: profile.id, + studentName, + current: profileIndex + 1, + total: targetProfiles.length, + profile: nextProfile + }) + } catch (error) { + const reason = error instanceof Error ? error.message : '生成失败' + + skipped.push({ + studentName, + reason + }) + sendCommentProgress({ + batchId, + status: 'student-failed', + classId: profile.classId, + className: profile.className, + studentId: profile.id, + studentName, + current: profileIndex + 1, + total: targetProfiles.length, + error: reason + }) + } + } + + sendCommentProgress({ + batchId, + status: 'finished', + classId: input.classId, + className, + current: targetProfiles.length, + total: targetProfiles.length + }) + + return { profiles, skipped } +} + export async function saveClasses(classes: ClassProfile[]): Promise { const source = await getAppDataSource() @@ -491,7 +619,9 @@ export async function migrateLocalStudentData( } await classRepository.save(await Promise.all(classes.map(mapClassProfile))) - await childRepository.save(await Promise.all(profiles.map((profile) => mapChildProfile(profile)))) + await childRepository.save( + await Promise.all(profiles.map((profile) => mapChildProfile(profile))) + ) }) } @@ -515,7 +645,9 @@ export async function deleteClassWithProfiles(classId: string): Promise { const source = await getAppDataSource() await source.transaction(async (manager) => { - await manager.getRepository(StudentProfileEntitySchema).delete({ classId }) + await manager + .getRepository(StudentProfileEntitySchema) + .delete({ classId }) await manager.getRepository(ClassEntitySchema).delete({ id: classId }) }) } diff --git a/src/main/types/report.ts b/src/main/types/report.ts index 1ab6d3b..d5a212a 100644 --- a/src/main/types/report.ts +++ b/src/main/types/report.ts @@ -74,6 +74,16 @@ export type DeleteReportResponse = message: string } +export type DeleteAllReportsResponse = + | { + ok: true + deletedCount: number + } + | { + ok: false + message: string + } + export type DownloadReportResponse = | { ok: true diff --git a/src/main/types/student.ts b/src/main/types/student.ts index ee4f771..63aafd6 100644 --- a/src/main/types/student.ts +++ b/src/main/types/student.ts @@ -93,6 +93,24 @@ export type GenerateStudentCommentInput = { profileId: string } +export type GenerateStudentCommentsInput = { + classId?: string + profileIds?: string[] +} + +export type CommentGenerationProgress = { + batchId: string + status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + classId?: string + className?: string + studentId?: string + studentName?: string + current: number + total: number + profile?: ChildProfile + error?: string +} + export type GenerateStudentCommentResponse = | { ok: true @@ -103,6 +121,17 @@ export type GenerateStudentCommentResponse = message: string } +export type GenerateStudentCommentsResponse = + | { + ok: true + profiles: ChildProfile[] + skipped: Array<{ studentName: string; reason: string }> + } + | { + ok: false + message: string + } + export type SaveClassesResponse = | { ok: true diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 348dede..fd6382e 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -93,6 +93,19 @@ type ReportGenerationProgress = { error?: string } +type CommentGenerationProgress = { + batchId: string + status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + classId?: string + className?: string + studentId?: string + studentName?: string + current: number + total: number + profile?: ChildProfile + error?: string +} + type ChildProfile = { id: string classId: string @@ -258,6 +271,17 @@ type GenerateReportsResponse = message: string } +type GenerateStudentCommentsResponse = + | { + ok: true + profiles: ChildProfile[] + skipped: Array<{ studentName: string; reason: string }> + } + | { + ok: false + message: string + } + type DownloadReportResponse = | { ok: true @@ -281,6 +305,16 @@ type DownloadClassReportsResponse = message: string } +type DeleteAllReportsResponse = + | { + ok: true + deletedCount: number + } + | { + ok: false + message: string + } + type AppAPI = { deleteClass: (classId: string) => Promise deleteStudentProfile: (profileId: string) => Promise @@ -301,6 +335,10 @@ type AppAPI = { generateStudentComment: (payload: { profileId: string }) => Promise + generateStudentComments: (payload: { + classId?: string + profileIds?: string[] + }) => Promise listModels: (payload: { baseUrl: string; apiKey: string }) => Promise testModelConnection: (modelConfig: ModelConfig) => Promise replaceClassProfiles: (payload: { @@ -337,10 +375,12 @@ type AppAPI = { openReport: (filePath: string) => Promise downloadReport: (id: string) => Promise downloadClassReports: (payload: { classId: string }) => Promise - onReportGenerationProgress: ( - callback: (progress: ReportGenerationProgress) => void + onReportGenerationProgress: (callback: (progress: ReportGenerationProgress) => void) => () => void + onCommentGenerationProgress: ( + callback: (progress: CommentGenerationProgress) => void ) => () => void deleteReport: (id: string) => Promise + deleteAllReports: () => Promise } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 1b0ed69..08ea64a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -41,6 +41,8 @@ const api = { }) => ipcRenderer.invoke('student:update-profile', profile), generateStudentComment: (payload: { profileId: string }) => ipcRenderer.invoke('student:generate-comment', payload), + generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) => + ipcRenderer.invoke('student:generate-comments', payload), listModels: (payload: { baseUrl: string; apiKey: string }) => ipcRenderer.invoke('models:list', payload), testModelConnection: (modelConfig: { @@ -155,25 +157,52 @@ const api = { downloadReport: (id: string) => ipcRenderer.invoke('reports:download', id), downloadClassReports: (payload: { classId: string }) => ipcRenderer.invoke('reports:download-class', payload), - onReportGenerationProgress: (callback: (progress: { - batchId: string - status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' - classId: string - className: string - studentId?: string - studentName?: string - current: number - total: number - report?: unknown - error?: string - }) => void) => { - const listener = (_: Electron.IpcRendererEvent, progress: Parameters[0]) => - callback(progress) + onReportGenerationProgress: ( + callback: (progress: { + batchId: string + status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + classId: string + className: string + studentId?: string + studentName?: string + current: number + total: number + report?: unknown + error?: string + }) => void + ): (() => void) => { + const listener = ( + _: Electron.IpcRendererEvent, + progress: Parameters[0] + ): void => callback(progress) ipcRenderer.on('reports:generation-progress', listener) return () => ipcRenderer.removeListener('reports:generation-progress', listener) }, - deleteReport: (id: string) => ipcRenderer.invoke('reports:delete', id) + onCommentGenerationProgress: ( + callback: (progress: { + batchId: string + status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + classId?: string + className?: string + studentId?: string + studentName?: string + current: number + total: number + profile?: unknown + error?: string + }) => void + ): (() => void) => { + const listener = ( + _: Electron.IpcRendererEvent, + progress: Parameters[0] + ): void => callback(progress) + + ipcRenderer.on('comments:generation-progress', listener) + return () => ipcRenderer.removeListener('comments:generation-progress', listener) + }, + deleteReport: (id: string) => ipcRenderer.invoke('reports:delete', id), + deleteAllReports: () => ipcRenderer.invoke('reports:delete-all') } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3498239..a9bbfda 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -19,7 +19,7 @@ function App(): React.JSX.Element { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/components/layout/AppSidebar.tsx b/src/renderer/src/components/layout/AppSidebar.tsx index 92e11d8..1c1f4f3 100644 --- a/src/renderer/src/components/layout/AppSidebar.tsx +++ b/src/renderer/src/components/layout/AppSidebar.tsx @@ -1,14 +1,6 @@ import { useMemo, useState } from 'react' import { ChevronDown } from 'lucide-react' import { Link, useLocation } from 'react-router-dom' - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from '@renderer/components/ui/card' import { Separator } from '@renderer/components/ui/separator' import { getOpenMenuIds, menuTree } from '@renderer/student/menu' import { cn } from '@renderer/lib/utils' @@ -66,18 +58,6 @@ export function AppSidebar(): React.JSX.Element { ))} - -
- - - 当前学期 - 2026 春季 - - - 共 20 份报告待处理 - - -
) } diff --git a/src/renderer/src/pages/ClassPage.tsx b/src/renderer/src/pages/ClassPage.tsx index b1c54e8..7b46074 100644 --- a/src/renderer/src/pages/ClassPage.tsx +++ b/src/renderer/src/pages/ClassPage.tsx @@ -1,19 +1,23 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { + Bot, Camera, Construction, + Download, FolderPlus, Images, MoreHorizontal, Pencil, Plus, + Presentation, Search, Trash2, Upload, X } from 'lucide-react' import JSZip from 'jszip' +import { useNavigate } from 'react-router-dom' import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter' import { @@ -66,11 +70,7 @@ import { import { Input } from '@renderer/components/ui/input' import { ScrollArea } from '@renderer/components/ui/scroll-area' import { Select } from '@renderer/components/ui/select' -import { - classTypeOptions, - createUuid, - getClassTypeLabel -} from '@renderer/student/classes' +import { classTypeOptions, createUuid, getClassTypeLabel } from '@renderer/student/classes' import { readChildProfilesFromSpreadsheet, replaceProfilesForClass @@ -127,7 +127,9 @@ function getImageMimeType(fileName: string): string | null { return IMAGE_EXTENSION_MIME_TYPES[extension] ?? null } -function getPhotoFieldFromFileName(fileName: string): 'meImage' | 'workImage1' | 'workImage2' | null { +function getPhotoFieldFromFileName( + fileName: string +): 'meImage' | 'workImage1' | 'workImage2' | null { const baseName = fileName .replace(/\.[^.]+$/, '') .trim() @@ -166,7 +168,9 @@ function findProfileForZipPath( (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') + 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])) @@ -180,6 +184,7 @@ function findProfileForZipPath( } export function ClassPage(): React.JSX.Element { + const navigate = useNavigate() const fileInputRef = useRef(null) const photoZipInputRef = useRef(null) const classPhotoInputRef = useRef(null) @@ -193,6 +198,9 @@ export function ClassPage(): React.JSX.Element { const [query, setQuery] = useState('') const [createDrawerOpen, setCreateDrawerOpen] = useState(false) const [exportingId, setExportingId] = useState('') + const [generatingCommentClassId, setGeneratingCommentClassId] = useState('') + const [generatingReportClassId, setGeneratingReportClassId] = useState('') + const [downloadingReportClassId, setDownloadingReportClassId] = useState('') const [editingClass, setEditingClass] = useState(null) const [uploadingClass, setUploadingClass] = useState(null) const [photoZipClass, setPhotoZipClass] = useState(null) @@ -521,6 +529,78 @@ export function ClassPage(): React.JSX.Element { } } + async function handleGenerateClassComments(classItem: ClassProfile): Promise { + const childCount = profileCountByClass.get(classItem.id) ?? 0 + + if (childCount === 0) { + showError('当前班级没有幼儿数据') + return + } + + setGeneratingCommentClassId(classItem.id) + + try { + const response = await window.api.generateStudentComments({ classId: classItem.id }) + + if (!response.ok) { + showError('生成评语失败', response.message) + return + } + + setProfiles((currentProfiles) => + currentProfiles.map( + (profile) => + response.profiles.find((nextProfile) => nextProfile.id === profile.id) ?? profile + ) + ) + showSuccess( + `已生成「${classItem.name}」评语`, + `成功 ${response.profiles.length} 个,失败 ${response.skipped.length} 个` + ) + } finally { + setGeneratingCommentClassId('') + } + } + + function handleGenerateClassReports(classItem: ClassProfile): void { + const childCount = profileCountByClass.get(classItem.id) ?? 0 + + if (childCount === 0) { + showError('当前班级没有幼儿数据') + return + } + + setGeneratingReportClassId(classItem.id) + navigate('/student/reports', { + state: { + classId: classItem.id, + autoGenerate: true + } + }) + } + + async function handleDownloadClassReports(classItem: ClassProfile): Promise { + setDownloadingReportClassId(classItem.id) + + try { + const response = await window.api.downloadClassReports({ classId: classItem.id }) + + if (!response.ok) { + if (!response.canceled) { + showError('下载班级报告失败', response.message) + } + return + } + + showSuccess( + `已下载「${classItem.name}」报告`, + `${response.filePath},共 ${response.reportCount} 份` + ) + } finally { + setDownloadingReportClassId('') + } + } + function openPhotoZipPicker(classItem: ClassProfile): void { setPhotoZipClass(classItem) @@ -599,7 +679,10 @@ export function ClassPage(): React.JSX.Element { } if (updatesByProfileId.size === 0) { - showError('没有匹配到可导入照片', '请确认 ZIP 内是 班级UUID/images/学生姓名/me.jpg、1.jpg、2.jpg') + showError( + '没有匹配到可导入照片', + '请确认 ZIP 内是 班级UUID/images/学生姓名/me.jpg、1.jpg、2.jpg' + ) return } @@ -724,25 +807,55 @@ export function ClassPage(): React.JSX.Element { 班级操作 - + handleOpenEdit(classItem)}> + + 编辑班级 + openUploadDrawer(classItem)}> 上传幼儿信息 - openPhotoZipPicker(classItem)}> - - 导入照片 ZIP - + + 图片操作 handleExportClass(classItem)} > - {exportingId === classItem.id ? '导出中' : '导出 ZIP'} + {exportingId === classItem.id ? '导出中' : '导出图片ZIP'} - handleOpenEdit(classItem)}> - - 编辑班级 + openPhotoZipPicker(classItem)}> + + 批量导入学生照片 + + + 一键操作 + handleGenerateClassComments(classItem)} + > + + {generatingCommentClassId === classItem.id + ? '评语生成中' + : '一键生成评语'} + + handleGenerateClassReports(classItem)} + > + + {generatingReportClassId === classItem.id + ? '报告生成中' + : '一键生成报告'} + + handleDownloadClassReports(classItem)} + > + + {downloadingReportClassId === classItem.id + ? '报告打包中' + : '一键下载班级报告'} diff --git a/src/renderer/src/pages/ReportPage.tsx b/src/renderer/src/pages/ReportPage.tsx index 148c57a..6e6525b 100644 --- a/src/renderer/src/pages/ReportPage.tsx +++ b/src/renderer/src/pages/ReportPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Download, ExternalLink, @@ -15,6 +15,17 @@ import { useLocation } from 'react-router-dom' import { toast } from 'sonner' import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@renderer/components/ui/alert-dialog' import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' import { @@ -24,6 +35,14 @@ import { CardHeader, CardTitle } from '@renderer/components/ui/card' +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle +} from '@renderer/components/ui/drawer' import { Input } from '@renderer/components/ui/input' import { ScrollArea } from '@renderer/components/ui/scroll-area' import { Select } from '@renderer/components/ui/select' @@ -82,23 +101,38 @@ function formatDateTime(value: string): string { }) } +type ReportPageLocationState = { + profileId?: string + classId?: string + mode?: 'view' + autoGenerate?: boolean +} + export function ReportPage(): React.JSX.Element { const location = useLocation() + const locationState = location.state as ReportPageLocationState | null + const autoGenerateKeyRef = useRef('') const [classes, setClasses] = useState([]) const [profiles, setProfiles] = useState([]) const [templates, setTemplates] = useState([]) const [reports, setReports] = useState([]) - const [progressCards, setProgressCards] = useState([]) + const [generationProgress, setGenerationProgress] = useState( + null + ) const [query, setQuery] = useState('') - const [selectedClassId, setSelectedClassId] = useState('all') + const [selectedClassId, setSelectedClassId] = useState(locationState?.classId ?? 'all') + const [generateDrawerOpen, setGenerateDrawerOpen] = useState(false) + const [generateClassId, setGenerateClassId] = useState(locationState?.classId ?? '') const [selectedFormat, setSelectedFormat] = useState('all') const [selectedTemplateId, setSelectedTemplateId] = useState('') const [generating, setGenerating] = useState(false) const [openingId, setOpeningId] = useState('') const [downloadingId, setDownloadingId] = useState('') - const [downloadingClass, setDownloadingClass] = useState(false) const [deletingId, setDeletingId] = useState('') - const [pendingStudentId, setPendingStudentId] = useState(null) + const [deletingAllReports, setDeletingAllReports] = useState(false) + const [pendingStudentId, setPendingStudentId] = useState( + locationState?.profileId ?? null + ) useEffect(() => { async function loadPageData(): Promise { @@ -137,26 +171,7 @@ export function ReportPage(): React.JSX.Element { useEffect(() => { return window.api.onReportGenerationProgress((progress) => { - setProgressCards((currentCards) => { - if (progress.status === 'finished') { - return currentCards.filter((card) => card.batchId !== progress.batchId) - } - - if (!progress.studentId) { - return currentCards - } - - const nextCard = progress - const existingIndex = currentCards.findIndex( - (card) => card.batchId === progress.batchId && card.studentId === progress.studentId - ) - - if (existingIndex < 0) { - return [nextCard, ...currentCards] - } - - return currentCards.map((card, index) => (index === existingIndex ? nextCard : card)) - }) + setGenerationProgress(progress.status === 'finished' ? null : progress) if (progress.status === 'student-finished' && progress.report) { setReports((currentReports) => [progress.report!, ...currentReports]) @@ -164,14 +179,6 @@ export function ReportPage(): React.JSX.Element { }) }, []) - useEffect(() => { - const state = location.state as { profileId?: string; mode?: 'view' } | null - - if (state?.profileId) { - setPendingStudentId(state.profileId) - } - }, [location.state]) - useEffect(() => { if (!pendingStudentId || profiles.length === 0) { return @@ -183,21 +190,50 @@ export function ReportPage(): React.JSX.Element { return } - setSelectedClassId(profile.classId) - setQuery(profile.name || profile.englishName) + queueMicrotask(() => { + setSelectedClassId(profile.classId) + setQuery(profile.name || profile.englishName) - const state = location.state as { profileId?: string; mode?: 'view' } | null + if (locationState?.mode === 'view') { + setPendingStudentId(null) + return + } - if (state?.mode === 'view') { - setPendingStudentId(null) + if (selectedTemplateId) { + void handleGenerateReports({ + classId: profile.classId, + studentIds: [profile.id] + }) + setPendingStudentId(null) + } + }) + }, [locationState?.mode, pendingStudentId, profiles, selectedTemplateId]) + + useEffect(() => { + const autoGenerateKey = locationState?.classId + ? `${locationState.classId}:${selectedTemplateId}` + : '' + + if ( + !locationState?.autoGenerate || + !locationState.classId || + !selectedTemplateId || + selectedClassId !== locationState.classId || + generating || + autoGenerateKeyRef.current === autoGenerateKey + ) { return } - if (selectedTemplateId) { - void handleGenerateReports([profile.id]) - setPendingStudentId(null) - } - }, [location.state, pendingStudentId, profiles, selectedTemplateId]) + autoGenerateKeyRef.current = autoGenerateKey + void handleGenerateReports({ classId: locationState.classId }) + }, [ + generating, + locationState?.autoGenerate, + locationState?.classId, + selectedTemplateId, + selectedClassId + ]) const pptxTemplates = templates.filter((template) => template.fileExtension === '.pptx') @@ -224,13 +260,17 @@ export function ReportPage(): React.JSX.Element { }) }, [query, reports, selectedClassId, selectedFormat]) - async function handleGenerateReports(studentIds?: string[]): Promise { + async function handleGenerateReports( + input: { classId?: string; studentIds?: string[] } = {} + ): Promise { + const targetClassId = input.classId ?? selectedClassId + if (!selectedTemplateId) { toast.error('请先选择 PPTX 报告模板') return } - if (selectedClassId === 'all') { + if (targetClassId === 'all') { toast.error('请选择要生成报告的班级') return } @@ -240,8 +280,8 @@ export function ReportPage(): React.JSX.Element { try { const response = await window.api.generateReports({ templateId: selectedTemplateId, - classId: selectedClassId, - studentIds + classId: targetClassId, + studentIds: input.studentIds }) if (!response.ok) { @@ -251,7 +291,10 @@ export function ReportPage(): React.JSX.Element { setReports((currentReports) => { const existingIds = new Set(currentReports.map((report) => report.id)) - return [...response.reports.filter((report) => !existingIds.has(report.id)), ...currentReports] + return [ + ...response.reports.filter((report) => !existingIds.has(report.id)), + ...currentReports + ] }) toast.success('报告生成完成', { description: @@ -259,11 +302,26 @@ export function ReportPage(): React.JSX.Element { ? `成功 ${response.reports.length} 份,跳过 ${response.skipped.length} 份` : `成功 ${response.reports.length} 份` }) + setGenerateDrawerOpen(false) } finally { setGenerating(false) } } + function openGenerateDrawer(): void { + setGenerateClassId(selectedClassId === 'all' ? '' : selectedClassId) + setGenerateDrawerOpen(true) + } + + function handleConfirmGenerateClassReports(): void { + if (!generateClassId) { + toast.error('请选择要生成报告的班级') + return + } + + void handleGenerateReports({ classId: generateClassId }) + } + async function handleDownloadReport(report: ReportItem): Promise { setDownloadingId(report.id) @@ -283,30 +341,6 @@ export function ReportPage(): React.JSX.Element { } } - async function handleDownloadClassReports(): Promise { - if (selectedClassId === 'all') { - toast.error('请先选择要下载的班级') - return - } - - setDownloadingClass(true) - - try { - const response = await window.api.downloadClassReports({ classId: selectedClassId }) - - if (!response.ok) { - if (!response.canceled) { - toast.error('下载班级报告失败', { description: response.message }) - } - return - } - - toast.success(`已下载 ${response.reportCount} 份报告`, { description: response.filePath }) - } finally { - setDownloadingClass(false) - } - } - async function handleOpenReport(report: ReportItem): Promise { setOpeningId(report.id) @@ -341,6 +375,30 @@ export function ReportPage(): React.JSX.Element { } } + async function handleDeleteAllReports(): Promise { + setDeletingAllReports(true) + + try { + const response = await window.api.deleteAllReports() + + if (!response.ok) { + toast.error('删除全部报告失败', { description: response.message }) + return + } + + setReports([]) + + if (response.deletedCount === 0) { + toast.info('没有可删除的报告') + return + } + + toast.success('已删除全部报告', { description: `共删除 ${response.deletedCount} 份报告` }) + } finally { + setDeletingAllReports(false) + } + } + return ( <>
@@ -406,70 +464,103 @@ export function ReportPage(): React.JSX.Element { ))} - -
- + + + + + + + 删除全部报告? + + 将删除当前数据库中的全部报告记录和对应文件,并把所有学生的报告状态重置为未生成。 + + + + 取消 + + 确认删除全部 + + + +
+ {generationProgress ? ( + + +
+ +
+
+
+
+

+ {generationProgress.studentName + ? `正在生成 ${generationProgress.studentName} 的报告` + : '正在准备生成报告'} +

+

+ {generationProgress.className} · {generationProgress.current}/ + {generationProgress.total} +

+
+ + {generationProgress.status === 'student-failed' ? '本份失败' : '生成中'} + +
+
+
0 ? 6 : 0 + ), + 100 + )}%` + }} + /> +
+ {generationProgress.error ? ( +

{generationProgress.error}

+ ) : null} +
+
+

+ {Math.round( + (generationProgress.current / Math.max(generationProgress.total, 1)) * 100 + )} + % +

+

总体进度

+
+ + + ) : null} +
- {progressCards.map((progress) => ( - - -
-
- -
- - {progress.status === 'student-failed' ? '生成失败' : '生成中'} - -
-
- - {progress.studentName || '学生'} 正在生成报告 - - - {progress.className} · {progress.current}/{progress.total} - -
-
- -
-
-
- {progress.error ? ( -

{progress.error}

- ) : null} - - - ))} - {filteredReports.map((report) => { const ReportIcon = getReportIcon(report.format) @@ -555,6 +646,57 @@ export function ReportPage(): React.JSX.Element {
+ + + + 生成班级报告 + + 选择一个班级,系统会使用当前选中的 PPTX 模板为该班级幼儿生成报告。 + + +
+
+ + +
+
+ 当前模板: + {templates.find((template) => template.id === selectedTemplateId)?.name || + '未选择 PPTX 模板'} +
+
+ + + + +
+
+ ) diff --git a/src/renderer/src/pages/StudentPage.tsx b/src/renderer/src/pages/StudentPage.tsx index 1dc6fd4..2ba5588 100644 --- a/src/renderer/src/pages/StudentPage.tsx +++ b/src/renderer/src/pages/StudentPage.tsx @@ -75,6 +75,7 @@ import { } from '@renderer/components/ui/table' import { getClassTypeLabel } from '@renderer/student/classes' import type { ChildProfile, ClassProfile } from '@renderer/types/app' +import type { CommentGenerationProgress } from '@renderer/types/app' function formatList(items: string[]): string { return items.length > 0 ? items.join('、') : '未填写' @@ -120,6 +121,7 @@ export function StudentPage(): React.JSX.Element { const [selectedId, setSelectedId] = useState('') const [selectedProfileIds, setSelectedProfileIds] = useState([]) const [generatingIds, setGeneratingIds] = useState([]) + const [commentProgress, setCommentProgress] = useState(null) const [editingProfile, setEditingProfile] = useState(null) const [query, setQuery] = useState('') const [selectedClassFilter, setSelectedClassFilter] = useState('all') @@ -167,6 +169,33 @@ export function StudentPage(): React.JSX.Element { loadPagedStudentProfiles() }, [page, pageSize, query, selectedClassFilter, studentDataReady]) + useEffect(() => { + return window.api.onCommentGenerationProgress((progress) => { + setCommentProgress(progress.status === 'finished' ? null : progress) + + if (progress.status === 'student-started' && progress.studentId) { + setGeneratingIds((currentIds) => + currentIds.includes(progress.studentId!) + ? currentIds + : [...currentIds, progress.studentId!] + ) + } + + if (progress.status === 'student-finished' && progress.profile) { + updateProfileInPage(progress.profile) + } + + if ( + (progress.status === 'student-finished' || progress.status === 'student-failed') && + progress.studentId + ) { + setGeneratingIds((currentIds) => + currentIds.filter((profileId) => profileId !== progress.studentId) + ) + } + }) + }, []) + const classOptions = useMemo(() => { const classMap = new Map() @@ -282,28 +311,26 @@ export function StudentPage(): React.JSX.Element { } async function handleBatchGenerateComments(): Promise { - const selectedProfiles = profiles.filter((profile) => selectedProfileIds.includes(profile.id)) - - if (selectedProfiles.length === 0) { + if (selectedProfileIds.length === 0) { toast.error('请先选择要生成评语的学生') return } - let successCount = 0 - let failedCount = 0 + const response = await window.api.generateStudentComments({ + profileIds: selectedProfileIds + }) - for (const profile of selectedProfiles) { - const ok = await handleGenerateComment(profile) + if (!response.ok) { + toast.error('批量生成评语失败', { description: response.message }) + return + } - if (ok) { - successCount += 1 - } else { - failedCount += 1 - } + for (const profile of response.profiles) { + updateProfileInPage(profile) } toast.success('批量生成完成', { - description: `成功 ${successCount} 个,失败 ${failedCount} 个` + description: `成功 ${response.profiles.length} 个,失败 ${response.skipped.length} 个` }) } @@ -312,9 +339,10 @@ export function StudentPage(): React.JSX.Element { } function handleGenerateReport(profile: ChildProfile): void { - navigate('/tools/reports', { + navigate('/student/reports', { state: { - profileId: profile.id + profileId: profile.id, + autoGenerate: true } }) } @@ -324,7 +352,7 @@ export function StudentPage(): React.JSX.Element { return } - navigate('/tools/reports', { + navigate('/student/reports', { state: { profileId: profile.id, mode: 'view' @@ -425,7 +453,7 @@ export function StudentPage(): React.JSX.Element {
+ {commentProgress ? ( + + +
+ +
+
+
+
+

+ {commentProgress.studentName + ? `正在生成 ${commentProgress.studentName} 的评语` + : '正在准备生成评语'} +

+

+ {commentProgress.className || '学生数据'} · {commentProgress.current}/ + {commentProgress.total} +

+
+ + {commentProgress.status === 'student-failed' ? '本份失败' : '生成中'} + +
+
+
0 ? 6 : 0 + ), + 100 + )}%` + }} + /> +
+ {commentProgress.error ? ( +

{commentProgress.error}

+ ) : null} +
+
+

+ {Math.round( + (commentProgress.current / Math.max(commentProgress.total, 1)) * 100 + )} + % +

+

总体进度

+
+ + + ) : null} +
@@ -803,10 +889,7 @@ function StudentProfileForm({ }) } - function updateImageField( - field: 'meImage' | 'workImage1' | 'workImage2', - file?: File - ): void { + function updateImageField(field: 'meImage' | 'workImage1' | 'workImage2', file?: File): void { if (!file) { return } diff --git a/src/renderer/src/pages/TemplatePage.tsx b/src/renderer/src/pages/TemplatePage.tsx index 52ee875..4648c8f 100644 --- a/src/renderer/src/pages/TemplatePage.tsx +++ b/src/renderer/src/pages/TemplatePage.tsx @@ -47,7 +47,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger } from '@renderer/components/ui/dropdown-menu' import { Input } from '@renderer/components/ui/input' @@ -470,7 +469,6 @@ export function TemplatePage(): React.JSX.Element { 模板操作 - openEditDrawer(template)}> 编辑信息 diff --git a/src/renderer/src/student/menu.ts b/src/renderer/src/student/menu.ts index f80979d..c3be048 100644 --- a/src/renderer/src/student/menu.ts +++ b/src/renderer/src/student/menu.ts @@ -1,38 +1,16 @@ import { - Bot, Database, FileArchive, - FileImage, - FileSignature, Files, FolderPlus, FolderOpen, - Presentation, Settings, - UserRound, - Wand2 + UserRound } from 'lucide-react' import type { MenuItem } from '@renderer/types/app' export const menuTree: MenuItem[] = [ - { - id: 'tools', - title: '小工具', - icon: Wand2, - children: [ - { - id: 'tool-image-paths', - title: '生成图片路径', - path: '/tools/image-paths', - icon: FileImage - }, - { id: 'tool-comments', title: '生成评语', path: '/tools/comments', icon: Bot }, - { id: 'tool-reports', title: '生成报告', path: '/tools/reports', icon: Presentation }, - { id: 'tool-convert', title: '格式转换', path: '/tools/convert', icon: FileArchive }, - { id: 'tool-signature', title: '园长签名', path: '/tools/signature', icon: FileSignature } - ] - }, { id: 'student', title: '学生管理', diff --git a/src/renderer/src/types/app.ts b/src/renderer/src/types/app.ts index 5cc497b..eed54bc 100644 --- a/src/renderer/src/types/app.ts +++ b/src/renderer/src/types/app.ts @@ -113,6 +113,19 @@ export type ReportGenerationProgress = { error?: string } +export type CommentGenerationProgress = { + batchId: string + status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + classId?: string + className?: string + studentId?: string + studentName?: string + current: number + total: number + profile?: ChildProfile + error?: string +} + export type LogEntry = { time: string level: LogLevel diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..f195a71 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,8 @@ +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", + "include": ["src/server/**/*"], + "compilerOptions": { + "composite": true, + "types": ["node"] + } +}