diff --git a/.gitignore b/.gitignore index 5b39104..730e661 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.pnpm-store dist out .DS_Store diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index efe84bc..2595efd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,10 @@ allowBuilds: electron: true electron-winstaller: true esbuild: true + +onlyBuiltDependencies: + - electron + - esbuild + registries: default: https://registry.npmmirror.com/ diff --git a/src/main/ipc/reportIpc.ts b/src/main/ipc/reportIpc.ts index 33061da..85e2847 100644 --- a/src/main/ipc/reportIpc.ts +++ b/src/main/ipc/reportIpc.ts @@ -7,7 +7,10 @@ import { downloadReport, generateReports, loadReports, - openReport + openReport, + pauseReportGeneration, + resumeReportGeneration, + stopReportGeneration } from '../services/reportService' import type { DeleteAllReportsResponse, @@ -18,7 +21,8 @@ import type { GenerateReportsInput, GenerateReportsResponse, LoadReportsResponse, - OpenReportResponse + OpenReportResponse, + ReportGenerationControlResponse } from '../types/report' function getErrorMessage(error: unknown): string { @@ -57,6 +61,54 @@ export function registerReportIpc(): void { } ) + ipcMain.handle( + 'reports:pause-generation', + async (_, batchId: string): Promise => { + if (pauseReportGeneration(batchId)) { + return { + ok: true + } + } + + return { + ok: false, + message: '当前没有可暂停的报告生成任务' + } + } + ) + + ipcMain.handle( + 'reports:resume-generation', + async (_, batchId: string): Promise => { + if (resumeReportGeneration(batchId)) { + return { + ok: true + } + } + + return { + ok: false, + message: '当前没有可继续的报告生成任务' + } + } + ) + + ipcMain.handle( + 'reports:stop-generation', + async (_, batchId: string): Promise => { + if (stopReportGeneration(batchId)) { + return { + ok: true + } + } + + return { + ok: false, + message: '当前没有可停止的报告生成任务' + } + } + ) + ipcMain.handle('reports:open', async (_, filePath: string): Promise => { try { await openReport(filePath) diff --git a/src/main/ipc/settingsIpc.ts b/src/main/ipc/settingsIpc.ts index 455fb85..af96e30 100644 --- a/src/main/ipc/settingsIpc.ts +++ b/src/main/ipc/settingsIpc.ts @@ -1,8 +1,8 @@ import { ipcMain } from 'electron' -import { loadSettings, saveSettings } from '../services/settingsService' +import { loadSettings, saveClassTypeConfigs, saveSettings } from '../services/settingsService' import type { ModelConfig } from '../types/model' -import type { LoadSettingsResponse, SaveSettingsResponse } from '../types/settings' +import type { ClassTypeConfig, LoadSettingsResponse, SaveSettingsResponse } from '../types/settings' export function registerSettingsIpc(): void { ipcMain.handle('settings:load', async (): Promise => { @@ -12,6 +12,14 @@ export function registerSettingsIpc(): void { ipcMain.handle( 'settings:save', async (_, modelConfig: ModelConfig): Promise => { - return saveSettings(modelConfig) - }) + return saveSettings(modelConfig) + } + ) + + ipcMain.handle( + 'settings:save-class-types', + async (_, classTypeConfigs: ClassTypeConfig[]): Promise => { + return saveClassTypeConfigs(classTypeConfigs) + } + ) } diff --git a/src/main/ipc/studentIpc.ts b/src/main/ipc/studentIpc.ts index 23891b8..86b3730 100644 --- a/src/main/ipc/studentIpc.ts +++ b/src/main/ipc/studentIpc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { + addClassZodiacs, deleteClassWithProfiles, deleteStudentProfile, getStudentProfile, @@ -10,11 +11,16 @@ import { loadStudentData, migrateStoredImagesToFiles, migrateLocalStudentData, + pauseCommentGeneration, replaceProfilesForClass, + resumeCommentGeneration, saveClasses, + stopCommentGeneration, updateStudentProfile } from '../services/studentService' import type { + AddClassZodiacsInput, + AddClassZodiacsResponse, ClassProfile, ChildProfile, DeleteClassResponse, @@ -30,6 +36,7 @@ import type { LoadStudentDataResponse, ReplaceClassProfilesResponse, SaveClassesResponse, + TaskControlResponse, UpdateStudentProfileInput, UpdateStudentProfileResponse } from '../types/student' @@ -146,6 +153,65 @@ export function registerStudentIpc(): void { } ) + ipcMain.handle( + 'student:add-class-zodiacs', + async (_, payload: AddClassZodiacsInput): Promise => { + try { + return { + ok: true, + ...(await addClassZodiacs(payload.classId)) + } + } catch (error) { + return { + ok: false, + message: getErrorMessage(error) + } + } + } + ) + + ipcMain.handle( + 'comments:pause-generation', + async (_, batchId: string): Promise => { + if (pauseCommentGeneration(batchId)) { + return { ok: true } + } + + return { + ok: false, + message: '当前没有可暂停的评语生成任务' + } + } + ) + + ipcMain.handle( + 'comments:resume-generation', + async (_, batchId: string): Promise => { + if (resumeCommentGeneration(batchId)) { + return { ok: true } + } + + return { + ok: false, + message: '当前没有可继续的评语生成任务' + } + } + ) + + ipcMain.handle( + 'comments:stop-generation', + async (_, batchId: string): Promise => { + if (stopCommentGeneration(batchId)) { + return { ok: true } + } + + return { + ok: false, + message: '当前没有可停止的评语生成任务' + } + } + ) + ipcMain.handle( 'student:save-classes', async (_, classes: ClassProfile[]): Promise => { @@ -170,9 +236,9 @@ export function registerStudentIpc(): void { payload: { classItem: ClassProfile; profiles: ChildProfile[] } ): Promise => { try { - await replaceProfilesForClass(payload.classItem, payload.profiles) return { - ok: true + ok: true, + ...(await replaceProfilesForClass(payload.classItem, payload.profiles)) } } catch (error) { return { diff --git a/src/main/services/reportService.ts b/src/main/services/reportService.ts index 39cc697..f8e0925 100644 --- a/src/main/services/reportService.ts +++ b/src/main/services/reportService.ts @@ -18,16 +18,29 @@ import { TemplateEntitySchema, type TemplateEntity } from '../entities/TemplateE import type { DownloadClassReportsInput, GenerateReportsInput, + ReportDownloadProgress, ReportFormat, ReportGenerationProgress, ReportItem } from '../types/report' +import type { ClassTypeConfig } from '../types/settings' import { getAppDataSource } from './databaseService' import { readImageBuffer } from './imageStorageService' +import { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService' const REPORT_FOLDER_NAME = 'reports' const TEMPLATE_FOLDER_NAME = 'templates' +type ReportGenerationController = { + batchId: string + paused: boolean + stopped: boolean + lastProgress: ReportGenerationProgress + resumeWaiters: Array<() => void> +} + +const reportGenerationControllers = new Map() + type ReportStudentData = { id: string classId: string @@ -50,6 +63,9 @@ type ReportStudentData = { comment: string comments: string teacherName: string + classType: string + classTypeLabel: string + courseContent: string } type StudentProfileWithAliases = StudentProfileEntity & { @@ -118,6 +134,103 @@ function sendReportProgress(progress: ReportGenerationProgress): void { } } +function sendReportDownloadProgress(progress: ReportDownloadProgress): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('reports:download-class-progress', progress) + } +} + +function updateReportControllerProgress( + controller: ReportGenerationController, + progress: ReportGenerationProgress +): void { + controller.lastProgress = progress + sendReportProgress(progress) +} + +async function waitForReportGenerationResume( + controller: ReportGenerationController +): Promise { + if (!controller.paused || controller.stopped) { + return + } + + updateReportControllerProgress(controller, { + ...controller.lastProgress, + status: 'paused', + studentId: undefined, + studentName: undefined + }) + + await new Promise((resolve) => { + controller.resumeWaiters.push(resolve) + }) +} + +export function pauseReportGeneration(batchId: string): boolean { + const controller = reportGenerationControllers.get(batchId) + + if (!controller || controller.stopped) { + return false + } + + controller.paused = true + updateReportControllerProgress(controller, { + ...controller.lastProgress, + status: 'paused', + studentId: undefined, + studentName: undefined + }) + return true +} + +export function resumeReportGeneration(batchId: string): boolean { + const controller = reportGenerationControllers.get(batchId) + + if (!controller || controller.stopped) { + return false + } + + controller.paused = false + const waiters = controller.resumeWaiters.splice(0) + + for (const resolve of waiters) { + resolve() + } + + updateReportControllerProgress(controller, { + ...controller.lastProgress, + status: 'started', + studentId: undefined, + studentName: undefined + }) + return true +} + +export function stopReportGeneration(batchId: string): boolean { + const controller = reportGenerationControllers.get(batchId) + + if (!controller) { + return false + } + + controller.stopped = true + controller.paused = false + const waiters = controller.resumeWaiters.splice(0) + + for (const resolve of waiters) { + resolve() + } + + updateReportControllerProgress(controller, { + ...controller.lastProgress, + status: 'stopped', + studentId: undefined, + studentName: undefined + }) + return true +} + function getFormatFromExtension(extension: string): ReportFormat { if (['.doc', '.docx'].includes(extension)) { return 'word' @@ -162,13 +275,33 @@ function getStudentName(student: StudentProfileEntity): string { return profile.studentName || profile.name || profile.englishName || '' } -function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData { +function getClassTypeConfig( + classType: string, + classTypeConfigs: ClassTypeConfig[] +): ClassTypeConfig { + return ( + classTypeConfigs.find((config) => config.id === classType) ?? + DEFAULT_CLASS_TYPE_CONFIGS.find((config) => config.id === classType) ?? { + id: classType, + label: classType || '便宜班', + courseContent: '' + } + ) +} + +function getStudentData( + student: StudentProfileEntity, + classItem: ClassEntity, + classTypeConfigs: ClassTypeConfig[] +): ReportStudentData { const friends = parseJsonList(student.friends).join('、') || ' ' const hobbies = parseJsonList(student.hobbies).join('、') || ' ' const favoriteGames = parseJsonList(student.favoriteGames).join('、') || ' ' const favoriteFoods = parseJsonList(student.favoriteFoods).join('、') || ' ' const teacherName = parseTeacherNames(classItem.teacherNames || classItem.teacherName || '').join(' ') || ' ' + const classType = classItem.type || 'cheap' + const classTypeConfig = getClassTypeConfig(classType, classTypeConfigs) return { id: student.id, @@ -191,7 +324,10 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): traits: student.traits || ' ', comment: student.comment || '暂无评语', comments: student.comment || '暂无评语', - teacherName + teacherName, + classType, + classTypeLabel: classTypeConfig.label, + courseContent: classTypeConfig.courseContent || ' ' } } @@ -220,6 +356,14 @@ function buildTextReplacements( name: studentData.name, class: classItem.name, className: classItem.name, + classType: studentData.classType, + class_type: studentData.classType, + classTypeLabel: studentData.classTypeLabel, + class_type_label: studentData.classTypeLabel, + courseContent: studentData.courseContent, + course_content: studentData.courseContent, + classCourse: studentData.courseContent, + class_course: studentData.courseContent, comments: studentData.comments, comment: studentData.comment, teacherName: studentData.teacherName, @@ -424,7 +568,7 @@ function replaceShapeTextPreservingStyle( shapeName: string, text: string ): string { - return slideXml.replace(//g, (shapeXml: string) => { + return slideXml.replace(/]*)?>[\s\S]*?<\/p:sp>/g, (shapeXml: string) => { if (!hasShapeName(shapeXml, shapeName)) { return shapeXml } @@ -470,24 +614,6 @@ function replaceInlineTextPlaceholderPreservingStyle( ) } -function replaceExactTextPlaceholderPreservingStyle( - slideXml: string, - placeholder: string, - text: string -): string { - const normalizedPlaceholder = normalizePptObjectName(placeholder) - - return slideXml.replace(/]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) => - replaceTextNodeValue(textNode, (value) => { - if (normalizePptObjectName(decodeXml(value)) !== normalizedPlaceholder) { - return null - } - - return escapeXml(text) - }) - ) -} - function getTextNodeValue(textNode: string): string { return textNode.match(/^]*)?>([\s\S]*?)<\/a:t>$/)?.[1] ?? '' } @@ -496,7 +622,7 @@ function replaceShapeInlinePlaceholdersPreservingStyle( slideXml: string, replacements: Record ): string { - return slideXml.replace(//g, (shapeXml: string) => { + return slideXml.replace(/]*)?>[\s\S]*?<\/p:sp>/g, (shapeXml: string) => { const textNodes = [...shapeXml.matchAll(/]*)?>[\s\S]*?<\/a:t>/g)] if (textNodes.length === 0) { @@ -548,7 +674,6 @@ async function replacePptxTextPreservingStyle( for (const [placeholder, text] of Object.entries(replacements)) { slideXml = replaceInlineTextPlaceholderPreservingStyle(slideXml, placeholder, text) slideXml = replaceShapeTextPreservingStyle(slideXml, placeholder, text) - slideXml = replaceExactTextPlaceholderPreservingStyle(slideXml, placeholder, text) } slideXml = replaceShapeInlinePlaceholdersPreservingStyle(slideXml, replacements) @@ -707,14 +832,15 @@ async function buildPptxReport( template: TemplateEntity, outputPath: string, student: StudentProfileEntity, - classItem: ClassEntity + classItem: ClassEntity, + classTypeConfigs: ClassTypeConfig[] ): Promise { const templateFilePath = resolveTemplateFilePath(template.filePath) const ppt = await PPTXTemplater.load(templateFilePath, { logLevel: 'silent' }) - const studentData = getStudentData(student, classItem) + const studentData = getStudentData(student, classItem, classTypeConfigs) const textReplacements = buildTextReplacements(studentData, classItem) const replacedImageKeys = new Set() @@ -757,6 +883,7 @@ export async function loadReports(): Promise { export async function generateReports(input: GenerateReportsInput): Promise<{ reports: ReportItem[] skipped: Array<{ studentName: string; reason: string }> + stopped?: boolean }> { const source = await getAppDataSource() const templateRepository = source.getRepository(TemplateEntitySchema) @@ -804,6 +931,11 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ throw new Error('当前班级没有可生成的学生') } + const settingsResponse = await loadSettings() + const classTypeConfigs = settingsResponse.ok + ? settingsResponse.classTypeConfigs + : DEFAULT_CLASS_TYPE_CONFIGS + await mkdir(getReportStoragePath(), { recursive: true }) const now = new Date().toISOString() @@ -811,15 +943,28 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ const skipped: Array<{ studentName: string; reason: string }> = [] const batchTraceId = randomUUID() let completedCount = 0 - - sendReportProgress({ + const controller: ReportGenerationController = { batchId: batchTraceId, - status: 'started', - classId: reportClass.id, - className: reportClass.name, - current: 0, - total: targetStudents.length - }) + paused: false, + stopped: false, + lastProgress: { + batchId: batchTraceId, + status: 'started', + classId: reportClass.id, + className: reportClass.name, + current: 0, + total: targetStudents.length + }, + resumeWaiters: [] + } + + reportGenerationControllers.set(batchTraceId, controller) + + function publishProgress(progress: ReportGenerationProgress): void { + updateReportControllerProgress(controller, progress) + } + + publishProgress(controller.lastProgress) async function generateStudentReport(student: StudentProfileEntity): Promise { const studentName = getStudentName(student) || '未命名学生' @@ -829,7 +974,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ const outputPath = join(getReportStoragePath(), `${reportId}${reportTemplate.fileExtension}`) try { - sendReportProgress({ + publishProgress({ batchId: batchTraceId, status: 'student-started', classId: reportClass.id, @@ -840,7 +985,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ total: targetStudents.length }) - await buildPptxReport(reportTemplate, outputPath, student, reportClass) + await buildPptxReport(reportTemplate, outputPath, student, reportClass, classTypeConfigs) const entity: ReportEntity = { id: reportId, @@ -863,7 +1008,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ const reportItem = mapReportEntity(entity) reports.push(reportItem) completedCount += 1 - sendReportProgress({ + publishProgress({ batchId: batchTraceId, status: 'student-finished', classId: reportClass.id, @@ -882,7 +1027,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ reason }) completedCount += 1 - sendReportProgress({ + publishProgress({ batchId: batchTraceId, status: 'student-failed', classId: reportClass.id, @@ -897,20 +1042,30 @@ export async function generateReports(input: GenerateReportsInput): Promise<{ } } - for (const student of targetStudents) { - await generateStudentReport(student) + try { + for (const student of targetStudents) { + await waitForReportGenerationResume(controller) + + if (controller.stopped) { + break + } + + await generateStudentReport(student) + } + + publishProgress({ + batchId: batchTraceId, + status: controller.stopped ? 'stopped' : 'finished', + classId: reportClass.id, + className: reportClass.name, + current: controller.stopped ? completedCount : targetStudents.length, + total: targetStudents.length + }) + } finally { + reportGenerationControllers.delete(batchTraceId) } - sendReportProgress({ - batchId: batchTraceId, - status: 'finished', - classId: reportClass.id, - className: reportClass.name, - current: targetStudents.length, - total: targetStudents.length - }) - - return { reports, skipped } + return { reports, skipped, stopped: controller.stopped } } export async function openReport(filePath: string): Promise { @@ -983,27 +1138,91 @@ export async function downloadClassReports( return { filePath: '', reportCount: reports.length, canceled: true } } + const batchId = randomUUID() const zip = new JSZip() const usedFileNames = new Map() + let completedCount = 0 - for (const report of reports) { - await access(report.filePath) - const baseFileName = getReportDownloadFileName(report) - const usedCount = usedFileNames.get(baseFileName) ?? 0 - usedFileNames.set(baseFileName, usedCount + 1) - const fileName = - usedCount === 0 - ? baseFileName - : `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}` - - zip.file(fileName, await readFile(report.filePath)) + function publishProgress( + progress: Omit + ): void { + sendReportDownloadProgress({ + batchId, + classId: input.classId, + className, + ...progress + }) } - const zipBuffer = await zip.generateAsync({ - type: 'nodebuffer', - compression: 'DEFLATE' - }) - await writeFile(result.filePath, zipBuffer) + try { + publishProgress({ + status: 'started', + current: 0, + total: reports.length, + filePath: result.filePath + }) + + for (const report of reports) { + publishProgress({ + status: 'file-started', + reportId: report.id, + reportTitle: report.title, + current: completedCount, + total: reports.length, + filePath: result.filePath + }) + + await access(report.filePath) + const baseFileName = getReportDownloadFileName(report) + const usedCount = usedFileNames.get(baseFileName) ?? 0 + usedFileNames.set(baseFileName, usedCount + 1) + const fileName = + usedCount === 0 + ? baseFileName + : `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}` + + zip.file(fileName, await readFile(report.filePath)) + completedCount += 1 + + publishProgress({ + status: 'file-finished', + reportId: report.id, + reportTitle: report.title, + current: completedCount, + total: reports.length, + filePath: result.filePath + }) + } + + publishProgress({ + status: 'writing', + current: reports.length, + total: reports.length, + filePath: result.filePath + }) + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE' + }) + await writeFile(result.filePath, zipBuffer) + + publishProgress({ + status: 'finished', + current: reports.length, + total: reports.length, + filePath: result.filePath + }) + } catch (error) { + publishProgress({ + status: 'failed', + current: completedCount, + total: reports.length, + filePath: result.filePath, + error: error instanceof Error ? error.message : '下载班级报告失败' + }) + throw error + } return { filePath: result.filePath, reportCount: reports.length } } diff --git a/src/main/services/settingsService.ts b/src/main/services/settingsService.ts index ed4bd2d..774dde6 100644 --- a/src/main/services/settingsService.ts +++ b/src/main/services/settingsService.ts @@ -4,12 +4,34 @@ import { join } from 'path' import type { ModelConfig } from '../types/model' import type { + ClassTypeConfig, LoadSettingsResponse, SaveSettingsResponse, SettingsFile, StoredApiKey } from '../types/settings' +export const DEFAULT_CLASS_TYPE_CONFIGS: ClassTypeConfig[] = [ + { + id: 'cheap', + label: '便宜班', + courseContent: + '本期开展了小袋鼠整合主题课程:(语言、社会、科学、健康、艺术)、生活数学;特色课程(英语、体能、美工、篮球)。' + }, + { + id: 'noble', + label: '贵族班', + courseContent: + '本学期开展了柏克莱主题课程(语言、社会、科学、艺术、健康);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。' + }, + { + id: 'big', + label: '大大班', + courseContent: + '本学期开展了双木桥主题课程(图说汉字、妙趣汉音、情智阅读、麦斯思维、专注力训练);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。' + } +] + function getSettingsPath(): string { return join(app.getPath('userData'), 'settings.json') } @@ -38,27 +60,65 @@ function decodeApiKey(apiKey?: StoredApiKey): string { return apiKey.value } +function normalizeClassTypeConfig(config: Partial): ClassTypeConfig | null { + const id = config.id?.trim() + const label = config.label?.trim() + + if (!id || !label) { + return null + } + + return { + id, + label, + courseContent: config.courseContent?.trim() ?? '' + } +} + +function normalizeClassTypeConfigs(configs?: Partial[]): ClassTypeConfig[] { + const normalizedConfigs = + configs + ?.map((config) => normalizeClassTypeConfig(config)) + .filter((config): config is ClassTypeConfig => config !== null) ?? [] + + if (normalizedConfigs.length === 0) { + return DEFAULT_CLASS_TYPE_CONFIGS + } + + return Array.from(new Map(normalizedConfigs.map((config) => [config.id, config])).values()) +} + +async function readSettingsFile(): Promise { + const raw = await readFile(getSettingsPath(), 'utf-8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null + throw error + }) + + if (!raw) { + return {} + } + + return JSON.parse(raw) as SettingsFile +} + +async function writeSettingsFile(settings: SettingsFile): Promise { + await writeFile(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8') +} + export async function loadSettings(): Promise { try { - const raw = await readFile(getSettingsPath(), 'utf-8').catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return null - throw error - }) - - if (!raw) { - return { ok: true, modelConfig: null } - } - - const settings = JSON.parse(raw) as SettingsFile + const settings = await readSettingsFile() + const classTypeConfigs = normalizeClassTypeConfigs(settings.classTypeConfigs) if (!settings.modelConfig) { - return { ok: true, modelConfig: null } + return { ok: true, modelConfig: null, classTypeConfigs } } const { apiKey, ...modelConfig } = settings.modelConfig return { ok: true, + classTypeConfigs, modelConfig: { ...modelConfig, apiKey: decodeApiKey(apiKey) @@ -74,7 +134,9 @@ export async function loadSettings(): Promise { export async function saveSettings(modelConfig: ModelConfig): Promise { try { + const currentSettings = await readSettingsFile() const settings: SettingsFile = { + ...currentSettings, modelConfig: { provider: modelConfig.provider, baseUrl: modelConfig.baseUrl, @@ -86,7 +148,7 @@ export async function saveSettings(modelConfig: ModelConfig): Promise { + try { + const currentSettings = await readSettingsFile() + const settings: SettingsFile = { + ...currentSettings, + classTypeConfigs: normalizeClassTypeConfigs(classTypeConfigs) + } + + await writeSettingsFile(settings) + + return { ok: true } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : '保存班级类型配置失败' + } + } +} diff --git a/src/main/services/studentService.ts b/src/main/services/studentService.ts index b67cc73..c94d817 100644 --- a/src/main/services/studentService.ts +++ b/src/main/services/studentService.ts @@ -17,10 +17,40 @@ import type { ListStudentProfilesInput, LoadStudentDataInput } from '../types/student' +import type { ClassTypeConfig } from '../types/settings' import { getAppDataSource } from './databaseService' import { isDataUrlImage, readImageAsDataUrl, storeImageValue } from './imageStorageService' import { getChatCompletionsUrl, logLargeModelRequest } from './modelService' -import { loadSettings } from './settingsService' +import { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService' + +type CommentGenerationController = { + batchId: string + paused: boolean + stopped: boolean + lastProgress: CommentGenerationProgress + resumeWaiters: Array<() => void> +} + +const commentGenerationControllers = new Map() +const CHINESE_ZODIAC_BY_BRANCH: Record = { + 子: '鼠', + 丑: '牛', + 寅: '虎', + 卯: '兔', + 辰: '龙', + 巳: '蛇', + 午: '马', + 未: '羊', + 申: '猴', + 酉: '鸡', + 戌: '狗', + 亥: '猪' +} +const GREGORIAN_ZODIACS = ['猴', '鸡', '狗', '猪', '鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊'] +const chineseYearFormatter = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', { + year: 'numeric', + timeZone: 'UTC' +}) function parseJsonList(value: string): string[] { try { @@ -35,8 +65,143 @@ function stringifyList(items: string[]): string { return JSON.stringify(items) } -function normalizeClassType(type: string): 'cheap' | 'noble' { - return type === 'noble' ? 'noble' : 'cheap' +function normalizeClassType(type: string): string { + return type.trim() || 'cheap' +} + +function createUtcDate(year: number, month: number, day: number): Date | null { + const date = new Date(Date.UTC(year, month - 1, day)) + + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null + } + + return date +} + +function parseBirthdayDate(value: string): Date | null { + const trimmedValue = value.trim() + + if (!trimmedValue) { + return null + } + + if (/^\d{5}(?:\.\d+)?$/.test(trimmedValue)) { + const excelSerialDate = Number(trimmedValue) + + if (Number.isFinite(excelSerialDate)) { + const excelEpoch = Date.UTC(1899, 11, 30) + return new Date(excelEpoch + Math.floor(excelSerialDate) * 24 * 60 * 60 * 1000) + } + } + + const compactMatch = trimmedValue.match(/^(\d{4})(\d{2})(\d{2})$/) + + if (compactMatch) { + return createUtcDate(Number(compactMatch[1]), Number(compactMatch[2]), Number(compactMatch[3])) + } + + const datePartsMatch = trimmedValue.match(/^(\d{4})\D+(\d{1,2})\D+(\d{1,2})/) + + if (datePartsMatch) { + return createUtcDate( + Number(datePartsMatch[1]), + Number(datePartsMatch[2]), + Number(datePartsMatch[3]) + ) + } + + const parsedTime = Date.parse(trimmedValue) + + if (!Number.isNaN(parsedTime)) { + const parsedDate = new Date(parsedTime) + return createUtcDate(parsedDate.getFullYear(), parsedDate.getMonth() + 1, parsedDate.getDate()) + } + + return null +} + +function getChineseZodiac(date: Date): string { + const chineseYear = chineseYearFormatter.format(date) + const branch = chineseYear.match(/[子丑寅卯辰巳午未申酉戌亥]/)?.[0] + + if (branch && CHINESE_ZODIAC_BY_BRANCH[branch]) { + return CHINESE_ZODIAC_BY_BRANCH[branch] + } + + return GREGORIAN_ZODIACS[date.getUTCFullYear() % 12] +} + +function normalizeProfileMatchValue(value: string): string { + return String(value ?? '') + .trim() + .replace(/\s+/g, '') + .toLowerCase() +} + +function getProfileMatchKeys( + profile: Pick +): string[] { + const keys = new Set() + const id = normalizeProfileMatchValue(profile.id) + const name = normalizeProfileMatchValue(profile.name) + const englishName = normalizeProfileMatchValue(profile.englishName) + const birthday = normalizeProfileMatchValue(profile.birthday) + + if (id) { + keys.add(`id:${id}`) + } + + if (name && birthday) { + keys.add(`name-birthday:${name}|${birthday}`) + } + + if (englishName && birthday) { + keys.add(`english-birthday:${englishName}|${birthday}`) + } + + if (name) { + keys.add(`name:${name}`) + } + + if (englishName) { + keys.add(`english:${englishName}`) + } + + return [...keys] +} + +function mergeImportedProfileIntoExisting( + existingProfile: StudentProfileEntity, + importedProfile: ChildProfile, + classItem: ClassProfile +): ChildProfile { + return { + id: existingProfile.id, + classId: classItem.id, + className: classItem.name, + name: importedProfile.name, + englishName: importedProfile.englishName, + gender: importedProfile.gender, + birthday: importedProfile.birthday, + zodiac: importedProfile.zodiac, + friends: importedProfile.friends, + hobbies: importedProfile.hobbies, + favoriteGames: importedProfile.favoriteGames, + favoriteFoods: importedProfile.favoriteFoods, + traits: importedProfile.traits, + comment: existingProfile.comment ?? '', + commentGeneratedAt: existingProfile.commentGeneratedAt ?? '', + reportGenerated: Boolean(existingProfile.reportGenerated), + meImage: existingProfile.meImage ?? '', + workImage1: existingProfile.workImage1 ?? '', + workImage2: existingProfile.workImage2 ?? '', + importedAt: importedProfile.importedAt + } } function parseTeacherNames(value: string): string[] { @@ -194,14 +359,44 @@ async function mapChildProfile( }) } -function buildStudentCommentPrompt(profile: ChildProfile): string { +function getClassTypeConfig( + classType: string, + classTypeConfigs: ClassTypeConfig[] +): ClassTypeConfig { + return ( + classTypeConfigs.find((config) => config.id === classType) ?? + DEFAULT_CLASS_TYPE_CONFIGS.find((config) => config.id === classType) ?? { + id: classType, + label: classType || '便宜班', + courseContent: '' + } + ) +} + +function getGivenName(name: string): string { + const trimmedName = name.trim() + + if (trimmedName.length <= 2) { + return trimmedName || '宝贝' + } + + return [...trimmedName].slice(1).join('') +} + +function buildStudentCommentPrompt(profile: ChildProfile, courseContent: string): string { return [ - '请为这名幼儿生成一段成长报告评语。', - '要求:语气温暖、具体、积极,适合幼儿园成长报告;不要编造资料里没有的姓名、日期或家庭信息;长度控制在 120 到 180 字。', + '请为这名幼儿生成一段学期末成长评语。', + '严格要求:', + `1. 第一句必须是“${getGivenName(profile.name || profile.englishName)}宝贝:你好,${courseContent}”。`, + '2. 正文保持一段完整段落,不要换行。', + '3. 只能依据下方资料描写表现,不要编造具体课程名称、课堂活动、比赛、绘本、故事创编、阅读课等资料中没有的信息。', + '4. 语气温暖、具体、积极,适合幼儿园成长报告;结尾委婉提出一个期望并送上祝福。', + '5. 字数控制在 150 到 250 字。', '', `姓名:${profile.name || '未填写'}`, `英文名:${profile.englishName || '未填写'}`, `班级:${profile.className || '未分班'}`, + `课程内容:${courseContent || '未配置'}`, `性别:${profile.gender || '未填写'}`, `生日:${profile.birthday || '未填写'}`, `属相:${profile.zodiac || '未填写'}`, @@ -213,6 +408,23 @@ function buildStudentCommentPrompt(profile: ChildProfile): string { ].join('\n') } +function ensureCommentIncludesCourseContent(comment: string, courseContent: string): string { + const trimmedComment = comment.trim() + const trimmedCourseContent = courseContent.trim() + + if (!trimmedCourseContent || trimmedComment.includes(trimmedCourseContent)) { + return trimmedComment + } + + const greetingMatch = trimmedComment.match(/^([^::]{1,12}[::])/) + + if (!greetingMatch) { + return `${trimmedCourseContent}${trimmedComment}` + } + + return `${greetingMatch[1]}你好,${trimmedCourseContent}${trimmedComment.slice(greetingMatch[0].length)}` +} + function getStudentDisplayName(profile: ChildProfile | StudentProfileEntity): string { return profile.name || profile.englishName || '未命名学生' } @@ -223,6 +435,97 @@ function sendCommentProgress(progress: CommentGenerationProgress): void { } } +function updateCommentControllerProgress( + controller: CommentGenerationController, + progress: CommentGenerationProgress +): void { + controller.lastProgress = progress + sendCommentProgress(progress) +} + +async function waitForCommentGenerationResume( + controller: CommentGenerationController +): Promise { + if (!controller.paused || controller.stopped) { + return + } + + updateCommentControllerProgress(controller, { + ...controller.lastProgress, + status: 'paused', + studentId: undefined, + studentName: undefined + }) + + await new Promise((resolve) => { + controller.resumeWaiters.push(resolve) + }) +} + +export function pauseCommentGeneration(batchId: string): boolean { + const controller = commentGenerationControllers.get(batchId) + + if (!controller || controller.stopped) { + return false + } + + controller.paused = true + updateCommentControllerProgress(controller, { + ...controller.lastProgress, + status: 'paused', + studentId: undefined, + studentName: undefined + }) + return true +} + +export function resumeCommentGeneration(batchId: string): boolean { + const controller = commentGenerationControllers.get(batchId) + + if (!controller || controller.stopped) { + return false + } + + controller.paused = false + const waiters = controller.resumeWaiters.splice(0) + + for (const resolve of waiters) { + resolve() + } + + updateCommentControllerProgress(controller, { + ...controller.lastProgress, + status: 'started', + studentId: undefined, + studentName: undefined + }) + return true +} + +export function stopCommentGeneration(batchId: string): boolean { + const controller = commentGenerationControllers.get(batchId) + + if (!controller) { + return false + } + + controller.stopped = true + controller.paused = false + const waiters = controller.resumeWaiters.splice(0) + + for (const resolve of waiters) { + resolve() + } + + updateCommentControllerProgress(controller, { + ...controller.lastProgress, + status: 'stopped', + studentId: undefined, + studentName: undefined + }) + return true +} + async function getRepositories(): Promise<{ classRepository: Repository childRepository: Repository @@ -241,6 +544,7 @@ export async function loadStudentData(input: LoadStudentDataInput = {}): Promise }> { const includeProfiles = input.includeProfiles ?? true const includeImages = input.includeImages ?? true + const includeClassImages = input.includeClassImages ?? includeImages const { classRepository, childRepository } = await getRepositories() const classes = await classRepository.find({ order: { @@ -286,7 +590,7 @@ export async function loadStudentData(input: LoadStudentDataInput = {}): Promise } } - const hydratedClasses = includeImages + const hydratedClasses = includeClassImages ? await Promise.all(normalizedClasses.map(hydrateClassImages)) : normalizedClasses @@ -391,9 +695,73 @@ export async function updateStudentProfile(profile: ChildProfile): Promise +}> { + const source = await getAppDataSource() + const repository = source.getRepository(StudentProfileEntitySchema) + const profiles = await repository.find({ + where: { classId }, + order: { + name: 'ASC', + id: 'ASC' + } + }) + + if (profiles.length === 0) { + throw new Error('当前班级没有幼儿数据') + } + + const updatedProfiles: StudentProfileEntity[] = [] + const skipped: Array<{ studentName: string; reason: string }> = [] + + for (const profile of profiles) { + const studentName = getStudentDisplayName(profile) + + if (String(profile.zodiac ?? '').trim()) { + skipped.push({ studentName, reason: '已填写属相' }) + continue + } + + const birthdayDate = parseBirthdayDate(String(profile.birthday ?? '')) + + if (!birthdayDate) { + skipped.push({ studentName, reason: '生日为空或格式无法识别' }) + continue + } + + updatedProfiles.push({ + ...profile, + zodiac: getChineseZodiac(birthdayDate) + }) + } + + if (updatedProfiles.length > 0) { + await repository.save(updatedProfiles) + } + + return { + profiles: [ + ...profiles.filter((profile) => !updatedProfiles.some((item) => item.id === profile.id)), + ...updatedProfiles + ] + .sort((first, second) => { + const nameCompare = first.name.localeCompare(second.name, 'zh-CN') + return nameCompare || first.id.localeCompare(second.id) + }) + .map((profile) => mapChildEntity(profile, { includeImages: false })), + updatedCount: updatedProfiles.length, + skipped + } +} + async function generateStudentCommentForEntity( entity: StudentProfileEntity, - repository: Repository + repository: Repository, + classRepository: Repository, + classTypeConfigs: ClassTypeConfig[] ): Promise { const profile = mapChildEntity(entity) const settings = await loadSettings() @@ -409,7 +777,10 @@ async function generateStudentCommentForEntity( } const requestUrl = getChatCompletionsUrl(modelConfig.baseUrl) - const userPrompt = buildStudentCommentPrompt(profile) + const classItem = await classRepository.findOneBy({ id: entity.classId }) + const classTypeConfig = getClassTypeConfig(classItem?.type ?? 'cheap', classTypeConfigs) + const courseContent = classTypeConfig.courseContent + const userPrompt = buildStudentCommentPrompt(profile, courseContent) logLargeModelRequest({ label: '生成学生评语', @@ -459,7 +830,7 @@ async function generateStudentCommentForEntity( const nextProfile: ChildProfile = { ...profile, - comment: comment.trim(), + comment: ensureCommentIncludesCourseContent(comment, courseContent), commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false }) } @@ -471,21 +842,41 @@ async function generateStudentCommentForEntity( export async function generateStudentComment(profileId: string): Promise { const source = await getAppDataSource() const repository = source.getRepository(StudentProfileEntitySchema) + const classRepository = source.getRepository(ClassEntitySchema) const entity = await repository.findOneBy({ id: profileId }) if (!entity) { throw new Error('学生信息不存在') } - return generateStudentCommentForEntity(entity, repository) + const settings = await loadSettings() + + if (!settings.ok) { + throw new Error(settings.message) + } + + return generateStudentCommentForEntity( + entity, + repository, + classRepository, + settings.classTypeConfigs + ) } export async function generateStudentComments(input: GenerateStudentCommentsInput): Promise<{ profiles: ChildProfile[] skipped: Array<{ studentName: string; reason: string }> + stopped?: boolean }> { const source = await getAppDataSource() const repository = source.getRepository(StudentProfileEntitySchema) + const classRepository = source.getRepository(ClassEntitySchema) + const settings = await loadSettings() + + if (!settings.ok) { + throw new Error(settings.message) + } + const selectedIds = new Set(input.profileIds ?? []) const allProfiles = await repository.find({ where: input.classId ? { classId: input.classId } : undefined, @@ -507,76 +898,104 @@ export async function generateStudentComments(input: GenerateStudentCommentsInpu const className = targetProfiles[0]?.className const profiles: ChildProfile[] = [] const skipped: Array<{ studentName: string; reason: string }> = [] - - sendCommentProgress({ + const controller: CommentGenerationController = { 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 - }) - } + paused: false, + stopped: false, + lastProgress: { + batchId, + status: 'started', + classId: input.classId, + className, + current: 0, + total: targetProfiles.length + }, + resumeWaiters: [] } - sendCommentProgress({ - batchId, - status: 'finished', - classId: input.classId, - className, - current: targetProfiles.length, - total: targetProfiles.length - }) + commentGenerationControllers.set(batchId, controller) - return { profiles, skipped } + function publishProgress(progress: CommentGenerationProgress): void { + updateCommentControllerProgress(controller, progress) + } + + publishProgress(controller.lastProgress) + + try { + for (const [profileIndex, profile] of targetProfiles.entries()) { + await waitForCommentGenerationResume(controller) + + if (controller.stopped) { + break + } + + const studentName = getStudentDisplayName(profile) + + try { + publishProgress({ + batchId, + status: 'student-started', + classId: profile.classId, + className: profile.className, + studentId: profile.id, + studentName, + current: profileIndex, + total: targetProfiles.length + }) + + const nextProfile = await generateStudentCommentForEntity( + profile, + repository, + classRepository, + settings.classTypeConfigs + ) + profiles.push(nextProfile) + + publishProgress({ + 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 + }) + publishProgress({ + batchId, + status: 'student-failed', + classId: profile.classId, + className: profile.className, + studentId: profile.id, + studentName, + current: profileIndex + 1, + total: targetProfiles.length, + error: reason + }) + } + } + + publishProgress({ + batchId, + status: controller.stopped ? 'stopped' : 'finished', + classId: input.classId, + className, + current: controller.lastProgress.current, + total: targetProfiles.length + }) + + return { profiles, skipped, stopped: controller.stopped } + } finally { + commentGenerationControllers.delete(batchId) + } } export async function saveClasses(classes: ClassProfile[]): Promise { @@ -628,16 +1047,91 @@ export async function migrateLocalStudentData( export async function replaceProfilesForClass( classItem: ClassProfile, profiles: ChildProfile[] -): Promise { +): Promise<{ + profiles: ChildProfile[] + insertedCount: number + updatedCount: number + skippedCount: number +}> { const source = await getAppDataSource() - await source.transaction(async (manager) => { + return source.transaction(async (manager) => { const childRepository = manager.getRepository(StudentProfileEntitySchema) + const existingProfiles = await childRepository.find({ + where: { classId: classItem.id }, + order: { + name: 'ASC', + id: 'ASC' + } + }) - await childRepository.delete({ classId: classItem.id }) - await childRepository.save( - await Promise.all(profiles.map((profile) => mapChildProfile(profile, classItem))) + if (existingProfiles.length === 0) { + const storedProfiles = await Promise.all( + profiles.map((profile) => mapChildProfile(profile, classItem)) + ) + + if (storedProfiles.length > 0) { + await childRepository.save(storedProfiles) + } + + return { + profiles: storedProfiles.map((profile) => + mapChildEntity(profile, { includeImages: false }) + ), + insertedCount: storedProfiles.length, + updatedCount: 0, + skippedCount: 0 + } + } + + const existingByMatchKey = new Map() + + for (const existingProfile of existingProfiles) { + for (const matchKey of getProfileMatchKeys( + mapChildEntity(existingProfile, { includeImages: false }) + )) { + existingByMatchKey.set(matchKey, [ + ...(existingByMatchKey.get(matchKey) ?? []), + existingProfile + ]) + } + } + + const usedExistingIds = new Set() + const updatedProfiles: StudentProfileEntity[] = [] + let skippedCount = 0 + + for (const profile of profiles) { + const matchedProfile = getProfileMatchKeys(profile) + .flatMap((matchKey) => existingByMatchKey.get(matchKey) ?? []) + .find((existingProfile) => !usedExistingIds.has(existingProfile.id)) + + if (!matchedProfile) { + skippedCount += 1 + continue + } + + usedExistingIds.add(matchedProfile.id) + updatedProfiles.push( + await mapChildProfile(mergeImportedProfileIntoExisting(matchedProfile, profile, classItem)) + ) + } + + if (updatedProfiles.length > 0) { + await childRepository.save(updatedProfiles) + } + + const updatedProfileById = new Map(updatedProfiles.map((profile) => [profile.id, profile])) + const nextProfiles = existingProfiles.map( + (profile) => updatedProfileById.get(profile.id) ?? profile ) + + return { + profiles: nextProfiles.map((profile) => mapChildEntity(profile, { includeImages: false })), + insertedCount: 0, + updatedCount: updatedProfiles.length, + skippedCount + } }) } diff --git a/src/main/types/report.ts b/src/main/types/report.ts index d5a212a..a6c27b7 100644 --- a/src/main/types/report.ts +++ b/src/main/types/report.ts @@ -24,7 +24,14 @@ export type GenerateReportsInput = { export type ReportGenerationProgress = { batchId: string - status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + status: + | 'started' + | 'student-started' + | 'student-finished' + | 'student-failed' + | 'paused' + | 'stopped' + | 'finished' classId: string className: string studentId?: string @@ -35,6 +42,19 @@ export type ReportGenerationProgress = { error?: string } +export type ReportDownloadProgress = { + batchId: string + status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed' + classId: string + className: string + reportId?: string + reportTitle?: string + current: number + total: number + filePath?: string + error?: string +} + export type LoadReportsResponse = | { ok: true @@ -50,6 +70,16 @@ export type GenerateReportsResponse = ok: true reports: ReportItem[] skipped: Array<{ studentName: string; reason: string }> + stopped?: boolean + } + | { + ok: false + message: string + } + +export type ReportGenerationControlResponse = + | { + ok: true } | { ok: false diff --git a/src/main/types/settings.ts b/src/main/types/settings.ts index 85d80aa..0412c4e 100644 --- a/src/main/types/settings.ts +++ b/src/main/types/settings.ts @@ -1,5 +1,11 @@ import type { ModelConfig } from './model' +export type ClassTypeConfig = { + id: string + label: string + courseContent: string +} + export type StoredApiKey = { encoding: 'safeStorage' | 'plain' value: string @@ -11,6 +17,7 @@ export type StoredModelConfig = Omit & { export type SettingsFile = { modelConfig?: StoredModelConfig + classTypeConfigs?: ClassTypeConfig[] } export type SaveSettingsResponse = @@ -26,6 +33,7 @@ export type LoadSettingsResponse = | { ok: true modelConfig: ModelConfig | null + classTypeConfigs: ClassTypeConfig[] } | { ok: false diff --git a/src/main/types/student.ts b/src/main/types/student.ts index 63aafd6..4f2bb6e 100644 --- a/src/main/types/student.ts +++ b/src/main/types/student.ts @@ -1,4 +1,4 @@ -export type ClassType = 'cheap' | 'noble' +export type ClassType = string export type ClassProfile = { id: string @@ -45,6 +45,7 @@ export type LoadStudentDataResponse = export type LoadStudentDataInput = { includeProfiles?: boolean includeImages?: boolean + includeClassImages?: boolean } export type ListStudentProfilesInput = { @@ -98,9 +99,32 @@ export type GenerateStudentCommentsInput = { profileIds?: string[] } +export type AddClassZodiacsInput = { + classId: string +} + +export type AddClassZodiacsResponse = + | { + ok: true + profiles: ChildProfile[] + updatedCount: number + skipped: Array<{ studentName: string; reason: string }> + } + | { + ok: false + message: string + } + export type CommentGenerationProgress = { batchId: string - status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + status: + | 'started' + | 'student-started' + | 'student-finished' + | 'student-failed' + | 'paused' + | 'stopped' + | 'finished' classId?: string className?: string studentId?: string @@ -126,6 +150,7 @@ export type GenerateStudentCommentsResponse = ok: true profiles: ChildProfile[] skipped: Array<{ studentName: string; reason: string }> + stopped?: boolean } | { ok: false @@ -141,9 +166,22 @@ export type SaveClassesResponse = message: string } +export type TaskControlResponse = + | { + ok: true + } + | { + ok: false + message: string + } + export type ReplaceClassProfilesResponse = | { ok: true + profiles: ChildProfile[] + insertedCount: number + updatedCount: number + skippedCount: number } | { ok: false diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index fd6382e..4796ca1 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -30,7 +30,13 @@ type ModelConfig = { systemPrompt: string } -type ClassType = 'cheap' | 'noble' +type ClassType = string + +type ClassTypeConfig = { + id: string + label: string + courseContent: string +} type ClassProfile = { id: string @@ -82,7 +88,14 @@ type ReportItem = { type ReportGenerationProgress = { batchId: string - status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + status: + | 'started' + | 'student-started' + | 'student-finished' + | 'student-failed' + | 'paused' + | 'stopped' + | 'finished' classId: string className: string studentId?: string @@ -93,9 +106,29 @@ type ReportGenerationProgress = { error?: string } +type ReportDownloadProgress = { + batchId: string + status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed' + classId: string + className: string + reportId?: string + reportTitle?: string + current: number + total: number + filePath?: string + error?: string +} + type CommentGenerationProgress = { batchId: string - status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + status: + | 'started' + | 'student-started' + | 'student-finished' + | 'student-failed' + | 'paused' + | 'stopped' + | 'finished' classId?: string className?: string studentId?: string @@ -142,6 +175,7 @@ type LoadSettingsResponse = | { ok: true modelConfig: ModelConfig | null + classTypeConfigs: ClassTypeConfig[] } | { ok: false @@ -174,6 +208,7 @@ type LoadStudentDataResponse = type LoadStudentDataInput = { includeProfiles?: boolean includeImages?: boolean + includeClassImages?: boolean } type ListStudentProfilesResponse = @@ -265,6 +300,7 @@ type GenerateReportsResponse = ok: true reports: ReportItem[] skipped: Array<{ studentName: string; reason: string }> + stopped?: boolean } | { ok: false @@ -276,6 +312,32 @@ type GenerateStudentCommentsResponse = ok: true profiles: ChildProfile[] skipped: Array<{ studentName: string; reason: string }> + stopped?: boolean + } + | { + ok: false + message: string + } + +type AddClassZodiacsResponse = + | { + ok: true + profiles: ChildProfile[] + updatedCount: number + skipped: Array<{ studentName: string; reason: string }> + } + | { + ok: false + message: string + } + +type ReplaceClassProfilesResponse = + | { + ok: true + profiles: ChildProfile[] + insertedCount: number + updatedCount: number + skippedCount: number } | { ok: false @@ -339,12 +401,16 @@ type AppAPI = { classId?: string profileIds?: string[] }) => Promise + addClassZodiacs: (payload: { classId: string }) => Promise + pauseCommentGeneration: (batchId: string) => Promise + resumeCommentGeneration: (batchId: string) => Promise + stopCommentGeneration: (batchId: string) => Promise listModels: (payload: { baseUrl: string; apiKey: string }) => Promise testModelConnection: (modelConfig: ModelConfig) => Promise replaceClassProfiles: (payload: { classItem: ClassProfile profiles: ChildProfile[] - }) => Promise + }) => Promise migrateLocalStudentData: (payload: { classes: ClassProfile[] profiles: ChildProfile[] @@ -352,6 +418,7 @@ type AppAPI = { saveClasses: (classes: ClassProfile[]) => Promise loadSettings: () => Promise saveSettings: (modelConfig: ModelConfig) => Promise + saveClassTypeConfigs: (classTypeConfigs: ClassTypeConfig[]) => Promise loadTemplates: () => Promise selectTemplateFile: () => Promise parseTemplatePlaceholders: (filePath: string) => Promise @@ -372,9 +439,13 @@ type AppAPI = { classId: string studentIds?: string[] }) => Promise + pauseReportGeneration: (batchId: string) => Promise + resumeReportGeneration: (batchId: string) => Promise + stopReportGeneration: (batchId: string) => Promise openReport: (filePath: string) => Promise downloadReport: (id: string) => Promise downloadClassReports: (payload: { classId: string }) => Promise + onReportDownloadProgress: (callback: (progress: ReportDownloadProgress) => void) => () => void onReportGenerationProgress: (callback: (progress: ReportGenerationProgress) => void) => () => void onCommentGenerationProgress: ( callback: (progress: CommentGenerationProgress) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 08ea64a..941d77c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,8 +8,11 @@ const api = { ipcRenderer.invoke('student:delete-profile', profileId), exportEmptyClassFolder: (payload: { className: string; classId: string; childNames: string[] }) => ipcRenderer.invoke('classes:export-empty-folder', payload), - loadStudentData: (payload?: { includeProfiles?: boolean; includeImages?: boolean }) => - ipcRenderer.invoke('student:load', payload), + loadStudentData: (payload?: { + includeProfiles?: boolean + includeImages?: boolean + includeClassImages?: boolean + }) => ipcRenderer.invoke('student:load', payload), listStudentProfiles: (payload: { page: number pageSize: number @@ -43,6 +46,14 @@ const api = { ipcRenderer.invoke('student:generate-comment', payload), generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) => ipcRenderer.invoke('student:generate-comments', payload), + addClassZodiacs: (payload: { classId: string }) => + ipcRenderer.invoke('student:add-class-zodiacs', payload), + pauseCommentGeneration: (batchId: string) => + ipcRenderer.invoke('comments:pause-generation', batchId), + resumeCommentGeneration: (batchId: string) => + ipcRenderer.invoke('comments:resume-generation', batchId), + stopCommentGeneration: (batchId: string) => + ipcRenderer.invoke('comments:stop-generation', batchId), listModels: (payload: { baseUrl: string; apiKey: string }) => ipcRenderer.invoke('models:list', payload), testModelConnection: (modelConfig: { @@ -58,7 +69,7 @@ const api = { classItem: { id: string name: string - type: 'cheap' | 'noble' + type: string teacherNames: string[] familyPhoto?: string } @@ -89,7 +100,7 @@ const api = { classes: Array<{ id: string name: string - type: 'cheap' | 'noble' + type: string teacherNames: string[] familyPhoto?: string }> @@ -120,7 +131,7 @@ const api = { classes: Array<{ id: string name: string - type: 'cheap' | 'noble' + type: string teacherNames: string[] familyPhoto?: string }> @@ -135,6 +146,13 @@ const api = { maxTokens: string systemPrompt: string }) => ipcRenderer.invoke('settings:save', modelConfig), + saveClassTypeConfigs: ( + classTypeConfigs: Array<{ + id: string + label: string + courseContent: string + }> + ) => ipcRenderer.invoke('settings:save-class-types', classTypeConfigs), loadTemplates: () => ipcRenderer.invoke('templates:load'), selectTemplateFile: () => ipcRenderer.invoke('templates:select-file'), parseTemplatePlaceholders: (filePath: string) => @@ -153,14 +171,48 @@ const api = { loadReports: () => ipcRenderer.invoke('reports:load'), generateReports: (payload: { templateId: string; classId: string; studentIds?: string[] }) => ipcRenderer.invoke('reports:generate', payload), + pauseReportGeneration: (batchId: string) => + ipcRenderer.invoke('reports:pause-generation', batchId), + resumeReportGeneration: (batchId: string) => + ipcRenderer.invoke('reports:resume-generation', batchId), + stopReportGeneration: (batchId: string) => ipcRenderer.invoke('reports:stop-generation', batchId), openReport: (filePath: string) => ipcRenderer.invoke('reports:open', filePath), downloadReport: (id: string) => ipcRenderer.invoke('reports:download', id), downloadClassReports: (payload: { classId: string }) => ipcRenderer.invoke('reports:download-class', payload), + onReportDownloadProgress: ( + callback: (progress: { + batchId: string + status: 'started' | 'file-started' | 'file-finished' | 'writing' | 'finished' | 'failed' + classId: string + className: string + reportId?: string + reportTitle?: string + current: number + total: number + filePath?: string + error?: string + }) => void + ): (() => void) => { + const listener = ( + _: Electron.IpcRendererEvent, + progress: Parameters[0] + ): void => callback(progress) + + ipcRenderer.on('reports:download-class-progress', listener) + return () => ipcRenderer.removeListener('reports:download-class-progress', listener) + }, onReportGenerationProgress: ( callback: (progress: { batchId: string - status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + status: + | 'started' + | 'student-started' + | 'student-finished' + | 'student-failed' + | 'paused' + | 'stopped' + | 'finished' classId: string className: string studentId?: string @@ -182,7 +234,14 @@ const api = { onCommentGenerationProgress: ( callback: (progress: { batchId: string - status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished' + status: + | 'started' + | 'student-started' + | 'student-finished' + | 'student-failed' + | 'paused' + | 'stopped' + | 'finished' classId?: string className?: string studentId?: string diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a9bbfda..b39e46c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' +import { GenerationProgressToasts } from '@renderer/components/feedback/GenerationProgressToasts' import { Toaster } from '@renderer/components/ui/sonner' import { AppLayout } from '@renderer/layouts/AppLayout' import { ClassPage } from '@renderer/pages/ClassPage' @@ -32,6 +33,7 @@ function App(): React.JSX.Element { } /> + ) diff --git a/src/renderer/src/components/feedback/GenerationProgressToasts.tsx b/src/renderer/src/components/feedback/GenerationProgressToasts.tsx new file mode 100644 index 0000000..acd8176 --- /dev/null +++ b/src/renderer/src/components/feedback/GenerationProgressToasts.tsx @@ -0,0 +1,192 @@ +import { useEffect } from 'react' +import { Bot, Download, Wand2 } from 'lucide-react' +import { toast } from 'sonner' + +import { showProgressToast } from '@renderer/components/ui/progress-toast' + +function getToastId(kind: 'comments' | 'reports' | 'report-downloads', batchId: string): string { + return `${kind}-progress-${batchId}` +} + +export function GenerationProgressToasts(): null { + useEffect(() => { + return window.api.onCommentGenerationProgress((progress) => { + const toastId = getToastId('comments', progress.batchId) + + if (progress.status === 'finished') { + toast.dismiss(toastId) + return + } + + const isPaused = progress.status === 'paused' + const isStopped = progress.status === 'stopped' + + showProgressToast({ + id: toastId, + icon: Bot, + title: isStopped + ? '评语生成已停止' + : isPaused + ? '评语生成已暂停' + : progress.studentName + ? `正在生成 ${progress.studentName} 的评语` + : '正在准备生成评语', + description: `${progress.className || '学生数据'} · ${progress.current}/${progress.total}`, + current: progress.current, + total: progress.total, + status: + progress.status === 'student-failed' + ? 'error' + : isStopped + ? 'stopped' + : isPaused + ? 'paused' + : 'running', + statusLabel: + progress.status === 'student-failed' + ? '本份失败' + : isStopped + ? '已停止' + : isPaused + ? '已暂停' + : '生成中', + error: progress.error, + duration: isStopped ? 5000 : undefined, + actionLabel: isStopped ? undefined : isPaused ? '继续' : '暂停', + onAction: isStopped + ? undefined + : async () => { + const response = isPaused + ? await window.api.resumeCommentGeneration(progress.batchId) + : await window.api.pauseCommentGeneration(progress.batchId) + + if (!response.ok) { + toast.error(isPaused ? '继续生成失败' : '暂停生成失败', { + description: response.message + }) + } + }, + secondaryActionLabel: isStopped ? undefined : '停止', + onSecondaryAction: isStopped + ? undefined + : async () => { + const response = await window.api.stopCommentGeneration(progress.batchId) + + if (!response.ok) { + toast.error('停止生成失败', { + description: response.message + }) + } + } + }) + }) + }, []) + + useEffect(() => { + return window.api.onReportGenerationProgress((progress) => { + const toastId = getToastId('reports', progress.batchId) + + if (progress.status === 'finished') { + toast.dismiss(toastId) + return + } + + const isPaused = progress.status === 'paused' + const isStopped = progress.status === 'stopped' + + showProgressToast({ + id: toastId, + icon: Wand2, + title: isStopped + ? '报告生成已停止' + : isPaused + ? '报告生成已暂停' + : progress.studentName + ? `正在生成 ${progress.studentName} 的报告` + : '正在准备生成报告', + description: `${progress.className} · ${progress.current}/${progress.total}`, + current: progress.current, + total: progress.total, + status: + progress.status === 'student-failed' + ? 'error' + : isStopped + ? 'stopped' + : isPaused + ? 'paused' + : 'running', + statusLabel: + progress.status === 'student-failed' + ? '本份失败' + : isStopped + ? '已停止' + : isPaused + ? '已暂停' + : '生成中', + error: progress.error, + duration: isStopped ? 5000 : undefined, + actionLabel: isStopped ? undefined : isPaused ? '继续' : '暂停', + onAction: isStopped + ? undefined + : async () => { + const response = isPaused + ? await window.api.resumeReportGeneration(progress.batchId) + : await window.api.pauseReportGeneration(progress.batchId) + + if (!response.ok) { + toast.error(isPaused ? '继续生成失败' : '暂停生成失败', { + description: response.message + }) + } + }, + secondaryActionLabel: isStopped ? undefined : '停止', + onSecondaryAction: isStopped + ? undefined + : async () => { + const response = await window.api.stopReportGeneration(progress.batchId) + + if (!response.ok) { + toast.error('停止生成失败', { + description: response.message + }) + } + } + }) + }) + }, []) + + useEffect(() => { + return window.api.onReportDownloadProgress((progress) => { + const toastId = getToastId('report-downloads', progress.batchId) + const isFinished = progress.status === 'finished' + const isFailed = progress.status === 'failed' + const isWriting = progress.status === 'writing' + const activeReportTitle = progress.reportTitle + ? `正在打包 ${progress.reportTitle}` + : '正在打包班级报告' + + showProgressToast({ + id: toastId, + icon: Download, + title: isFinished + ? '班级报告导出完成' + : isFailed + ? '班级报告导出失败' + : isWriting + ? '正在写入压缩包' + : activeReportTitle, + description: isFinished + ? progress.filePath + : `${progress.className} · ${progress.current}/${progress.total}`, + current: progress.current, + total: progress.total, + status: isFinished ? 'success' : isFailed ? 'error' : 'running', + statusLabel: isFinished ? '已完成' : isFailed ? '失败' : isWriting ? '写入中' : '打包中', + error: progress.error, + duration: isFinished || isFailed ? 5000 : undefined + }) + }) + }, []) + + return null +} diff --git a/src/renderer/src/components/settings/ModelSettingsForm.tsx b/src/renderer/src/components/settings/ModelSettingsForm.tsx index 3bf2d73..1bf1713 100644 --- a/src/renderer/src/components/settings/ModelSettingsForm.tsx +++ b/src/renderer/src/components/settings/ModelSettingsForm.tsx @@ -1,4 +1,15 @@ -import { Bot, Eye, KeyRound, ListRestart, Loader2, Save, SlidersHorizontal } from 'lucide-react' +import { + Bot, + BookOpen, + Eye, + KeyRound, + ListRestart, + Loader2, + Plus, + Save, + SlidersHorizontal, + Trash2 +} from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -6,7 +17,8 @@ import { Button } from '@renderer/components/ui/button' import { Card, CardContent, CardHeader } from '@renderer/components/ui/card' import { Input } from '@renderer/components/ui/input' import { ScrollArea } from '@renderer/components/ui/scroll-area' -import type { ModelConfig, SettingKey } from '@renderer/types/app' +import { DEFAULT_CLASS_TYPE_CONFIGS } from '@renderer/student/classes' +import type { ClassTypeConfig, ModelConfig, SettingKey } from '@renderer/types/app' import { Field } from './Field' @@ -23,6 +35,9 @@ const defaultModelConfig: ModelConfig = { export function ModelSettingsForm(): React.JSX.Element { const [setting, setSetting] = useState('model') const [modelConfig, setModelConfig] = useState(defaultModelConfig) + const [classTypeConfigs, setClassTypeConfigs] = useState( + DEFAULT_CLASS_TYPE_CONFIGS + ) const [modelOptions, setModelOptions] = useState([]) const [modelListLoading, setModelListLoading] = useState(false) const [settingsSaving, setSettingsSaving] = useState(false) @@ -48,6 +63,8 @@ export function ModelSettingsForm(): React.JSX.Element { ) toast.success('已加载本地配置') } + + setClassTypeConfigs(response.classTypeConfigs) }) }, []) @@ -93,6 +110,70 @@ export function ModelSettingsForm(): React.JSX.Element { } } + const updateClassTypeConfig = ( + index: number, + key: keyof ClassTypeConfig, + value: string + ): void => { + setClassTypeConfigs((currentConfigs) => + currentConfigs.map((config, configIndex) => + configIndex === index ? { ...config, [key]: value } : config + ) + ) + } + + const addClassTypeConfig = (): void => { + setClassTypeConfigs((currentConfigs) => [ + ...currentConfigs, + { + id: `class-${currentConfigs.length + 1}`, + label: '新班级类型', + courseContent: '' + } + ]) + } + + const removeClassTypeConfig = (index: number): void => { + setClassTypeConfigs((currentConfigs) => + currentConfigs.filter((_, configIndex) => configIndex !== index) + ) + } + + const saveClassTypeConfigs = async (): Promise => { + const invalidConfig = classTypeConfigs.find( + (config) => !config.id.trim() || !config.label.trim() + ) + + if (invalidConfig) { + toast.error('请填写班级类型 ID 和名称') + return + } + + const duplicateIds = classTypeConfigs + .map((config) => config.id.trim()) + .filter((id, index, ids) => ids.indexOf(id) !== index) + + if (duplicateIds.length > 0) { + toast.error('班级类型 ID 不能重复') + return + } + + setSettingsSaving(true) + + try { + const response = await window.api.saveClassTypeConfigs(classTypeConfigs) + + if (!response.ok) { + toast.error(response.message) + return + } + + toast.success('班级类型配置已保存') + } finally { + setSettingsSaving(false) + } + } + const testConnection = async (): Promise => { setConnectionTesting(true) @@ -127,136 +208,218 @@ export function ModelSettingsForm(): React.JSX.Element { 大模型配置 + -
大模型配置
+
{setting === 'model' ? '大模型配置' : '班级类型配置'}
+ {setting === 'model' ? ( + + ) : ( + + )} -
-
- - updateModelConfig('provider', event.target.value)} - placeholder="OpenAI Compatible" - /> - - - updateModelConfig('model', event.target.value)} - placeholder="输入或从候选模型中选择" - /> - - {modelOptions.map((model) => ( -