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"?>
|
<?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>
|
||||||
@@ -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())
|
||||||
@@ -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,9 +71,7 @@ export function registerReportIpc(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle('reports:download', async (_, id: string): Promise<DownloadReportResponse> => {
|
||||||
'reports:download',
|
|
||||||
async (_, id: string): Promise<DownloadReportResponse> => {
|
|
||||||
try {
|
try {
|
||||||
const result = await downloadReport(id)
|
const result = await downloadReport(id)
|
||||||
|
|
||||||
@@ -93,8 +93,7 @@ export function registerReportIpc(): void {
|
|||||||
message: getErrorMessage(error)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +43,9 @@ export function registerStudentIpc(): void {
|
|||||||
console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error)
|
console.error('[图片迁移] 迁移 SQLite 内图片到文件失败', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('student:load', async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
|
ipcMain.handle(
|
||||||
|
'student:load',
|
||||||
|
async (_, payload?: LoadStudentDataInput): Promise<LoadStudentDataResponse> => {
|
||||||
try {
|
try {
|
||||||
const studentData = await loadStudentData(payload)
|
const studentData = await loadStudentData(payload)
|
||||||
return {
|
return {
|
||||||
@@ -53,7 +58,8 @@ export function registerStudentIpc(): void {
|
|||||||
message: getErrorMessage(error)
|
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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+42
-2
@@ -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 {
|
||||||
|
|||||||
+34
-5
@@ -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,7 +157,8 @@ 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: (
|
||||||
|
callback: (progress: {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
||||||
classId: string
|
classId: string
|
||||||
@@ -166,14 +169,40 @@ const api = {
|
|||||||
total: number
|
total: number
|
||||||
report?: unknown
|
report?: unknown
|
||||||
error?: string
|
error?: string
|
||||||
}) => void) => {
|
}) => void
|
||||||
const listener = (_: Electron.IpcRendererEvent, progress: Parameters<typeof callback>[0]) =>
|
): (() => void) => {
|
||||||
callback(progress)
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
setSelectedClassId(profile.classId)
|
setSelectedClassId(profile.classId)
|
||||||
setQuery(profile.name || profile.englishName)
|
setQuery(profile.name || profile.englishName)
|
||||||
|
|
||||||
const state = location.state as { profileId?: string; mode?: 'view' } | null
|
if (locationState?.mode === 'view') {
|
||||||
|
|
||||||
if (state?.mode === 'view') {
|
|
||||||
setPendingStudentId(null)
|
setPendingStudentId(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTemplateId) {
|
if (selectedTemplateId) {
|
||||||
void handleGenerateReports([profile.id])
|
void handleGenerateReports({
|
||||||
|
classId: profile.classId,
|
||||||
|
studentIds: [profile.id]
|
||||||
|
})
|
||||||
setPendingStudentId(null)
|
setPendingStudentId(null)
|
||||||
}
|
}
|
||||||
}, [location.state, pendingStudentId, profiles, selectedTemplateId])
|
})
|
||||||
|
}, [locationState?.mode, pendingStudentId, profiles, selectedTemplateId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const autoGenerateKey = locationState?.classId
|
||||||
|
? `${locationState.classId}:${selectedTemplateId}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (
|
||||||
|
!locationState?.autoGenerate ||
|
||||||
|
!locationState.classId ||
|
||||||
|
!selectedTemplateId ||
|
||||||
|
selectedClassId !== locationState.classId ||
|
||||||
|
generating ||
|
||||||
|
autoGenerateKeyRef.current === autoGenerateKey
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoGenerateKeyRef.current = autoGenerateKey
|
||||||
|
void handleGenerateReports({ classId: locationState.classId })
|
||||||
|
}, [
|
||||||
|
generating,
|
||||||
|
locationState?.autoGenerate,
|
||||||
|
locationState?.classId,
|
||||||
|
selectedTemplateId,
|
||||||
|
selectedClassId
|
||||||
|
])
|
||||||
|
|
||||||
const pptxTemplates = templates.filter((template) => template.fileExtension === '.pptx')
|
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>
|
||||||
|
|
||||||
<ScrollArea className="h-140">
|
{generationProgress ? (
|
||||||
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
|
<Card className="border-primary/30 bg-primary/5 shadow-none">
|
||||||
{progressCards.map((progress) => (
|
<CardContent className="grid gap-4 p-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
|
||||||
<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">
|
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary/10 text-primary">
|
||||||
<Wand2 className="size-5" />
|
<Wand2 className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={progress.status === 'student-failed' ? 'destructive' : 'warning'}>
|
<div className="min-w-0 space-y-2">
|
||||||
{progress.status === 'student-failed' ? '生成失败' : '生成中'}
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="h-2 overflow-hidden rounded-full bg-background">
|
||||||
<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
|
<div
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(
|
width: `${Math.min(
|
||||||
Math.max((progress.current / Math.max(progress.total, 1)) * 100, 8),
|
Math.max(
|
||||||
|
(generationProgress.current / Math.max(generationProgress.total, 1)) *
|
||||||
|
100,
|
||||||
|
generationProgress.current > 0 ? 6 : 0
|
||||||
|
),
|
||||||
100
|
100
|
||||||
)}%`
|
)}%`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{progress.error ? (
|
{generationProgress.error ? (
|
||||||
<p className="mt-3 text-sm text-destructive">{progress.error}</p>
|
<p className="text-sm text-destructive">{generationProgress.error}</p>
|
||||||
) : null}
|
) : 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
) : null}
|
||||||
|
|
||||||
|
<ScrollArea className="h-140">
|
||||||
|
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{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 />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
successCount += 1
|
|
||||||
} else {
|
|
||||||
failedCount += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const profile of response.profiles) {
|
||||||
|
updateProfileInPage(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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: '学生管理',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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