feat: improve report and comment workflows

This commit is contained in:
2026-06-27 01:59:31 +08:00
parent 203f3b9ec1
commit df3b623144
26 changed files with 1068 additions and 560 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myConfigurationMode" value="MANUAL" />
<option name="myRunOnReformat" value="true" />
</component>
</project>
+88
View File
@@ -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())
+18 -5
View File
@@ -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,9 +71,7 @@ export function registerReportIpc(): void {
}
})
ipcMain.handle(
'reports:download',
async (_, id: string): Promise<DownloadReportResponse> => {
ipcMain.handle('reports:download', async (_, id: string): Promise<DownloadReportResponse> => {
try {
const result = await downloadReport(id)
@@ -93,8 +93,7 @@ export function registerReportIpc(): void {
message: getErrorMessage(error)
}
}
}
)
})
ipcMain.handle(
'reports:download-class',
@@ -137,4 +136,18 @@ export function registerReportIpc(): void {
}
}
})
ipcMain.handle('reports:delete-all', async (): Promise<DeleteAllReportsResponse> => {
try {
return {
ok: true,
deletedCount: await deleteAllReports()
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
})
}
+25 -2
View File
@@ -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,7 +43,9 @@ export function registerStudentIpc(): void {
console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error)
})
ipcMain.handle('student:load', async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
ipcMain.handle(
'student:load',
async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
try {
const studentData = await loadStudentData(payload)
return {
@@ -53,7 +58,8 @@ export function registerStudentIpc(): void {
message: getErrorMessage(error)
}
}
})
}
)
ipcMain.handle(
'student:list',
@@ -123,6 +129,23 @@ export function registerStudentIpc(): void {
}
)
ipcMain.handle(
'student:generate-comments',
async (_, payload: GenerateStudentCommentsInput): Promise<GenerateStudentCommentsResponse> => {
try {
return {
ok: true,
...(await generateStudentComments(payload))
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
}
}
)
ipcMain.handle(
'student:save-classes',
async (_, classes: ClassProfile[]): Promise<SaveClassesResponse> => {
+110 -282
View File
@@ -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<string, string> {
function buildTextReplacements(
studentData: ReportStudentData,
classItem: ClassEntity
): Record<string, string> {
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<string, unknown> {
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<string, number> | 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(/<p:sp[\s\S]*?<\/p:sp>/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(/<a:t(?:\s[^>]*)?>[\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(/^<a:t(?:\s[^>]*)?>([\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<string, string>,
traceId: string
replacements: Record<string, string>
): Promise<void> {
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<string>,
traceId: string
usedImageKeys: Set<string>
): 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<string>,
traceId: string
usedImageKeys: Set<string>
): Promise<void> {
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<string, string>,
getImageValue: (fieldKey: string) => string | null | undefined,
traceId: string
getImageValue: (fieldKey: string) => string | null | undefined
): PptImageReplacementPlan[] {
const plans: PptImageReplacementPlan[] = []
const usedImageKeys = new Set<string>()
@@ -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<string, number>()
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<void> {
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<string>()
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<void> {
@@ -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<void> {
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<void> {
}
}
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<void> {
await studentRepository.update({ id: report.studentId }, { reportGenerated: false })
}
}
export async function deleteAllReports(): Promise<number> {
const source = await getAppDataSource()
const repository = source.getRepository<ReportEntity>(ReportEntitySchema)
const studentRepository = source.getRepository<StudentProfileEntity>(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
}
+147 -15
View File
@@ -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<ClassEntity> {
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<ClassEntity>
childRepository: Repository<StudentProfileEntity>
@@ -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<Child
return mapChildEntity(await hydrateStudentImages(storedProfile))
}
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
const entity = await repository.findOneBy({ id: profileId })
if (!entity) {
throw new Error('学生信息不存在')
}
async function generateStudentCommentForEntity(
entity: StudentProfileEntity,
repository: Repository<StudentProfileEntity>
): Promise<ChildProfile> {
const profile = mapChildEntity(entity)
const settings = await loadSettings()
@@ -451,6 +468,117 @@ export async function generateStudentComment(profileId: string): Promise<ChildPr
return mapChildEntity(await hydrateStudentImages(storedProfile))
}
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(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<StudentProfileEntity>(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<void> {
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<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
await manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema).delete({ classId })
await manager
.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
.delete({ classId })
await manager.getRepository<ClassEntity>(ClassEntitySchema).delete({ id: classId })
})
}
+10
View File
@@ -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
+29
View File
@@ -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
+42 -2
View File
@@ -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<BasicMutationResponse>
deleteStudentProfile: (profileId: string) => Promise<BasicMutationResponse>
@@ -301,6 +335,10 @@ type AppAPI = {
generateStudentComment: (payload: {
profileId: string
}) => Promise<StudentProfileMutationResponse>
generateStudentComments: (payload: {
classId?: string
profileIds?: string[]
}) => Promise<GenerateStudentCommentsResponse>
listModels: (payload: { baseUrl: string; apiKey: string }) => Promise<ModelListResponse>
testModelConnection: (modelConfig: ModelConfig) => Promise<TestModelConnectionResponse>
replaceClassProfiles: (payload: {
@@ -337,10 +375,12 @@ type AppAPI = {
openReport: (filePath: string) => Promise<BasicMutationResponse>
downloadReport: (id: string) => Promise<DownloadReportResponse>
downloadClassReports: (payload: { classId: string }) => Promise<DownloadClassReportsResponse>
onReportGenerationProgress: (
callback: (progress: ReportGenerationProgress) => void
onReportGenerationProgress: (callback: (progress: ReportGenerationProgress) => void) => () => void
onCommentGenerationProgress: (
callback: (progress: CommentGenerationProgress) => void
) => () => void
deleteReport: (id: string) => Promise<BasicMutationResponse>
deleteAllReports: () => Promise<DeleteAllReportsResponse>
}
declare global {
+34 -5
View File
@@ -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,7 +157,8 @@ const api = {
downloadReport: (id: string) => ipcRenderer.invoke('reports:download', id),
downloadClassReports: (payload: { classId: string }) =>
ipcRenderer.invoke('reports:download-class', payload),
onReportGenerationProgress: (callback: (progress: {
onReportGenerationProgress: (
callback: (progress: {
batchId: string
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
classId: string
@@ -166,14 +169,40 @@ const api = {
total: number
report?: unknown
error?: string
}) => void) => {
const listener = (_: Electron.IpcRendererEvent, progress: Parameters<typeof callback>[0]) =>
callback(progress)
}) => void
): (() => void) => {
const listener = (
_: Electron.IpcRendererEvent,
progress: Parameters<typeof callback>[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<typeof callback>[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
+1 -1
View File
@@ -19,7 +19,7 @@ function App(): React.JSX.Element {
<Route path="tools" element={<Navigate to="/tools/comments" replace />} />
<Route path="tools/image-paths" element={<PlaceholderPage title="生成图片路径" />} />
<Route path="tools/comments" element={<ToolsPage />} />
<Route path="tools/reports" element={<PlaceholderPage title="生成报告" />} />
<Route path="tools/reports" element={<Navigate to="/student/reports" replace />} />
<Route path="tools/convert" element={<PlaceholderPage title="格式转换" />} />
<Route path="tools/signature" element={<PlaceholderPage title="园长签名" />} />
<Route path="student" element={<Navigate to="/student/list" replace />} />
@@ -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 {
))}
</nav>
</div>
<div className="mt-auto p-4 max-[760px]:hidden">
<Card className="shadow-none">
<CardHeader className="p-4">
<CardDescription></CardDescription>
<CardTitle className="text-base">2026 </CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 text-sm text-muted-foreground">
20
</CardContent>
</Card>
</div>
</aside>
)
}
+130 -17
View File
@@ -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<HTMLInputElement>(null)
const photoZipInputRef = useRef<HTMLInputElement>(null)
const classPhotoInputRef = useRef<HTMLInputElement>(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<EditingClass | null>(null)
const [uploadingClass, setUploadingClass] = useState<ClassProfile | null>(null)
const [photoZipClass, setPhotoZipClass] = useState<ClassProfile | null>(null)
@@ -521,6 +529,78 @@ export function ClassPage(): React.JSX.Element {
}
}
async function handleGenerateClassComments(classItem: ClassProfile): Promise<void> {
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<void> {
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 {
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => handleOpenEdit(classItem)}>
<Pencil />
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openUploadDrawer(classItem)}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openPhotoZipPicker(classItem)}>
<Images />
ZIP
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem
disabled={exportingId === classItem.id}
onSelect={() => handleExportClass(classItem)}
>
<FolderPlus />
{exportingId === classItem.id ? '导出中' : '导出 ZIP'}
{exportingId === classItem.id ? '导出中' : '导出图片ZIP'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleOpenEdit(classItem)}>
<Pencil />
<DropdownMenuItem onSelect={() => openPhotoZipPicker(classItem)}>
<Images />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem
disabled={generatingCommentClassId === classItem.id}
onSelect={() => handleGenerateClassComments(classItem)}
>
<Bot />
{generatingCommentClassId === classItem.id
? '评语生成中'
: '一键生成评语'}
</DropdownMenuItem>
<DropdownMenuItem
disabled={generatingReportClassId === classItem.id}
onSelect={() => handleGenerateClassReports(classItem)}
>
<Presentation />
{generatingReportClassId === classItem.id
? '报告生成中'
: '一键生成报告'}
</DropdownMenuItem>
<DropdownMenuItem
disabled={downloadingReportClassId === classItem.id}
onSelect={() => handleDownloadClassReports(classItem)}
>
<Download />
{downloadingReportClassId === classItem.id
? '报告打包中'
: '一键下载班级报告'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+250 -108
View File
@@ -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<ClassProfile[]>([])
const [profiles, setProfiles] = useState<ChildProfile[]>([])
const [templates, setTemplates] = useState<TemplateItem[]>([])
const [reports, setReports] = useState<ReportItem[]>([])
const [progressCards, setProgressCards] = useState<ReportGenerationProgress[]>([])
const [generationProgress, setGenerationProgress] = useState<ReportGenerationProgress | null>(
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<ReportFormat | 'all'>('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<string | null>(null)
const [deletingAllReports, setDeletingAllReports] = useState(false)
const [pendingStudentId, setPendingStudentId] = useState<string | null>(
locationState?.profileId ?? null
)
useEffect(() => {
async function loadPageData(): Promise<void> {
@@ -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
}
queueMicrotask(() => {
setSelectedClassId(profile.classId)
setQuery(profile.name || profile.englishName)
const state = location.state as { profileId?: string; mode?: 'view' } | null
if (state?.mode === 'view') {
if (locationState?.mode === 'view') {
setPendingStudentId(null)
return
}
if (selectedTemplateId) {
void handleGenerateReports([profile.id])
void handleGenerateReports({
classId: profile.classId,
studentIds: [profile.id]
})
setPendingStudentId(null)
}
}, [location.state, pendingStudentId, profiles, selectedTemplateId])
})
}, [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
}
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<void> {
async function handleGenerateReports(
input: { classId?: string; studentIds?: string[] } = {}
): Promise<void> {
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<void> {
setDownloadingId(report.id)
@@ -283,30 +341,6 @@ export function ReportPage(): React.JSX.Element {
}
}
async function handleDownloadClassReports(): Promise<void> {
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<void> {
setOpeningId(report.id)
@@ -341,6 +375,30 @@ export function ReportPage(): React.JSX.Element {
}
}
async function handleDeleteAllReports(): Promise<void> {
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 (
<>
<section className="space-y-4 bg-white px-4 py-4">
@@ -406,70 +464,103 @@ export function ReportPage(): React.JSX.Element {
</option>
))}
</Select>
<Button
disabled={generating || selectedClassId === 'all' || !selectedTemplateId}
onClick={() => handleGenerateReports()}
>
<Wand2 />
{generating ? '生成中' : '生成当前班级报告'}
</Button>
</div>
<div className="flex justify-end">
<Button
variant="outline"
disabled={downloadingClass || selectedClassId === 'all'}
onClick={handleDownloadClassReports}
>
<Download />
{downloadingClass ? '打包中' : '下载当前班级报告'}
<div className="flex flex-col justify-end gap-2 sm:flex-row">
<Button disabled={generating || !selectedTemplateId} onClick={openGenerateDrawer}>
<Wand2 />
{generating ? '生成中' : '生成班级报告'}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={
deletingAllReports || reports.length === 0 || Boolean(generationProgress)
}
>
<Trash2 />
{deletingAllReports ? '删除中' : '删除全部报告'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAllReports}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<ScrollArea className="h-140">
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
{progressCards.map((progress) => (
<Card
key={`${progress.batchId}-${progress.studentId}`}
className="border-primary/40 bg-primary/5 shadow-none"
>
<CardHeader className="space-y-3">
<div className="flex items-start justify-between gap-3">
{generationProgress ? (
<Card className="border-primary/30 bg-primary/5 shadow-none">
<CardContent className="grid gap-4 p-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary/10 text-primary">
<Wand2 className="size-5" />
</div>
<Badge variant={progress.status === 'student-failed' ? 'destructive' : 'warning'}>
{progress.status === 'student-failed' ? '生成失败' : '生成中'}
<div className="min-w-0 space-y-2">
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">
{generationProgress.studentName
? `正在生成 ${generationProgress.studentName} 的报告`
: '正在准备生成报告'}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{generationProgress.className} · {generationProgress.current}/
{generationProgress.total}
</p>
</div>
<Badge
variant={
generationProgress.status === 'student-failed' ? 'destructive' : 'warning'
}
>
{generationProgress.status === 'student-failed' ? '本份失败' : '生成中'}
</Badge>
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
{progress.studentName || '学生'}
</CardTitle>
<CardDescription className="mt-2 truncate">
{progress.className} · {progress.current}/{progress.total}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-2 overflow-hidden rounded-full bg-background">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.min(
Math.max((progress.current / Math.max(progress.total, 1)) * 100, 8),
Math.max(
(generationProgress.current / Math.max(generationProgress.total, 1)) *
100,
generationProgress.current > 0 ? 6 : 0
),
100
)}%`
}}
/>
</div>
{progress.error ? (
<p className="mt-3 text-sm text-destructive">{progress.error}</p>
{generationProgress.error ? (
<p className="text-sm text-destructive">{generationProgress.error}</p>
) : null}
</div>
<div className="text-left md:text-right">
<p className="text-2xl font-semibold text-primary">
{Math.round(
(generationProgress.current / Math.max(generationProgress.total, 1)) * 100
)}
%
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</CardContent>
</Card>
))}
) : null}
<ScrollArea className="h-140">
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
{filteredReports.map((report) => {
const ReportIcon = getReportIcon(report.format)
@@ -555,6 +646,57 @@ export function ReportPage(): React.JSX.Element {
</ScrollArea>
</section>
<Drawer direction="right" open={generateDrawerOpen} onOpenChange={setGenerateDrawerOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle></DrawerTitle>
<DrawerDescription className="mt-1">
使 PPTX
</DrawerDescription>
</DrawerHeader>
<div className="space-y-5 p-6">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="generate-report-class">
</label>
<Select
id="generate-report-class"
value={generateClassId}
onChange={(event) => setGenerateClassId(event.target.value)}
>
<option value=""></option>
{classes.map((classItem) => (
<option key={classItem.id} value={classItem.id}>
{classItem.name}
</option>
))}
</Select>
</div>
<div className="rounded-md bg-muted px-3 py-3 text-sm text-muted-foreground">
{templates.find((template) => template.id === selectedTemplateId)?.name ||
'未选择 PPTX 模板'}
</div>
</div>
<DrawerFooter>
<Button
disabled={generating || !generateClassId || !selectedTemplateId}
onClick={handleConfirmGenerateClassReports}
>
<Wand2 />
{generating ? '生成中' : '确认生成'}
</Button>
<Button
variant="outline"
disabled={generating}
onClick={() => setGenerateDrawerOpen(false)}
>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DashboardFooter />
</>
)
+104 -21
View File
@@ -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<string>('')
const [selectedProfileIds, setSelectedProfileIds] = useState<string[]>([])
const [generatingIds, setGeneratingIds] = useState<string[]>([])
const [commentProgress, setCommentProgress] = useState<CommentGenerationProgress | null>(null)
const [editingProfile, setEditingProfile] = useState<ChildProfile | null>(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<string, ClassProfile>()
@@ -282,28 +311,26 @@ export function StudentPage(): React.JSX.Element {
}
async function handleBatchGenerateComments(): Promise<void> {
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 (ok) {
successCount += 1
} else {
failedCount += 1
if (!response.ok) {
toast.error('批量生成评语失败', { description: response.message })
return
}
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 {
<div className="flex justify-end">
<Button
size="sm"
disabled={selectedProfileIds.length === 0 || generatingIds.length > 0}
disabled={selectedProfileIds.length === 0 || Boolean(commentProgress)}
onClick={handleBatchGenerateComments}
>
<Wand2 />
@@ -434,6 +462,64 @@ export function StudentPage(): React.JSX.Element {
</div>
</div>
{commentProgress ? (
<Card className="border-primary/30 bg-primary/5 shadow-none">
<CardContent className="grid gap-4 p-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary/10 text-primary">
<Bot className="size-5" />
</div>
<div className="min-w-0 space-y-2">
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">
{commentProgress.studentName
? `正在生成 ${commentProgress.studentName} 的评语`
: '正在准备生成评语'}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{commentProgress.className || '学生数据'} · {commentProgress.current}/
{commentProgress.total}
</p>
</div>
<Badge
variant={
commentProgress.status === 'student-failed' ? 'destructive' : 'warning'
}
>
{commentProgress.status === 'student-failed' ? '本份失败' : '生成中'}
</Badge>
</div>
<div className="h-2 overflow-hidden rounded-full bg-background">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.min(
Math.max(
(commentProgress.current / Math.max(commentProgress.total, 1)) * 100,
commentProgress.current > 0 ? 6 : 0
),
100
)}%`
}}
/>
</div>
{commentProgress.error ? (
<p className="text-sm text-destructive">{commentProgress.error}</p>
) : null}
</div>
<div className="text-left md:text-right">
<p className="text-2xl font-semibold text-primary">
{Math.round(
(commentProgress.current / Math.max(commentProgress.total, 1)) * 100
)}
%
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</CardContent>
</Card>
) : null}
<div className="overflow-hidden rounded-md border">
<Table className="min-w-[1080px]">
<TableHeader>
@@ -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
}
-2
View File
@@ -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 {
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openEditDrawer(template)}>
<Pencil />
+1 -23
View File
@@ -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: '学生管理',
+13
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["src/server/**/*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}