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"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="PrettierConfiguration"> <component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" /> <option name="myConfigurationMode" value="MANUAL" />
<option name="myRunOnReformat" value="true" />
</component> </component>
</project> </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())
+33 -20
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { import {
deleteAllReports,
deleteReport, deleteReport,
downloadClassReports, downloadClassReports,
downloadReport, downloadReport,
@@ -9,6 +10,7 @@ import {
openReport openReport
} from '../services/reportService' } from '../services/reportService'
import type { import type {
DeleteAllReportsResponse,
DeleteReportResponse, DeleteReportResponse,
DownloadClassReportsInput, DownloadClassReportsInput,
DownloadClassReportsResponse, DownloadClassReportsResponse,
@@ -69,32 +71,29 @@ export function registerReportIpc(): void {
} }
}) })
ipcMain.handle( ipcMain.handle('reports:download', async (_, id: string): Promise<DownloadReportResponse> => {
'reports:download', try {
async (_, id: string): Promise<DownloadReportResponse> => { const result = await downloadReport(id)
try {
const result = await downloadReport(id)
if (result.canceled) { if (result.canceled) {
return {
ok: false,
canceled: true,
message: '已取消下载'
}
}
return {
ok: true,
filePath: result.filePath
}
} catch (error) {
return { return {
ok: false, ok: false,
message: getErrorMessage(error) canceled: true,
message: '已取消下载'
} }
} }
return {
ok: true,
filePath: result.filePath
}
} catch (error) {
return {
ok: false,
message: getErrorMessage(error)
}
} }
) })
ipcMain.handle( ipcMain.handle(
'reports:download-class', '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)
}
}
})
} }
+35 -12
View File
@@ -5,6 +5,7 @@ import {
deleteStudentProfile, deleteStudentProfile,
getStudentProfile, getStudentProfile,
generateStudentComment, generateStudentComment,
generateStudentComments,
listStudentProfiles, listStudentProfiles,
loadStudentData, loadStudentData,
migrateStoredImagesToFiles, migrateStoredImagesToFiles,
@@ -20,6 +21,8 @@ import type {
DeleteStudentProfileResponse, DeleteStudentProfileResponse,
GenerateStudentCommentInput, GenerateStudentCommentInput,
GenerateStudentCommentResponse, GenerateStudentCommentResponse,
GenerateStudentCommentsInput,
GenerateStudentCommentsResponse,
GetStudentProfileResponse, GetStudentProfileResponse,
LoadStudentDataInput, LoadStudentDataInput,
ListStudentProfilesInput, ListStudentProfilesInput,
@@ -40,20 +43,23 @@ export function registerStudentIpc(): void {
console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error) console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error)
}) })
ipcMain.handle('student:load', async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => { ipcMain.handle(
try { 'student:load',
const studentData = await loadStudentData(payload) async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
return { try {
ok: true, const studentData = await loadStudentData(payload)
...studentData return {
} ok: true,
} catch (error) { ...studentData
return { }
ok: false, } catch (error) {
message: getErrorMessage(error) return {
ok: false,
message: getErrorMessage(error)
}
} }
} }
}) )
ipcMain.handle( ipcMain.handle(
'student:list', '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( ipcMain.handle(
'student:save-classes', 'student:save-classes',
async (_, classes: ClassProfile[]): Promise<SaveClassesResponse> => { async (_, classes: ClassProfile[]): Promise<SaveClassesResponse> => {
+110 -282
View File
@@ -6,7 +6,7 @@ import 'reflect-metadata'
import { app, BrowserWindow, dialog, shell } from 'electron' import { app, BrowserWindow, dialog, shell } from 'electron'
import JSZip from 'jszip' import JSZip from 'jszip'
import { PPTXTemplater } from 'node-pptx-templater' 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 { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
import { ReportEntitySchema, type ReportEntity } from '../entities/ReportEntity' import { ReportEntitySchema, type ReportEntity } from '../entities/ReportEntity'
@@ -24,11 +24,9 @@ import type {
} from '../types/report' } from '../types/report'
import { getAppDataSource } from './databaseService' import { getAppDataSource } from './databaseService'
import { readImageBuffer } from './imageStorageService' import { readImageBuffer } from './imageStorageService'
import { getLogger } from './loggerService'
const REPORT_FOLDER_NAME = 'reports' const REPORT_FOLDER_NAME = 'reports'
const TEMPLATE_FOLDER_NAME = 'templates' const TEMPLATE_FOLDER_NAME = 'templates'
const logger = getLogger('report')
type ReportStudentData = { type ReportStudentData = {
id: string id: string
@@ -48,15 +46,16 @@ type ReportStudentData = {
game: string game: string
favoriteFoods: string favoriteFoods: string
food: string food: string
meImage: string
workImage1: string
workImage2: string
traits: string traits: string
comment: string comment: string
comments: string comments: string
teacherName: string teacherName: string
} }
type StudentProfileWithAliases = StudentProfileEntity & {
studentName?: string
}
function getReportStoragePath(): string { function getReportStoragePath(): string {
return join(app.getPath('userData'), REPORT_FOLDER_NAME) return join(app.getPath('userData'), REPORT_FOLDER_NAME)
} }
@@ -157,6 +156,12 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 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 { function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData {
const friends = parseJsonList(student.friends).join('、') || ' ' const friends = parseJsonList(student.friends).join('、') || ' '
const hobbies = parseJsonList(student.hobbies).join('、') || ' ' const hobbies = parseJsonList(student.hobbies).join('、') || ' '
@@ -169,7 +174,7 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity):
id: student.id, id: student.id,
classId: student.classId, classId: student.classId,
className: student.className, className: student.className,
name: student.name || '未填写', name: getStudentName(student) || '未填写',
englishName: student.englishName || ' ', englishName: student.englishName || ' ',
sex: student.gender || '男', sex: student.gender || '男',
gender: student.gender || '男', gender: student.gender || '男',
@@ -183,9 +188,6 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity):
game: favoriteGames, game: favoriteGames,
favoriteFoods, favoriteFoods,
food: favoriteFoods, food: favoriteFoods,
meImage: student.meImage || ' ',
workImage1: student.workImage1 || ' ',
workImage2: student.workImage2 || ' ',
traits: student.traits || ' ', traits: student.traits || ' ',
comment: student.comment || '暂无评语', comment: student.comment || '暂无评语',
comments: student.comment || '暂无评语', comments: student.comment || '暂无评语',
@@ -210,7 +212,10 @@ function parseTeacherNames(value: string): string[] {
return value.trim() ? [value.trim()] : [] return value.trim() ? [value.trim()] : []
} }
function buildTextReplacements(studentData: ReportStudentData, classItem: ClassEntity): Record<string, string> { function buildTextReplacements(
studentData: ReportStudentData,
classItem: ClassEntity
): Record<string, string> {
return { return {
name: studentData.name, name: studentData.name,
class: classItem.name, class: classItem.name,
@@ -233,9 +238,7 @@ function buildTextReplacements(studentData: ReportStudentData, classItem: ClassE
game: studentData.game, game: studentData.game,
favoriteFoods: studentData.favoriteFoods, favoriteFoods: studentData.favoriteFoods,
food: studentData.food, food: studentData.food,
me_image: studentData.meImage, traits: studentData.traits
work_image_1: studentData.workImage1,
work_image_2: studentData.workImage2
} }
} }
@@ -300,8 +303,6 @@ type PptImageReplacementTarget = {
} }
type PptImageReplacementPlan = { type PptImageReplacementPlan = {
placeholderName: string
fieldKey: string
imageValue?: string | null imageValue?: string | null
target: PptImageReplacementTarget target: PptImageReplacementTarget
} }
@@ -370,56 +371,6 @@ function getImageIdentifier(image: PptImageInfo, fallback: string): string {
return String(image.id ?? image.name ?? image.relationshipId ?? fallback) 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[] { function getSlidePaths(zip: JSZip): string[] {
return Object.keys(zip.files) return Object.keys(zip.files)
.filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)) .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
@@ -468,7 +419,11 @@ function replaceTextNodeValue(
return `${match[1]}${nextValue}${match[3]}` 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) => { return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
if (!hasShapeName(shapeXml, shapeName)) { if (!hasShapeName(shapeXml, shapeName)) {
return shapeXml 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 { function getTextNodeValue(textNode: string): string {
return textNode.match(/^<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>$/)?.[1] ?? '' return textNode.match(/^<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>$/)?.[1] ?? ''
} }
@@ -534,10 +507,7 @@ function replaceShapeInlinePlaceholdersPreservingStyle(
let nextCombinedText = combinedText let nextCombinedText = combinedText
for (const [placeholder, text] of Object.entries(replacements)) { for (const [placeholder, text] of Object.entries(replacements)) {
const placeholderPattern = new RegExp( const placeholderPattern = new RegExp(`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`, 'g')
`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`,
'g'
)
nextCombinedText = nextCombinedText.replace(placeholderPattern, escapeXml(text)) nextCombinedText = nextCombinedText.replace(placeholderPattern, escapeXml(text))
} }
@@ -560,12 +530,10 @@ function replaceShapeInlinePlaceholdersPreservingStyle(
async function replacePptxTextPreservingStyle( async function replacePptxTextPreservingStyle(
filePath: string, filePath: string,
replacements: Record<string, string>, replacements: Record<string, string>
traceId: string
): Promise<void> { ): Promise<void> {
const fileBuffer = await readFile(filePath) const fileBuffer = await readFile(filePath)
const zip = await JSZip.loadAsync(fileBuffer) const zip = await JSZip.loadAsync(fileBuffer)
let replacementCount = 0
for (const slidePath of getSlidePaths(zip)) { for (const slidePath of getSlidePaths(zip)) {
const slideFile = zip.file(slidePath) const slideFile = zip.file(slidePath)
@@ -580,11 +548,11 @@ async function replacePptxTextPreservingStyle(
for (const [placeholder, text] of Object.entries(replacements)) { for (const [placeholder, text] of Object.entries(replacements)) {
slideXml = replaceInlineTextPlaceholderPreservingStyle(slideXml, placeholder, text) slideXml = replaceInlineTextPlaceholderPreservingStyle(slideXml, placeholder, text)
slideXml = replaceShapeTextPreservingStyle(slideXml, placeholder, text) slideXml = replaceShapeTextPreservingStyle(slideXml, placeholder, text)
slideXml = replaceExactTextPlaceholderPreservingStyle(slideXml, placeholder, text)
} }
slideXml = replaceShapeInlinePlaceholdersPreservingStyle(slideXml, replacements) slideXml = replaceShapeInlinePlaceholdersPreservingStyle(slideXml, replacements)
if (slideXml !== originalSlideXml) { if (slideXml !== originalSlideXml) {
replacementCount += 1
zip.file(slidePath, slideXml) zip.file(slidePath, slideXml)
} }
} }
@@ -594,19 +562,13 @@ async function replacePptxTextPreservingStyle(
compression: 'DEFLATE' compression: 'DEFLATE'
}) })
await writeFile(filePath, outputBuffer) await writeFile(filePath, outputBuffer)
logger.debug(`[${traceId}] 保留样式的 PPTX 文本替换完成`, {
touchedSlideCount: replacementCount,
replacementKeys: Object.keys(replacements)
})
} }
function findImageReplacementTargets( function findImageReplacementTargets(
ppt: PPTXTemplater, ppt: PPTXTemplater,
slideNumber: number, slideNumber: number,
placeholderName: string, placeholderName: string,
usedImageKeys: Set<string>, usedImageKeys: Set<string>
traceId: string
): PptImageReplacementTarget[] { ): PptImageReplacementTarget[] {
const normalizedPlaceholderName = normalizePptObjectName(placeholderName) const normalizedPlaceholderName = normalizePptObjectName(placeholderName)
const slideImages = getSlideImages(ppt, slideNumber) const slideImages = getSlideImages(ppt, slideNumber)
@@ -626,11 +588,6 @@ function findImageReplacementTargets(
.filter((target) => !usedImageKeys.has(target.imageKey)) .filter((target) => !usedImageKeys.has(target.imageKey))
if (directMatchingTargets.length > 0) { if (directMatchingTargets.length > 0) {
logger.debug(`[${traceId}] 图片占位符直接命中图片对象`, {
slideNumber,
placeholderName,
targets: directMatchingTargets
})
return directMatchingTargets return directMatchingTargets
} }
@@ -664,92 +621,42 @@ function findImageReplacementTargets(
return first.centerDistance - second.centerDistance 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 return fallbackTargets
} }
async function replaceImageIfExists( async function replaceImageIfExists(
ppt: PPTXTemplater, ppt: PPTXTemplater,
replacementPlan: PptImageReplacementPlan, replacementPlan: PptImageReplacementPlan,
usedImageKeys: Set<string>, usedImageKeys: Set<string>
traceId: string
): Promise<void> { ): Promise<void> {
const imageBuffer = await readImageBuffer(replacementPlan.imageValue) const imageBuffer = await readImageBuffer(replacementPlan.imageValue)
if (!imageBuffer) { if (!imageBuffer) {
logger.warn(`[${traceId}] 图片替换跳过:没有可读取的图片数据`, {
placeholderName: replacementPlan.placeholderName,
fieldKey: replacementPlan.fieldKey,
imageValue: describeImageValue(replacementPlan.imageValue)
})
return return
} }
if (usedImageKeys.has(replacementPlan.target.imageKey)) { if (usedImageKeys.has(replacementPlan.target.imageKey)) {
logger.warn(`[${traceId}] 图片替换跳过:目标图片对象已经替换过`, {
placeholderName: replacementPlan.placeholderName,
fieldKey: replacementPlan.fieldKey,
target: replacementPlan.target
})
return return
} }
try { 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 await ppt
.useSlide(replacementPlan.target.slideNumber) .useSlide(replacementPlan.target.slideNumber)
.replaceImage(replacementPlan.target.imageIdentifier, imageBuffer) .replaceImage(replacementPlan.target.imageIdentifier, imageBuffer)
usedImageKeys.add(replacementPlan.target.imageKey) usedImageKeys.add(replacementPlan.target.imageKey)
logger.info(`[${traceId}] node-pptx-templater.replaceImage 图片替换成功`, {
placeholderName: replacementPlan.placeholderName,
fieldKey: replacementPlan.fieldKey,
target: replacementPlan.target
})
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '' const message = error instanceof Error ? error.message : ''
if (!message.includes('not found')) { if (!message.includes('not found')) {
logger.error(`[${traceId}] node-pptx-templater.replaceImage 图片替换失败`, {
placeholderName: replacementPlan.placeholderName,
fieldKey: replacementPlan.fieldKey,
target: replacementPlan.target,
error
})
throw error throw error
} }
logger.warn(`[${traceId}] 图片替换跳过:目标在当前页未找到`, {
placeholderName: replacementPlan.placeholderName,
fieldKey: replacementPlan.fieldKey,
target: replacementPlan.target,
message
})
} }
} }
function buildImageReplacementPlans( function buildImageReplacementPlans(
ppt: PPTXTemplater, ppt: PPTXTemplater,
replacements: Record<string, string>, replacements: Record<string, string>,
getImageValue: (fieldKey: string) => string | null | undefined, getImageValue: (fieldKey: string) => string | null | undefined
traceId: string
): PptImageReplacementPlan[] { ): PptImageReplacementPlan[] {
const plans: PptImageReplacementPlan[] = [] const plans: PptImageReplacementPlan[] = []
const usedImageKeys = new Set<string>() const usedImageKeys = new Set<string>()
@@ -758,17 +665,7 @@ function buildImageReplacementPlans(
for (const [placeholderName, fieldKey] of Object.entries(replacements)) { for (const [placeholderName, fieldKey] of Object.entries(replacements)) {
const imageValue = getImageValue(fieldKey) const imageValue = getImageValue(fieldKey)
logger.debug(`[${traceId}] 开始规划图片占位符`, {
placeholderName,
fieldKey,
imageValue: describeImageValue(imageValue)
})
if (!imageValue) { if (!imageValue) {
logger.warn(`[${traceId}] 图片占位符跳过:字段没有图片`, {
placeholderName,
fieldKey
})
continue continue
} }
@@ -783,16 +680,10 @@ function buildImageReplacementPlans(
ppt, ppt,
slideNumber, slideNumber,
placeholderName, placeholderName,
usedImageKeys, usedImageKeys
traceId
)[0] )[0]
if (!target) { if (!target) {
logger.debug(`[${traceId}] 当前页没有找到可替换图片目标`, {
slideNumber,
placeholderName,
fieldKey
})
continue continue
} }
@@ -801,50 +692,14 @@ function buildImageReplacementPlans(
// 如果边扫描边替换,后续 getImages() 看到的 targetPath 可能已经变了, // 如果边扫描边替换,后续 getImages() 看到的 targetPath 可能已经变了,
// 容易让第二个占位符匹配到刚被第一个占位符替换过的图片。 // 容易让第二个占位符匹配到刚被第一个占位符替换过的图片。
plans.push({ plans.push({
placeholderName,
fieldKey,
imageValue, imageValue,
target target
}) })
usedImageKeys.add(target.imageKey) usedImageKeys.add(target.imageKey)
usedFieldSlideKeys.add(fieldSlideKey) 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 return plans
} }
@@ -852,20 +707,9 @@ async function buildPptxReport(
template: TemplateEntity, template: TemplateEntity,
outputPath: string, outputPath: string,
student: StudentProfileEntity, student: StudentProfileEntity,
classItem: ClassEntity, classItem: ClassEntity
traceId: string
): Promise<void> { ): Promise<void> {
const templateFilePath = resolveTemplateFilePath(template.filePath) 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, { const ppt = await PPTXTemplater.load(templateFilePath, {
logLevel: 'silent' logLevel: 'silent'
@@ -874,33 +718,21 @@ async function buildPptxReport(
const textReplacements = buildTextReplacements(studentData, classItem) const textReplacements = buildTextReplacements(studentData, classItem)
const replacedImageKeys = new Set<string>() const replacedImageKeys = new Set<string>()
logger.info(`[${traceId}] PPTX 模板加载完成`, {
slideCount: ppt.slideCount,
textReplacementKeys: Object.keys(textReplacements),
imageReplacementAliases: buildImageReplacements()
})
logSlideObjects(ppt, traceId)
const imageReplacementPlans = buildImageReplacementPlans( const imageReplacementPlans = buildImageReplacementPlans(
ppt, ppt,
buildImageReplacements(), buildImageReplacements(),
(fieldKey) => (fieldKey) =>
fieldKey === 'familyPhoto' fieldKey === 'familyPhoto'
? classItem.familyPhoto ? classItem.familyPhoto
: student[fieldKey as 'meImage' | 'workImage1' | 'workImage2'], : student[fieldKey as 'meImage' | 'workImage1' | 'workImage2']
traceId
) )
for (const imageReplacementPlan of imageReplacementPlans) { for (const imageReplacementPlan of imageReplacementPlans) {
await replaceImageIfExists(ppt, imageReplacementPlan, replacedImageKeys, traceId) await replaceImageIfExists(ppt, imageReplacementPlan, replacedImageKeys)
} }
await ppt.saveToFile(outputPath) await ppt.saveToFile(outputPath)
await replacePptxTextPreservingStyle(outputPath, textReplacements, traceId) await replacePptxTextPreservingStyle(outputPath, textReplacements)
logger.info(`[${traceId}] PPTX 报告保存完成`, {
outputPath,
replacedImageCount: replacedImageKeys.size
})
} }
async function removeFileIfExists(filePath: string): Promise<void> { async function removeFileIfExists(filePath: string): Promise<void> {
@@ -953,6 +785,8 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
throw new Error('请选择班级') throw new Error('请选择班级')
} }
const reportTemplate = template
const reportClass = classItem
const selectedIds = new Set(input.studentIds ?? []) const selectedIds = new Set(input.studentIds ?? [])
const students = await studentRepository.find({ const students = await studentRepository.find({
where: { where: {
@@ -976,71 +810,50 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
const reports: ReportItem[] = [] const reports: ReportItem[] = []
const skipped: Array<{ studentName: string; reason: string }> = [] const skipped: Array<{ studentName: string; reason: string }> = []
const batchTraceId = randomUUID() 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({ sendReportProgress({
batchId: batchTraceId, batchId: batchTraceId,
status: 'started', status: 'started',
classId: classItem.id, classId: reportClass.id,
className: classItem.name, className: reportClass.name,
current: 0, current: 0,
total: targetStudents.length total: targetStudents.length
}) })
for (const [studentIndex, student] of targetStudents.entries()) { async function generateStudentReport(student: StudentProfileEntity): Promise<void> {
const studentName = student.name || student.englishName || '未命名学生' const studentName = getStudentName(student) || '未命名学生'
const title = `${classItem.name} ${studentName} 幼儿成长报告` const title = `${reportClass.name} ${studentName} 幼儿成长报告`
const reportId = randomUUID() const reportId = randomUUID()
const fileName = `${sanitizeFileName(title)}${template.fileExtension}` const fileName = `${sanitizeFileName(title)}${reportTemplate.fileExtension}`
const outputPath = join(getReportStoragePath(), `${reportId}${template.fileExtension}`) const outputPath = join(getReportStoragePath(), `${reportId}${reportTemplate.fileExtension}`)
const traceId = `${batchTraceId}:${student.id}`
try { try {
sendReportProgress({ sendReportProgress({
batchId: batchTraceId, batchId: batchTraceId,
status: 'student-started', status: 'student-started',
classId: classItem.id, classId: reportClass.id,
className: classItem.name, className: reportClass.name,
studentId: student.id, studentId: student.id,
studentName, studentName,
current: studentIndex + 1, current: completedCount,
total: targetStudents.length 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 = { const entity: ReportEntity = {
id: reportId, id: reportId,
title, title,
classId: classItem.id, classId: reportClass.id,
className: classItem.name, className: reportClass.name,
studentId: student.id, studentId: student.id,
studentName, studentName,
format: getFormatFromExtension(template.fileExtension), format: getFormatFromExtension(reportTemplate.fileExtension),
originalFileName: fileName, originalFileName: fileName,
filePath: outputPath, filePath: outputPath,
fileExtension: template.fileExtension, fileExtension: reportTemplate.fileExtension,
templateId: template.id, templateId: reportTemplate.id,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
} }
@@ -1049,42 +862,34 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
await studentRepository.update({ id: student.id }, { reportGenerated: true }) await studentRepository.update({ id: student.id }, { reportGenerated: true })
const reportItem = mapReportEntity(entity) const reportItem = mapReportEntity(entity)
reports.push(reportItem) reports.push(reportItem)
completedCount += 1
sendReportProgress({ sendReportProgress({
batchId: batchTraceId, batchId: batchTraceId,
status: 'student-finished', status: 'student-finished',
classId: classItem.id, classId: reportClass.id,
className: classItem.name, className: reportClass.name,
studentId: student.id, studentId: student.id,
studentName, studentName,
current: studentIndex + 1, current: completedCount,
total: targetStudents.length, total: targetStudents.length,
report: reportItem report: reportItem
}) })
logger.info(`[${traceId}] 单个学生报告生成成功`, {
reportId,
outputPath
})
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : '生成失败' const reason = error instanceof Error ? error.message : '生成失败'
logger.error(`[${traceId}] 单个学生报告生成失败`, {
studentId: student.id,
studentName,
reportId,
outputPath,
error
})
skipped.push({ skipped.push({
studentName, studentName,
reason reason
}) })
completedCount += 1
sendReportProgress({ sendReportProgress({
batchId: batchTraceId, batchId: batchTraceId,
status: 'student-failed', status: 'student-failed',
classId: classItem.id, classId: reportClass.id,
className: classItem.name, className: reportClass.name,
studentId: student.id, studentId: student.id,
studentName, studentName,
current: studentIndex + 1, current: completedCount,
total: targetStudents.length, total: targetStudents.length,
error: reason error: reason
}) })
@@ -1092,16 +897,15 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
} }
} }
logger.info(`[${batchTraceId}] 批量生成报告完成`, { for (const student of targetStudents) {
successCount: reports.length, await generateStudentReport(student)
skippedCount: skipped.length, }
skipped
})
sendReportProgress({ sendReportProgress({
batchId: batchTraceId, batchId: batchTraceId,
status: 'finished', status: 'finished',
classId: classItem.id, classId: reportClass.id,
className: classItem.name, className: reportClass.name,
current: targetStudents.length, current: targetStudents.length,
total: 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 repository = await getReportRepository()
const report = await repository.findOneBy({ id }) 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 }) 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 'reflect-metadata'
import { randomUUID } from 'crypto'
import { Brackets, type Repository } from 'typeorm' import { Brackets, type Repository } from 'typeorm'
import { BrowserWindow } from 'electron'
import { import {
StudentProfileEntitySchema, StudentProfileEntitySchema,
@@ -10,6 +12,8 @@ import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
import type { import type {
ChildProfile, ChildProfile,
ClassProfile, ClassProfile,
CommentGenerationProgress,
GenerateStudentCommentsInput,
ListStudentProfilesInput, ListStudentProfilesInput,
LoadStudentDataInput LoadStudentDataInput
} from '../types/student' } from '../types/student'
@@ -118,11 +122,14 @@ function mapClassEntity(entity: ClassEntity): ClassProfile {
name: entity.name, name: entity.name,
type: normalizeClassType(entity.type), type: normalizeClassType(entity.type),
teacherNames: parseTeacherNames(entity.teacherNames || entity.teacherName || ''), 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 { return {
id: entity.id, id: entity.id,
classId: entity.classId, classId: entity.classId,
@@ -154,7 +161,8 @@ async function mapClassProfile(profile: ClassProfile): Promise<ClassEntity> {
type: profile.type, type: profile.type,
teacherName: (profile.teacherNames ?? []).join(' ') || null, teacherName: (profile.teacherNames ?? []).join(' ') || null,
teacherNames: stringifyTeacherNames(profile.teacherNames ?? []), 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') ].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<{ async function getRepositories(): Promise<{
classRepository: Repository<ClassEntity> classRepository: Repository<ClassEntity>
childRepository: Repository<StudentProfileEntity> 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 { return {
classes: (await Promise.all(normalizedClasses.map(hydrateClassImages))).map(mapClassEntity), classes: hydratedClasses.map(mapClassEntity),
profiles: hydratedProfiles.map((profile) => mapChildEntity(profile, { includeImages })) profiles: hydratedProfiles.map((profile) => mapChildEntity(profile, { includeImages }))
} }
} }
@@ -369,15 +391,10 @@ export async function updateStudentProfile(profile: ChildProfile): Promise<Child
return mapChildEntity(await hydrateStudentImages(storedProfile)) return mapChildEntity(await hydrateStudentImages(storedProfile))
} }
export async function generateStudentComment(profileId: string): Promise<ChildProfile> { async function generateStudentCommentForEntity(
const source = await getAppDataSource() entity: StudentProfileEntity,
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema) repository: Repository<StudentProfileEntity>
const entity = await repository.findOneBy({ id: profileId }) ): Promise<ChildProfile> {
if (!entity) {
throw new Error('学生信息不存在')
}
const profile = mapChildEntity(entity) const profile = mapChildEntity(entity)
const settings = await loadSettings() const settings = await loadSettings()
@@ -451,6 +468,117 @@ export async function generateStudentComment(profileId: string): Promise<ChildPr
return mapChildEntity(await hydrateStudentImages(storedProfile)) 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> { export async function saveClasses(classes: ClassProfile[]): Promise<void> {
const source = await getAppDataSource() const source = await getAppDataSource()
@@ -491,7 +619,9 @@ export async function migrateLocalStudentData(
} }
await classRepository.save(await Promise.all(classes.map(mapClassProfile))) 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() const source = await getAppDataSource()
await source.transaction(async (manager) => { 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 }) await manager.getRepository<ClassEntity>(ClassEntitySchema).delete({ id: classId })
}) })
} }
+10
View File
@@ -74,6 +74,16 @@ export type DeleteReportResponse =
message: string message: string
} }
export type DeleteAllReportsResponse =
| {
ok: true
deletedCount: number
}
| {
ok: false
message: string
}
export type DownloadReportResponse = export type DownloadReportResponse =
| { | {
ok: true ok: true
+29
View File
@@ -93,6 +93,24 @@ export type GenerateStudentCommentInput = {
profileId: string 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 = export type GenerateStudentCommentResponse =
| { | {
ok: true ok: true
@@ -103,6 +121,17 @@ export type GenerateStudentCommentResponse =
message: string message: string
} }
export type GenerateStudentCommentsResponse =
| {
ok: true
profiles: ChildProfile[]
skipped: Array<{ studentName: string; reason: string }>
}
| {
ok: false
message: string
}
export type SaveClassesResponse = export type SaveClassesResponse =
| { | {
ok: true ok: true
+42 -2
View File
@@ -93,6 +93,19 @@ type ReportGenerationProgress = {
error?: string 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 = { type ChildProfile = {
id: string id: string
classId: string classId: string
@@ -258,6 +271,17 @@ type GenerateReportsResponse =
message: string message: string
} }
type GenerateStudentCommentsResponse =
| {
ok: true
profiles: ChildProfile[]
skipped: Array<{ studentName: string; reason: string }>
}
| {
ok: false
message: string
}
type DownloadReportResponse = type DownloadReportResponse =
| { | {
ok: true ok: true
@@ -281,6 +305,16 @@ type DownloadClassReportsResponse =
message: string message: string
} }
type DeleteAllReportsResponse =
| {
ok: true
deletedCount: number
}
| {
ok: false
message: string
}
type AppAPI = { type AppAPI = {
deleteClass: (classId: string) => Promise<BasicMutationResponse> deleteClass: (classId: string) => Promise<BasicMutationResponse>
deleteStudentProfile: (profileId: string) => Promise<BasicMutationResponse> deleteStudentProfile: (profileId: string) => Promise<BasicMutationResponse>
@@ -301,6 +335,10 @@ type AppAPI = {
generateStudentComment: (payload: { generateStudentComment: (payload: {
profileId: string profileId: string
}) => Promise<StudentProfileMutationResponse> }) => Promise<StudentProfileMutationResponse>
generateStudentComments: (payload: {
classId?: string
profileIds?: string[]
}) => Promise<GenerateStudentCommentsResponse>
listModels: (payload: { baseUrl: string; apiKey: string }) => Promise<ModelListResponse> listModels: (payload: { baseUrl: string; apiKey: string }) => Promise<ModelListResponse>
testModelConnection: (modelConfig: ModelConfig) => Promise<TestModelConnectionResponse> testModelConnection: (modelConfig: ModelConfig) => Promise<TestModelConnectionResponse>
replaceClassProfiles: (payload: { replaceClassProfiles: (payload: {
@@ -337,10 +375,12 @@ type AppAPI = {
openReport: (filePath: string) => Promise<BasicMutationResponse> openReport: (filePath: string) => Promise<BasicMutationResponse>
downloadReport: (id: string) => Promise<DownloadReportResponse> downloadReport: (id: string) => Promise<DownloadReportResponse>
downloadClassReports: (payload: { classId: string }) => Promise<DownloadClassReportsResponse> downloadClassReports: (payload: { classId: string }) => Promise<DownloadClassReportsResponse>
onReportGenerationProgress: ( onReportGenerationProgress: (callback: (progress: ReportGenerationProgress) => void) => () => void
callback: (progress: ReportGenerationProgress) => void onCommentGenerationProgress: (
callback: (progress: CommentGenerationProgress) => void
) => () => void ) => () => void
deleteReport: (id: string) => Promise<BasicMutationResponse> deleteReport: (id: string) => Promise<BasicMutationResponse>
deleteAllReports: () => Promise<DeleteAllReportsResponse>
} }
declare global { declare global {
+44 -15
View File
@@ -41,6 +41,8 @@ const api = {
}) => ipcRenderer.invoke('student:update-profile', profile), }) => ipcRenderer.invoke('student:update-profile', profile),
generateStudentComment: (payload: { profileId: string }) => generateStudentComment: (payload: { profileId: string }) =>
ipcRenderer.invoke('student:generate-comment', payload), ipcRenderer.invoke('student:generate-comment', payload),
generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) =>
ipcRenderer.invoke('student:generate-comments', payload),
listModels: (payload: { baseUrl: string; apiKey: string }) => listModels: (payload: { baseUrl: string; apiKey: string }) =>
ipcRenderer.invoke('models:list', payload), ipcRenderer.invoke('models:list', payload),
testModelConnection: (modelConfig: { testModelConnection: (modelConfig: {
@@ -155,25 +157,52 @@ const api = {
downloadReport: (id: string) => ipcRenderer.invoke('reports:download', id), downloadReport: (id: string) => ipcRenderer.invoke('reports:download', id),
downloadClassReports: (payload: { classId: string }) => downloadClassReports: (payload: { classId: string }) =>
ipcRenderer.invoke('reports:download-class', payload), ipcRenderer.invoke('reports:download-class', payload),
onReportGenerationProgress: (callback: (progress: { onReportGenerationProgress: (
batchId: string callback: (progress: {
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' batchId: string
classId: string status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
className: string classId: string
studentId?: string className: string
studentName?: string studentId?: string
current: number studentName?: string
total: number current: number
report?: unknown total: number
error?: string report?: unknown
}) => void) => { error?: string
const listener = (_: Electron.IpcRendererEvent, progress: Parameters<typeof callback>[0]) => }) => void
callback(progress) ): (() => void) => {
const listener = (
_: Electron.IpcRendererEvent,
progress: Parameters<typeof callback>[0]
): void => callback(progress)
ipcRenderer.on('reports:generation-progress', listener) ipcRenderer.on('reports:generation-progress', listener)
return () => ipcRenderer.removeListener('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 // 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" element={<Navigate to="/tools/comments" replace />} />
<Route path="tools/image-paths" element={<PlaceholderPage title="生成图片路径" />} /> <Route path="tools/image-paths" element={<PlaceholderPage title="生成图片路径" />} />
<Route path="tools/comments" element={<ToolsPage />} /> <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/convert" element={<PlaceholderPage title="格式转换" />} />
<Route path="tools/signature" element={<PlaceholderPage title="园长签名" />} /> <Route path="tools/signature" element={<PlaceholderPage title="园长签名" />} />
<Route path="student" element={<Navigate to="/student/list" replace />} /> <Route path="student" element={<Navigate to="/student/list" replace />} />
@@ -1,14 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import { Link, useLocation } from 'react-router-dom' 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 { Separator } from '@renderer/components/ui/separator'
import { getOpenMenuIds, menuTree } from '@renderer/student/menu' import { getOpenMenuIds, menuTree } from '@renderer/student/menu'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
@@ -66,18 +58,6 @@ export function AppSidebar(): React.JSX.Element {
))} ))}
</nav> </nav>
</div> </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> </aside>
) )
} }
+130 -17
View File
@@ -1,19 +1,23 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
Bot,
Camera, Camera,
Construction, Construction,
Download,
FolderPlus, FolderPlus,
Images, Images,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Plus, Plus,
Presentation,
Search, Search,
Trash2, Trash2,
Upload, Upload,
X X
} from 'lucide-react' } from 'lucide-react'
import JSZip from 'jszip' import JSZip from 'jszip'
import { useNavigate } from 'react-router-dom'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter' import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter'
import { import {
@@ -66,11 +70,7 @@ import {
import { Input } from '@renderer/components/ui/input' import { Input } from '@renderer/components/ui/input'
import { ScrollArea } from '@renderer/components/ui/scroll-area' import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Select } from '@renderer/components/ui/select' import { Select } from '@renderer/components/ui/select'
import { import { classTypeOptions, createUuid, getClassTypeLabel } from '@renderer/student/classes'
classTypeOptions,
createUuid,
getClassTypeLabel
} from '@renderer/student/classes'
import { import {
readChildProfilesFromSpreadsheet, readChildProfilesFromSpreadsheet,
replaceProfilesForClass replaceProfilesForClass
@@ -127,7 +127,9 @@ function getImageMimeType(fileName: string): string | null {
return IMAGE_EXTENSION_MIME_TYPES[extension] ?? 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 const baseName = fileName
.replace(/\.[^.]+$/, '') .replace(/\.[^.]+$/, '')
.trim() .trim()
@@ -166,7 +168,9 @@ function findProfileForZipPath(
(part) => normalizeMatchName(part) === normalizeMatchName(targetClass.id) (part) => normalizeMatchName(part) === normalizeMatchName(targetClass.id)
) )
const relativeParts = classRootIndex >= 0 ? pathParts.slice(classRootIndex + 1) : pathParts 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) { for (let index = folderParts.length - 1; index >= 0; index -= 1) {
const matchedProfile = profileByName.get(normalizeMatchName(folderParts[index])) const matchedProfile = profileByName.get(normalizeMatchName(folderParts[index]))
@@ -180,6 +184,7 @@ function findProfileForZipPath(
} }
export function ClassPage(): React.JSX.Element { export function ClassPage(): React.JSX.Element {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const photoZipInputRef = useRef<HTMLInputElement>(null) const photoZipInputRef = useRef<HTMLInputElement>(null)
const classPhotoInputRef = useRef<HTMLInputElement>(null) const classPhotoInputRef = useRef<HTMLInputElement>(null)
@@ -193,6 +198,9 @@ export function ClassPage(): React.JSX.Element {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [createDrawerOpen, setCreateDrawerOpen] = useState(false) const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const [exportingId, setExportingId] = useState('') const [exportingId, setExportingId] = useState('')
const [generatingCommentClassId, setGeneratingCommentClassId] = useState('')
const [generatingReportClassId, setGeneratingReportClassId] = useState('')
const [downloadingReportClassId, setDownloadingReportClassId] = useState('')
const [editingClass, setEditingClass] = useState<EditingClass | null>(null) const [editingClass, setEditingClass] = useState<EditingClass | null>(null)
const [uploadingClass, setUploadingClass] = useState<ClassProfile | null>(null) const [uploadingClass, setUploadingClass] = useState<ClassProfile | null>(null)
const [photoZipClass, setPhotoZipClass] = 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 { function openPhotoZipPicker(classItem: ClassProfile): void {
setPhotoZipClass(classItem) setPhotoZipClass(classItem)
@@ -599,7 +679,10 @@ export function ClassPage(): React.JSX.Element {
} }
if (updatesByProfileId.size === 0) { 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 return
} }
@@ -724,25 +807,55 @@ export function ClassPage(): React.JSX.Element {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuItem onSelect={() => handleOpenEdit(classItem)}>
<Pencil />
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openUploadDrawer(classItem)}> <DropdownMenuItem onSelect={() => openUploadDrawer(classItem)}>
<Upload /> <Upload />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => openPhotoZipPicker(classItem)}> <DropdownMenuSeparator />
<Images /> <DropdownMenuLabel></DropdownMenuLabel>
ZIP
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
disabled={exportingId === classItem.id} disabled={exportingId === classItem.id}
onSelect={() => handleExportClass(classItem)} onSelect={() => handleExportClass(classItem)}
> >
<FolderPlus /> <FolderPlus />
{exportingId === classItem.id ? '导出中' : '导出 ZIP'} {exportingId === classItem.id ? '导出中' : '导出图片ZIP'}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleOpenEdit(classItem)}> <DropdownMenuItem onSelect={() => openPhotoZipPicker(classItem)}>
<Pencil /> <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> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
+271 -129
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
Download, Download,
ExternalLink, ExternalLink,
@@ -15,6 +15,17 @@ import { useLocation } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { DashboardFooter } from '@renderer/components/dashboard/DashboardFooter' 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 { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button' import { Button } from '@renderer/components/ui/button'
import { import {
@@ -24,6 +35,14 @@ import {
CardHeader, CardHeader,
CardTitle CardTitle
} from '@renderer/components/ui/card' } 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 { Input } from '@renderer/components/ui/input'
import { ScrollArea } from '@renderer/components/ui/scroll-area' import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Select } from '@renderer/components/ui/select' 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 { export function ReportPage(): React.JSX.Element {
const location = useLocation() const location = useLocation()
const locationState = location.state as ReportPageLocationState | null
const autoGenerateKeyRef = useRef('')
const [classes, setClasses] = useState<ClassProfile[]>([]) const [classes, setClasses] = useState<ClassProfile[]>([])
const [profiles, setProfiles] = useState<ChildProfile[]>([]) const [profiles, setProfiles] = useState<ChildProfile[]>([])
const [templates, setTemplates] = useState<TemplateItem[]>([]) const [templates, setTemplates] = useState<TemplateItem[]>([])
const [reports, setReports] = useState<ReportItem[]>([]) const [reports, setReports] = useState<ReportItem[]>([])
const [progressCards, setProgressCards] = useState<ReportGenerationProgress[]>([]) const [generationProgress, setGenerationProgress] = useState<ReportGenerationProgress | null>(
null
)
const [query, setQuery] = useState('') 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 [selectedFormat, setSelectedFormat] = useState<ReportFormat | 'all'>('all')
const [selectedTemplateId, setSelectedTemplateId] = useState('') const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [openingId, setOpeningId] = useState('') const [openingId, setOpeningId] = useState('')
const [downloadingId, setDownloadingId] = useState('') const [downloadingId, setDownloadingId] = useState('')
const [downloadingClass, setDownloadingClass] = useState(false)
const [deletingId, setDeletingId] = useState('') 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(() => { useEffect(() => {
async function loadPageData(): Promise<void> { async function loadPageData(): Promise<void> {
@@ -137,26 +171,7 @@ export function ReportPage(): React.JSX.Element {
useEffect(() => { useEffect(() => {
return window.api.onReportGenerationProgress((progress) => { return window.api.onReportGenerationProgress((progress) => {
setProgressCards((currentCards) => { setGenerationProgress(progress.status === 'finished' ? null : progress)
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))
})
if (progress.status === 'student-finished' && progress.report) { if (progress.status === 'student-finished' && progress.report) {
setReports((currentReports) => [progress.report!, ...currentReports]) 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(() => { useEffect(() => {
if (!pendingStudentId || profiles.length === 0) { if (!pendingStudentId || profiles.length === 0) {
return return
@@ -183,21 +190,50 @@ export function ReportPage(): React.JSX.Element {
return return
} }
setSelectedClassId(profile.classId) queueMicrotask(() => {
setQuery(profile.name || profile.englishName) 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') { if (selectedTemplateId) {
setPendingStudentId(null) 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 return
} }
if (selectedTemplateId) { autoGenerateKeyRef.current = autoGenerateKey
void handleGenerateReports([profile.id]) void handleGenerateReports({ classId: locationState.classId })
setPendingStudentId(null) }, [
} generating,
}, [location.state, pendingStudentId, profiles, selectedTemplateId]) locationState?.autoGenerate,
locationState?.classId,
selectedTemplateId,
selectedClassId
])
const pptxTemplates = templates.filter((template) => template.fileExtension === '.pptx') const pptxTemplates = templates.filter((template) => template.fileExtension === '.pptx')
@@ -224,13 +260,17 @@ export function ReportPage(): React.JSX.Element {
}) })
}, [query, reports, selectedClassId, selectedFormat]) }, [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) { if (!selectedTemplateId) {
toast.error('请先选择 PPTX 报告模板') toast.error('请先选择 PPTX 报告模板')
return return
} }
if (selectedClassId === 'all') { if (targetClassId === 'all') {
toast.error('请选择要生成报告的班级') toast.error('请选择要生成报告的班级')
return return
} }
@@ -240,8 +280,8 @@ export function ReportPage(): React.JSX.Element {
try { try {
const response = await window.api.generateReports({ const response = await window.api.generateReports({
templateId: selectedTemplateId, templateId: selectedTemplateId,
classId: selectedClassId, classId: targetClassId,
studentIds studentIds: input.studentIds
}) })
if (!response.ok) { if (!response.ok) {
@@ -251,7 +291,10 @@ export function ReportPage(): React.JSX.Element {
setReports((currentReports) => { setReports((currentReports) => {
const existingIds = new Set(currentReports.map((report) => report.id)) 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('报告生成完成', { toast.success('报告生成完成', {
description: description:
@@ -259,11 +302,26 @@ export function ReportPage(): React.JSX.Element {
? `成功 ${response.reports.length} 份,跳过 ${response.skipped.length}` ? `成功 ${response.reports.length} 份,跳过 ${response.skipped.length}`
: `成功 ${response.reports.length}` : `成功 ${response.reports.length}`
}) })
setGenerateDrawerOpen(false)
} finally { } finally {
setGenerating(false) 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> { async function handleDownloadReport(report: ReportItem): Promise<void> {
setDownloadingId(report.id) 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> { async function handleOpenReport(report: ReportItem): Promise<void> {
setOpeningId(report.id) 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 ( return (
<> <>
<section className="space-y-4 bg-white px-4 py-4"> <section className="space-y-4 bg-white px-4 py-4">
@@ -406,70 +464,103 @@ export function ReportPage(): React.JSX.Element {
</option> </option>
))} ))}
</Select> </Select>
<Button
disabled={generating || selectedClassId === 'all' || !selectedTemplateId}
onClick={() => handleGenerateReports()}
>
<Wand2 />
{generating ? '生成中' : '生成当前班级报告'}
</Button>
</div> </div>
<div className="flex justify-end"> <div className="flex flex-col justify-end gap-2 sm:flex-row">
<Button <Button disabled={generating || !selectedTemplateId} onClick={openGenerateDrawer}>
variant="outline" <Wand2 />
disabled={downloadingClass || selectedClassId === 'all'} {generating ? '生成中' : '生成班级报告'}
onClick={handleDownloadClassReports}
>
<Download />
{downloadingClass ? '打包中' : '下载当前班级报告'}
</Button> </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>
</div> </div>
{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>
<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="h-2 overflow-hidden rounded-full bg-background">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.min(
Math.max(
(generationProgress.current / Math.max(generationProgress.total, 1)) *
100,
generationProgress.current > 0 ? 6 : 0
),
100
)}%`
}}
/>
</div>
{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"> <ScrollArea className="h-140">
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3"> <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">
<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' ? '生成失败' : '生成中'}
</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-full rounded-full bg-primary transition-all"
style={{
width: `${Math.min(
Math.max((progress.current / Math.max(progress.total, 1)) * 100, 8),
100
)}%`
}}
/>
</div>
{progress.error ? (
<p className="mt-3 text-sm text-destructive">{progress.error}</p>
) : null}
</CardContent>
</Card>
))}
{filteredReports.map((report) => { {filteredReports.map((report) => {
const ReportIcon = getReportIcon(report.format) const ReportIcon = getReportIcon(report.format)
@@ -555,6 +646,57 @@ export function ReportPage(): React.JSX.Element {
</ScrollArea> </ScrollArea>
</section> </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 /> <DashboardFooter />
</> </>
) )
+104 -21
View File
@@ -75,6 +75,7 @@ import {
} from '@renderer/components/ui/table' } from '@renderer/components/ui/table'
import { getClassTypeLabel } from '@renderer/student/classes' import { getClassTypeLabel } from '@renderer/student/classes'
import type { ChildProfile, ClassProfile } from '@renderer/types/app' import type { ChildProfile, ClassProfile } from '@renderer/types/app'
import type { CommentGenerationProgress } from '@renderer/types/app'
function formatList(items: string[]): string { function formatList(items: string[]): string {
return items.length > 0 ? items.join('、') : '未填写' return items.length > 0 ? items.join('、') : '未填写'
@@ -120,6 +121,7 @@ export function StudentPage(): React.JSX.Element {
const [selectedId, setSelectedId] = useState<string>('') const [selectedId, setSelectedId] = useState<string>('')
const [selectedProfileIds, setSelectedProfileIds] = useState<string[]>([]) const [selectedProfileIds, setSelectedProfileIds] = useState<string[]>([])
const [generatingIds, setGeneratingIds] = useState<string[]>([]) const [generatingIds, setGeneratingIds] = useState<string[]>([])
const [commentProgress, setCommentProgress] = useState<CommentGenerationProgress | null>(null)
const [editingProfile, setEditingProfile] = useState<ChildProfile | null>(null) const [editingProfile, setEditingProfile] = useState<ChildProfile | null>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [selectedClassFilter, setSelectedClassFilter] = useState('all') const [selectedClassFilter, setSelectedClassFilter] = useState('all')
@@ -167,6 +169,33 @@ export function StudentPage(): React.JSX.Element {
loadPagedStudentProfiles() loadPagedStudentProfiles()
}, [page, pageSize, query, selectedClassFilter, studentDataReady]) }, [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 classOptions = useMemo(() => {
const classMap = new Map<string, ClassProfile>() const classMap = new Map<string, ClassProfile>()
@@ -282,28 +311,26 @@ export function StudentPage(): React.JSX.Element {
} }
async function handleBatchGenerateComments(): Promise<void> { async function handleBatchGenerateComments(): Promise<void> {
const selectedProfiles = profiles.filter((profile) => selectedProfileIds.includes(profile.id)) if (selectedProfileIds.length === 0) {
if (selectedProfiles.length === 0) {
toast.error('请先选择要生成评语的学生') toast.error('请先选择要生成评语的学生')
return return
} }
let successCount = 0 const response = await window.api.generateStudentComments({
let failedCount = 0 profileIds: selectedProfileIds
})
for (const profile of selectedProfiles) { if (!response.ok) {
const ok = await handleGenerateComment(profile) toast.error('批量生成评语失败', { description: response.message })
return
}
if (ok) { for (const profile of response.profiles) {
successCount += 1 updateProfileInPage(profile)
} else {
failedCount += 1
}
} }
toast.success('批量生成完成', { 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 { function handleGenerateReport(profile: ChildProfile): void {
navigate('/tools/reports', { navigate('/student/reports', {
state: { state: {
profileId: profile.id profileId: profile.id,
autoGenerate: true
} }
}) })
} }
@@ -324,7 +352,7 @@ export function StudentPage(): React.JSX.Element {
return return
} }
navigate('/tools/reports', { navigate('/student/reports', {
state: { state: {
profileId: profile.id, profileId: profile.id,
mode: 'view' mode: 'view'
@@ -425,7 +453,7 @@ export function StudentPage(): React.JSX.Element {
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
size="sm" size="sm"
disabled={selectedProfileIds.length === 0 || generatingIds.length > 0} disabled={selectedProfileIds.length === 0 || Boolean(commentProgress)}
onClick={handleBatchGenerateComments} onClick={handleBatchGenerateComments}
> >
<Wand2 /> <Wand2 />
@@ -434,6 +462,64 @@ export function StudentPage(): React.JSX.Element {
</div> </div>
</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"> <div className="overflow-hidden rounded-md border">
<Table className="min-w-[1080px]"> <Table className="min-w-[1080px]">
<TableHeader> <TableHeader>
@@ -803,10 +889,7 @@ function StudentProfileForm({
}) })
} }
function updateImageField( function updateImageField(field: 'meImage' | 'workImage1' | 'workImage2', file?: File): void {
field: 'meImage' | 'workImage1' | 'workImage2',
file?: File
): void {
if (!file) { if (!file) {
return return
} }
-2
View File
@@ -47,7 +47,6 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu' } from '@renderer/components/ui/dropdown-menu'
import { Input } from '@renderer/components/ui/input' import { Input } from '@renderer/components/ui/input'
@@ -470,7 +469,6 @@ export function TemplatePage(): React.JSX.Element {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openEditDrawer(template)}> <DropdownMenuItem onSelect={() => openEditDrawer(template)}>
<Pencil /> <Pencil />
+1 -23
View File
@@ -1,38 +1,16 @@
import { import {
Bot,
Database, Database,
FileArchive, FileArchive,
FileImage,
FileSignature,
Files, Files,
FolderPlus, FolderPlus,
FolderOpen, FolderOpen,
Presentation,
Settings, Settings,
UserRound, UserRound
Wand2
} from 'lucide-react' } from 'lucide-react'
import type { MenuItem } from '@renderer/types/app' import type { MenuItem } from '@renderer/types/app'
export const menuTree: MenuItem[] = [ 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', id: 'student',
title: '学生管理', title: '学生管理',
+13
View File
@@ -113,6 +113,19 @@ export type ReportGenerationProgress = {
error?: string 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 = { export type LogEntry = {
time: string time: string
level: LogLevel level: LogLevel
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["src/server/**/*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}