feat: improve report and comment workflows
This commit is contained in:
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.
Generated
+2
-1
@@ -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>
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import {
|
||||
deleteAllReports,
|
||||
deleteReport,
|
||||
downloadClassReports,
|
||||
downloadReport,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
openReport
|
||||
} from '../services/reportService'
|
||||
import type {
|
||||
DeleteAllReportsResponse,
|
||||
DeleteReportResponse,
|
||||
DownloadClassReportsInput,
|
||||
DownloadClassReportsResponse,
|
||||
@@ -69,32 +71,29 @@ export function registerReportIpc(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'reports:download',
|
||||
async (_, id: string): Promise<DownloadReportResponse> => {
|
||||
try {
|
||||
const result = await downloadReport(id)
|
||||
ipcMain.handle('reports:download', async (_, id: string): Promise<DownloadReportResponse> => {
|
||||
try {
|
||||
const result = await downloadReport(id)
|
||||
|
||||
if (result.canceled) {
|
||||
return {
|
||||
ok: false,
|
||||
canceled: true,
|
||||
message: '已取消下载'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
filePath: result.filePath
|
||||
}
|
||||
} catch (error) {
|
||||
if (result.canceled) {
|
||||
return {
|
||||
ok: false,
|
||||
message: getErrorMessage(error)
|
||||
canceled: true,
|
||||
message: '已取消下载'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
filePath: result.filePath
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: getErrorMessage(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'reports:download-class',
|
||||
@@ -137,4 +136,18 @@ export function registerReportIpc(): void {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('reports:delete-all', async (): Promise<DeleteAllReportsResponse> => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
deletedCount: await deleteAllReports()
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: getErrorMessage(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+35
-12
@@ -5,6 +5,7 @@ import {
|
||||
deleteStudentProfile,
|
||||
getStudentProfile,
|
||||
generateStudentComment,
|
||||
generateStudentComments,
|
||||
listStudentProfiles,
|
||||
loadStudentData,
|
||||
migrateStoredImagesToFiles,
|
||||
@@ -20,6 +21,8 @@ import type {
|
||||
DeleteStudentProfileResponse,
|
||||
GenerateStudentCommentInput,
|
||||
GenerateStudentCommentResponse,
|
||||
GenerateStudentCommentsInput,
|
||||
GenerateStudentCommentsResponse,
|
||||
GetStudentProfileResponse,
|
||||
LoadStudentDataInput,
|
||||
ListStudentProfilesInput,
|
||||
@@ -40,20 +43,23 @@ export function registerStudentIpc(): void {
|
||||
console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error)
|
||||
})
|
||||
|
||||
ipcMain.handle('student:load', async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
|
||||
try {
|
||||
const studentData = await loadStudentData(payload)
|
||||
return {
|
||||
ok: true,
|
||||
...studentData
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: getErrorMessage(error)
|
||||
ipcMain.handle(
|
||||
'student:load',
|
||||
async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
|
||||
try {
|
||||
const studentData = await loadStudentData(payload)
|
||||
return {
|
||||
ok: true,
|
||||
...studentData
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: getErrorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'student:list',
|
||||
@@ -123,6 +129,23 @@ export function registerStudentIpc(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'student:generate-comments',
|
||||
async (_, payload: GenerateStudentCommentsInput): Promise<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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+42
-2
@@ -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 {
|
||||
|
||||
+44
-15
@@ -41,6 +41,8 @@ const api = {
|
||||
}) => ipcRenderer.invoke('student:update-profile', profile),
|
||||
generateStudentComment: (payload: { profileId: string }) =>
|
||||
ipcRenderer.invoke('student:generate-comment', payload),
|
||||
generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) =>
|
||||
ipcRenderer.invoke('student:generate-comments', payload),
|
||||
listModels: (payload: { baseUrl: string; apiKey: string }) =>
|
||||
ipcRenderer.invoke('models:list', payload),
|
||||
testModelConnection: (modelConfig: {
|
||||
@@ -155,25 +157,52 @@ const api = {
|
||||
downloadReport: (id: string) => ipcRenderer.invoke('reports:download', id),
|
||||
downloadClassReports: (payload: { classId: string }) =>
|
||||
ipcRenderer.invoke('reports:download-class', payload),
|
||||
onReportGenerationProgress: (callback: (progress: {
|
||||
batchId: string
|
||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
||||
classId: string
|
||||
className: string
|
||||
studentId?: string
|
||||
studentName?: string
|
||||
current: number
|
||||
total: number
|
||||
report?: unknown
|
||||
error?: string
|
||||
}) => void) => {
|
||||
const listener = (_: Electron.IpcRendererEvent, progress: Parameters<typeof callback>[0]) =>
|
||||
callback(progress)
|
||||
onReportGenerationProgress: (
|
||||
callback: (progress: {
|
||||
batchId: string
|
||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
||||
classId: string
|
||||
className: string
|
||||
studentId?: string
|
||||
studentName?: string
|
||||
current: number
|
||||
total: number
|
||||
report?: unknown
|
||||
error?: string
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_: Electron.IpcRendererEvent,
|
||||
progress: Parameters<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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
setSelectedClassId(profile.classId)
|
||||
setQuery(profile.name || profile.englishName)
|
||||
queueMicrotask(() => {
|
||||
setSelectedClassId(profile.classId)
|
||||
setQuery(profile.name || profile.englishName)
|
||||
|
||||
const state = location.state as { profileId?: string; mode?: 'view' } | null
|
||||
if (locationState?.mode === 'view') {
|
||||
setPendingStudentId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (state?.mode === 'view') {
|
||||
setPendingStudentId(null)
|
||||
if (selectedTemplateId) {
|
||||
void handleGenerateReports({
|
||||
classId: profile.classId,
|
||||
studentIds: [profile.id]
|
||||
})
|
||||
setPendingStudentId(null)
|
||||
}
|
||||
})
|
||||
}, [locationState?.mode, pendingStudentId, profiles, selectedTemplateId])
|
||||
|
||||
useEffect(() => {
|
||||
const autoGenerateKey = locationState?.classId
|
||||
? `${locationState.classId}:${selectedTemplateId}`
|
||||
: ''
|
||||
|
||||
if (
|
||||
!locationState?.autoGenerate ||
|
||||
!locationState.classId ||
|
||||
!selectedTemplateId ||
|
||||
selectedClassId !== locationState.classId ||
|
||||
generating ||
|
||||
autoGenerateKeyRef.current === autoGenerateKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedTemplateId) {
|
||||
void handleGenerateReports([profile.id])
|
||||
setPendingStudentId(null)
|
||||
}
|
||||
}, [location.state, pendingStudentId, profiles, selectedTemplateId])
|
||||
autoGenerateKeyRef.current = autoGenerateKey
|
||||
void handleGenerateReports({ classId: locationState.classId })
|
||||
}, [
|
||||
generating,
|
||||
locationState?.autoGenerate,
|
||||
locationState?.classId,
|
||||
selectedTemplateId,
|
||||
selectedClassId
|
||||
])
|
||||
|
||||
const pptxTemplates = templates.filter((template) => template.fileExtension === '.pptx')
|
||||
|
||||
@@ -224,13 +260,17 @@ export function ReportPage(): React.JSX.Element {
|
||||
})
|
||||
}, [query, reports, selectedClassId, selectedFormat])
|
||||
|
||||
async function handleGenerateReports(studentIds?: string[]): Promise<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>
|
||||
|
||||
{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">
|
||||
<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) => {
|
||||
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 />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 (!response.ok) {
|
||||
toast.error('批量生成评语失败', { description: response.message })
|
||||
return
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
successCount += 1
|
||||
} else {
|
||||
failedCount += 1
|
||||
}
|
||||
for (const profile of response.profiles) {
|
||||
updateProfileInPage(profile)
|
||||
}
|
||||
|
||||
toast.success('批量生成完成', {
|
||||
description: `成功 ${successCount} 个,失败 ${failedCount} 个`
|
||||
description: `成功 ${response.profiles.length} 个,失败 ${response.skipped.length} 个`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -312,9 +339,10 @@ export function StudentPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
function handleGenerateReport(profile: ChildProfile): void {
|
||||
navigate('/tools/reports', {
|
||||
navigate('/student/reports', {
|
||||
state: {
|
||||
profileId: profile.id
|
||||
profileId: profile.id,
|
||||
autoGenerate: true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -324,7 +352,7 @@ export function StudentPage(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
navigate('/tools/reports', {
|
||||
navigate('/student/reports', {
|
||||
state: {
|
||||
profileId: profile.id,
|
||||
mode: 'view'
|
||||
@@ -425,7 +453,7 @@ export function StudentPage(): React.JSX.Element {
|
||||
<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
|
||||
}
|
||||
|
||||
@@ -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,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: '学生管理',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["src/server/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user