feat: enhance class report workflows
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -3,5 +3,10 @@ allowBuilds:
|
|||||||
electron: true
|
electron: true
|
||||||
electron-winstaller: true
|
electron-winstaller: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- electron
|
||||||
|
- esbuild
|
||||||
|
|
||||||
registries:
|
registries:
|
||||||
default: https://registry.npmmirror.com/
|
default: https://registry.npmmirror.com/
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
downloadReport,
|
downloadReport,
|
||||||
generateReports,
|
generateReports,
|
||||||
loadReports,
|
loadReports,
|
||||||
openReport
|
openReport,
|
||||||
|
pauseReportGeneration,
|
||||||
|
resumeReportGeneration,
|
||||||
|
stopReportGeneration
|
||||||
} from '../services/reportService'
|
} from '../services/reportService'
|
||||||
import type {
|
import type {
|
||||||
DeleteAllReportsResponse,
|
DeleteAllReportsResponse,
|
||||||
@@ -18,7 +21,8 @@ import type {
|
|||||||
GenerateReportsInput,
|
GenerateReportsInput,
|
||||||
GenerateReportsResponse,
|
GenerateReportsResponse,
|
||||||
LoadReportsResponse,
|
LoadReportsResponse,
|
||||||
OpenReportResponse
|
OpenReportResponse,
|
||||||
|
ReportGenerationControlResponse
|
||||||
} from '../types/report'
|
} from '../types/report'
|
||||||
|
|
||||||
function getErrorMessage(error: unknown): string {
|
function getErrorMessage(error: unknown): string {
|
||||||
@@ -57,6 +61,54 @@ export function registerReportIpc(): void {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'reports:pause-generation',
|
||||||
|
async (_, batchId: string): Promise<ReportGenerationControlResponse> => {
|
||||||
|
if (pauseReportGeneration(batchId)) {
|
||||||
|
return {
|
||||||
|
ok: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '当前没有可暂停的报告生成任务'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'reports:resume-generation',
|
||||||
|
async (_, batchId: string): Promise<ReportGenerationControlResponse> => {
|
||||||
|
if (resumeReportGeneration(batchId)) {
|
||||||
|
return {
|
||||||
|
ok: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '当前没有可继续的报告生成任务'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'reports:stop-generation',
|
||||||
|
async (_, batchId: string): Promise<ReportGenerationControlResponse> => {
|
||||||
|
if (stopReportGeneration(batchId)) {
|
||||||
|
return {
|
||||||
|
ok: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '当前没有可停止的报告生成任务'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle('reports:open', async (_, filePath: string): Promise<OpenReportResponse> => {
|
ipcMain.handle('reports:open', async (_, filePath: string): Promise<OpenReportResponse> => {
|
||||||
try {
|
try {
|
||||||
await openReport(filePath)
|
await openReport(filePath)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ipcMain } from 'electron'
|
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 { ModelConfig } from '../types/model'
|
||||||
import type { LoadSettingsResponse, SaveSettingsResponse } from '../types/settings'
|
import type { ClassTypeConfig, LoadSettingsResponse, SaveSettingsResponse } from '../types/settings'
|
||||||
|
|
||||||
export function registerSettingsIpc(): void {
|
export function registerSettingsIpc(): void {
|
||||||
ipcMain.handle('settings:load', async (): Promise<LoadSettingsResponse> => {
|
ipcMain.handle('settings:load', async (): Promise<LoadSettingsResponse> => {
|
||||||
@@ -12,6 +12,14 @@ export function registerSettingsIpc(): void {
|
|||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'settings:save',
|
'settings:save',
|
||||||
async (_, modelConfig: ModelConfig): Promise<SaveSettingsResponse> => {
|
async (_, modelConfig: ModelConfig): Promise<SaveSettingsResponse> => {
|
||||||
return saveSettings(modelConfig)
|
return saveSettings(modelConfig)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'settings:save-class-types',
|
||||||
|
async (_, classTypeConfigs: ClassTypeConfig[]): Promise<SaveSettingsResponse> => {
|
||||||
|
return saveClassTypeConfigs(classTypeConfigs)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
addClassZodiacs,
|
||||||
deleteClassWithProfiles,
|
deleteClassWithProfiles,
|
||||||
deleteStudentProfile,
|
deleteStudentProfile,
|
||||||
getStudentProfile,
|
getStudentProfile,
|
||||||
@@ -10,11 +11,16 @@ import {
|
|||||||
loadStudentData,
|
loadStudentData,
|
||||||
migrateStoredImagesToFiles,
|
migrateStoredImagesToFiles,
|
||||||
migrateLocalStudentData,
|
migrateLocalStudentData,
|
||||||
|
pauseCommentGeneration,
|
||||||
replaceProfilesForClass,
|
replaceProfilesForClass,
|
||||||
|
resumeCommentGeneration,
|
||||||
saveClasses,
|
saveClasses,
|
||||||
|
stopCommentGeneration,
|
||||||
updateStudentProfile
|
updateStudentProfile
|
||||||
} from '../services/studentService'
|
} from '../services/studentService'
|
||||||
import type {
|
import type {
|
||||||
|
AddClassZodiacsInput,
|
||||||
|
AddClassZodiacsResponse,
|
||||||
ClassProfile,
|
ClassProfile,
|
||||||
ChildProfile,
|
ChildProfile,
|
||||||
DeleteClassResponse,
|
DeleteClassResponse,
|
||||||
@@ -30,6 +36,7 @@ import type {
|
|||||||
LoadStudentDataResponse,
|
LoadStudentDataResponse,
|
||||||
ReplaceClassProfilesResponse,
|
ReplaceClassProfilesResponse,
|
||||||
SaveClassesResponse,
|
SaveClassesResponse,
|
||||||
|
TaskControlResponse,
|
||||||
UpdateStudentProfileInput,
|
UpdateStudentProfileInput,
|
||||||
UpdateStudentProfileResponse
|
UpdateStudentProfileResponse
|
||||||
} from '../types/student'
|
} from '../types/student'
|
||||||
@@ -146,6 +153,65 @@ export function registerStudentIpc(): void {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'student:add-class-zodiacs',
|
||||||
|
async (_, payload: AddClassZodiacsInput): Promise<AddClassZodiacsResponse> => {
|
||||||
|
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<TaskControlResponse> => {
|
||||||
|
if (pauseCommentGeneration(batchId)) {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '当前没有可暂停的评语生成任务'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'comments:resume-generation',
|
||||||
|
async (_, batchId: string): Promise<TaskControlResponse> => {
|
||||||
|
if (resumeCommentGeneration(batchId)) {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '当前没有可继续的评语生成任务'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'comments:stop-generation',
|
||||||
|
async (_, batchId: string): Promise<TaskControlResponse> => {
|
||||||
|
if (stopCommentGeneration(batchId)) {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '当前没有可停止的评语生成任务'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'student:save-classes',
|
'student:save-classes',
|
||||||
async (_, classes: ClassProfile[]): Promise<SaveClassesResponse> => {
|
async (_, classes: ClassProfile[]): Promise<SaveClassesResponse> => {
|
||||||
@@ -170,9 +236,9 @@ export function registerStudentIpc(): void {
|
|||||||
payload: { classItem: ClassProfile; profiles: ChildProfile[] }
|
payload: { classItem: ClassProfile; profiles: ChildProfile[] }
|
||||||
): Promise<ReplaceClassProfilesResponse> => {
|
): Promise<ReplaceClassProfilesResponse> => {
|
||||||
try {
|
try {
|
||||||
await replaceProfilesForClass(payload.classItem, payload.profiles)
|
|
||||||
return {
|
return {
|
||||||
ok: true
|
ok: true,
|
||||||
|
...(await replaceProfilesForClass(payload.classItem, payload.profiles))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,16 +18,29 @@ import { TemplateEntitySchema, type TemplateEntity } from '../entities/TemplateE
|
|||||||
import type {
|
import type {
|
||||||
DownloadClassReportsInput,
|
DownloadClassReportsInput,
|
||||||
GenerateReportsInput,
|
GenerateReportsInput,
|
||||||
|
ReportDownloadProgress,
|
||||||
ReportFormat,
|
ReportFormat,
|
||||||
ReportGenerationProgress,
|
ReportGenerationProgress,
|
||||||
ReportItem
|
ReportItem
|
||||||
} from '../types/report'
|
} from '../types/report'
|
||||||
|
import type { ClassTypeConfig } from '../types/settings'
|
||||||
import { getAppDataSource } from './databaseService'
|
import { getAppDataSource } from './databaseService'
|
||||||
import { readImageBuffer } from './imageStorageService'
|
import { readImageBuffer } from './imageStorageService'
|
||||||
|
import { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService'
|
||||||
|
|
||||||
const REPORT_FOLDER_NAME = 'reports'
|
const REPORT_FOLDER_NAME = 'reports'
|
||||||
const TEMPLATE_FOLDER_NAME = 'templates'
|
const TEMPLATE_FOLDER_NAME = 'templates'
|
||||||
|
|
||||||
|
type ReportGenerationController = {
|
||||||
|
batchId: string
|
||||||
|
paused: boolean
|
||||||
|
stopped: boolean
|
||||||
|
lastProgress: ReportGenerationProgress
|
||||||
|
resumeWaiters: Array<() => void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportGenerationControllers = new Map<string, ReportGenerationController>()
|
||||||
|
|
||||||
type ReportStudentData = {
|
type ReportStudentData = {
|
||||||
id: string
|
id: string
|
||||||
classId: string
|
classId: string
|
||||||
@@ -50,6 +63,9 @@ type ReportStudentData = {
|
|||||||
comment: string
|
comment: string
|
||||||
comments: string
|
comments: string
|
||||||
teacherName: string
|
teacherName: string
|
||||||
|
classType: string
|
||||||
|
classTypeLabel: string
|
||||||
|
courseContent: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudentProfileWithAliases = StudentProfileEntity & {
|
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<void> {
|
||||||
|
if (!controller.paused || controller.stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReportControllerProgress(controller, {
|
||||||
|
...controller.lastProgress,
|
||||||
|
status: 'paused',
|
||||||
|
studentId: undefined,
|
||||||
|
studentName: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((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 {
|
function getFormatFromExtension(extension: string): ReportFormat {
|
||||||
if (['.doc', '.docx'].includes(extension)) {
|
if (['.doc', '.docx'].includes(extension)) {
|
||||||
return 'word'
|
return 'word'
|
||||||
@@ -162,13 +275,33 @@ function getStudentName(student: StudentProfileEntity): string {
|
|||||||
return profile.studentName || profile.name || profile.englishName || ''
|
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 friends = parseJsonList(student.friends).join('、') || ' '
|
||||||
const hobbies = parseJsonList(student.hobbies).join('、') || ' '
|
const hobbies = parseJsonList(student.hobbies).join('、') || ' '
|
||||||
const favoriteGames = parseJsonList(student.favoriteGames).join('、') || ' '
|
const favoriteGames = parseJsonList(student.favoriteGames).join('、') || ' '
|
||||||
const favoriteFoods = parseJsonList(student.favoriteFoods).join('、') || ' '
|
const favoriteFoods = parseJsonList(student.favoriteFoods).join('、') || ' '
|
||||||
const teacherName =
|
const teacherName =
|
||||||
parseTeacherNames(classItem.teacherNames || classItem.teacherName || '').join(' ') || ' '
|
parseTeacherNames(classItem.teacherNames || classItem.teacherName || '').join(' ') || ' '
|
||||||
|
const classType = classItem.type || 'cheap'
|
||||||
|
const classTypeConfig = getClassTypeConfig(classType, classTypeConfigs)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: student.id,
|
id: student.id,
|
||||||
@@ -191,7 +324,10 @@ function getStudentData(student: StudentProfileEntity, classItem: ClassEntity):
|
|||||||
traits: student.traits || ' ',
|
traits: student.traits || ' ',
|
||||||
comment: student.comment || '暂无评语',
|
comment: student.comment || '暂无评语',
|
||||||
comments: student.comment || '暂无评语',
|
comments: student.comment || '暂无评语',
|
||||||
teacherName
|
teacherName,
|
||||||
|
classType,
|
||||||
|
classTypeLabel: classTypeConfig.label,
|
||||||
|
courseContent: classTypeConfig.courseContent || ' '
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +356,14 @@ function buildTextReplacements(
|
|||||||
name: studentData.name,
|
name: studentData.name,
|
||||||
class: classItem.name,
|
class: classItem.name,
|
||||||
className: 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,
|
comments: studentData.comments,
|
||||||
comment: studentData.comment,
|
comment: studentData.comment,
|
||||||
teacherName: studentData.teacherName,
|
teacherName: studentData.teacherName,
|
||||||
@@ -424,7 +568,7 @@ function replaceShapeTextPreservingStyle(
|
|||||||
shapeName: string,
|
shapeName: string,
|
||||||
text: string
|
text: string
|
||||||
): string {
|
): string {
|
||||||
return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
|
return slideXml.replace(/<p:sp(?:\s[^>]*)?>[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
|
||||||
if (!hasShapeName(shapeXml, shapeName)) {
|
if (!hasShapeName(shapeXml, shapeName)) {
|
||||||
return shapeXml
|
return shapeXml
|
||||||
}
|
}
|
||||||
@@ -470,24 +614,6 @@ 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] ?? ''
|
||||||
}
|
}
|
||||||
@@ -496,7 +622,7 @@ function replaceShapeInlinePlaceholdersPreservingStyle(
|
|||||||
slideXml: string,
|
slideXml: string,
|
||||||
replacements: Record<string, string>
|
replacements: Record<string, string>
|
||||||
): string {
|
): string {
|
||||||
return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
|
return slideXml.replace(/<p:sp(?:\s[^>]*)?>[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
|
||||||
const textNodes = [...shapeXml.matchAll(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g)]
|
const textNodes = [...shapeXml.matchAll(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g)]
|
||||||
|
|
||||||
if (textNodes.length === 0) {
|
if (textNodes.length === 0) {
|
||||||
@@ -548,7 +674,6 @@ 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)
|
||||||
|
|
||||||
@@ -707,14 +832,15 @@ async function buildPptxReport(
|
|||||||
template: TemplateEntity,
|
template: TemplateEntity,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
student: StudentProfileEntity,
|
student: StudentProfileEntity,
|
||||||
classItem: ClassEntity
|
classItem: ClassEntity,
|
||||||
|
classTypeConfigs: ClassTypeConfig[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const templateFilePath = resolveTemplateFilePath(template.filePath)
|
const templateFilePath = resolveTemplateFilePath(template.filePath)
|
||||||
|
|
||||||
const ppt = await PPTXTemplater.load(templateFilePath, {
|
const ppt = await PPTXTemplater.load(templateFilePath, {
|
||||||
logLevel: 'silent'
|
logLevel: 'silent'
|
||||||
})
|
})
|
||||||
const studentData = getStudentData(student, classItem)
|
const studentData = getStudentData(student, classItem, classTypeConfigs)
|
||||||
const textReplacements = buildTextReplacements(studentData, classItem)
|
const textReplacements = buildTextReplacements(studentData, classItem)
|
||||||
const replacedImageKeys = new Set<string>()
|
const replacedImageKeys = new Set<string>()
|
||||||
|
|
||||||
@@ -757,6 +883,7 @@ export async function loadReports(): Promise<ReportItem[]> {
|
|||||||
export async function generateReports(input: GenerateReportsInput): Promise<{
|
export async function generateReports(input: GenerateReportsInput): Promise<{
|
||||||
reports: ReportItem[]
|
reports: ReportItem[]
|
||||||
skipped: Array<{ studentName: string; reason: string }>
|
skipped: Array<{ studentName: string; reason: string }>
|
||||||
|
stopped?: boolean
|
||||||
}> {
|
}> {
|
||||||
const source = await getAppDataSource()
|
const source = await getAppDataSource()
|
||||||
const templateRepository = source.getRepository<TemplateEntity>(TemplateEntitySchema)
|
const templateRepository = source.getRepository<TemplateEntity>(TemplateEntitySchema)
|
||||||
@@ -804,6 +931,11 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
|
|||||||
throw new Error('当前班级没有可生成的学生')
|
throw new Error('当前班级没有可生成的学生')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settingsResponse = await loadSettings()
|
||||||
|
const classTypeConfigs = settingsResponse.ok
|
||||||
|
? settingsResponse.classTypeConfigs
|
||||||
|
: DEFAULT_CLASS_TYPE_CONFIGS
|
||||||
|
|
||||||
await mkdir(getReportStoragePath(), { recursive: true })
|
await mkdir(getReportStoragePath(), { recursive: true })
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
@@ -811,15 +943,28 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
|
|||||||
const skipped: Array<{ studentName: string; reason: string }> = []
|
const skipped: Array<{ studentName: string; reason: string }> = []
|
||||||
const batchTraceId = randomUUID()
|
const batchTraceId = randomUUID()
|
||||||
let completedCount = 0
|
let completedCount = 0
|
||||||
|
const controller: ReportGenerationController = {
|
||||||
sendReportProgress({
|
|
||||||
batchId: batchTraceId,
|
batchId: batchTraceId,
|
||||||
status: 'started',
|
paused: false,
|
||||||
classId: reportClass.id,
|
stopped: false,
|
||||||
className: reportClass.name,
|
lastProgress: {
|
||||||
current: 0,
|
batchId: batchTraceId,
|
||||||
total: targetStudents.length
|
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<void> {
|
async function generateStudentReport(student: StudentProfileEntity): Promise<void> {
|
||||||
const studentName = getStudentName(student) || '未命名学生'
|
const studentName = getStudentName(student) || '未命名学生'
|
||||||
@@ -829,7 +974,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
|
|||||||
const outputPath = join(getReportStoragePath(), `${reportId}${reportTemplate.fileExtension}`)
|
const outputPath = join(getReportStoragePath(), `${reportId}${reportTemplate.fileExtension}`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendReportProgress({
|
publishProgress({
|
||||||
batchId: batchTraceId,
|
batchId: batchTraceId,
|
||||||
status: 'student-started',
|
status: 'student-started',
|
||||||
classId: reportClass.id,
|
classId: reportClass.id,
|
||||||
@@ -840,7 +985,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
|
|||||||
total: targetStudents.length
|
total: targetStudents.length
|
||||||
})
|
})
|
||||||
|
|
||||||
await buildPptxReport(reportTemplate, outputPath, student, reportClass)
|
await buildPptxReport(reportTemplate, outputPath, student, reportClass, classTypeConfigs)
|
||||||
|
|
||||||
const entity: ReportEntity = {
|
const entity: ReportEntity = {
|
||||||
id: reportId,
|
id: reportId,
|
||||||
@@ -863,7 +1008,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
|
|||||||
const reportItem = mapReportEntity(entity)
|
const reportItem = mapReportEntity(entity)
|
||||||
reports.push(reportItem)
|
reports.push(reportItem)
|
||||||
completedCount += 1
|
completedCount += 1
|
||||||
sendReportProgress({
|
publishProgress({
|
||||||
batchId: batchTraceId,
|
batchId: batchTraceId,
|
||||||
status: 'student-finished',
|
status: 'student-finished',
|
||||||
classId: reportClass.id,
|
classId: reportClass.id,
|
||||||
@@ -882,7 +1027,7 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
|
|||||||
reason
|
reason
|
||||||
})
|
})
|
||||||
completedCount += 1
|
completedCount += 1
|
||||||
sendReportProgress({
|
publishProgress({
|
||||||
batchId: batchTraceId,
|
batchId: batchTraceId,
|
||||||
status: 'student-failed',
|
status: 'student-failed',
|
||||||
classId: reportClass.id,
|
classId: reportClass.id,
|
||||||
@@ -897,20 +1042,30 @@ export async function generateReports(input: GenerateReportsInput): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const student of targetStudents) {
|
try {
|
||||||
await generateStudentReport(student)
|
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({
|
return { reports, skipped, stopped: controller.stopped }
|
||||||
batchId: batchTraceId,
|
|
||||||
status: 'finished',
|
|
||||||
classId: reportClass.id,
|
|
||||||
className: reportClass.name,
|
|
||||||
current: targetStudents.length,
|
|
||||||
total: targetStudents.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return { reports, skipped }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openReport(filePath: string): Promise<void> {
|
export async function openReport(filePath: string): Promise<void> {
|
||||||
@@ -983,27 +1138,91 @@ export async function downloadClassReports(
|
|||||||
return { filePath: '', reportCount: reports.length, canceled: true }
|
return { filePath: '', reportCount: reports.length, canceled: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const batchId = randomUUID()
|
||||||
const zip = new JSZip()
|
const zip = new JSZip()
|
||||||
const usedFileNames = new Map<string, number>()
|
const usedFileNames = new Map<string, number>()
|
||||||
|
let completedCount = 0
|
||||||
|
|
||||||
for (const report of reports) {
|
function publishProgress(
|
||||||
await access(report.filePath)
|
progress: Omit<ReportDownloadProgress, 'batchId' | 'classId' | 'className'>
|
||||||
const baseFileName = getReportDownloadFileName(report)
|
): void {
|
||||||
const usedCount = usedFileNames.get(baseFileName) ?? 0
|
sendReportDownloadProgress({
|
||||||
usedFileNames.set(baseFileName, usedCount + 1)
|
batchId,
|
||||||
const fileName =
|
classId: input.classId,
|
||||||
usedCount === 0
|
className,
|
||||||
? baseFileName
|
...progress
|
||||||
: `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}`
|
})
|
||||||
|
|
||||||
zip.file(fileName, await readFile(report.filePath))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const zipBuffer = await zip.generateAsync({
|
try {
|
||||||
type: 'nodebuffer',
|
publishProgress({
|
||||||
compression: 'DEFLATE'
|
status: 'started',
|
||||||
})
|
current: 0,
|
||||||
await writeFile(result.filePath, zipBuffer)
|
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 }
|
return { filePath: result.filePath, reportCount: reports.length }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,34 @@ import { join } from 'path'
|
|||||||
|
|
||||||
import type { ModelConfig } from '../types/model'
|
import type { ModelConfig } from '../types/model'
|
||||||
import type {
|
import type {
|
||||||
|
ClassTypeConfig,
|
||||||
LoadSettingsResponse,
|
LoadSettingsResponse,
|
||||||
SaveSettingsResponse,
|
SaveSettingsResponse,
|
||||||
SettingsFile,
|
SettingsFile,
|
||||||
StoredApiKey
|
StoredApiKey
|
||||||
} from '../types/settings'
|
} 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 {
|
function getSettingsPath(): string {
|
||||||
return join(app.getPath('userData'), 'settings.json')
|
return join(app.getPath('userData'), 'settings.json')
|
||||||
}
|
}
|
||||||
@@ -38,27 +60,65 @@ function decodeApiKey(apiKey?: StoredApiKey): string {
|
|||||||
return apiKey.value
|
return apiKey.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeClassTypeConfig(config: Partial<ClassTypeConfig>): 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>[]): 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<SettingsFile> {
|
||||||
|
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<void> {
|
||||||
|
await writeFile(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSettings(): Promise<LoadSettingsResponse> {
|
export async function loadSettings(): Promise<LoadSettingsResponse> {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(getSettingsPath(), 'utf-8').catch((error: NodeJS.ErrnoException) => {
|
const settings = await readSettingsFile()
|
||||||
if (error.code === 'ENOENT') return null
|
const classTypeConfigs = normalizeClassTypeConfigs(settings.classTypeConfigs)
|
||||||
throw error
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return { ok: true, modelConfig: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = JSON.parse(raw) as SettingsFile
|
|
||||||
|
|
||||||
if (!settings.modelConfig) {
|
if (!settings.modelConfig) {
|
||||||
return { ok: true, modelConfig: null }
|
return { ok: true, modelConfig: null, classTypeConfigs }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { apiKey, ...modelConfig } = settings.modelConfig
|
const { apiKey, ...modelConfig } = settings.modelConfig
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
classTypeConfigs,
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
...modelConfig,
|
...modelConfig,
|
||||||
apiKey: decodeApiKey(apiKey)
|
apiKey: decodeApiKey(apiKey)
|
||||||
@@ -74,7 +134,9 @@ export async function loadSettings(): Promise<LoadSettingsResponse> {
|
|||||||
|
|
||||||
export async function saveSettings(modelConfig: ModelConfig): Promise<SaveSettingsResponse> {
|
export async function saveSettings(modelConfig: ModelConfig): Promise<SaveSettingsResponse> {
|
||||||
try {
|
try {
|
||||||
|
const currentSettings = await readSettingsFile()
|
||||||
const settings: SettingsFile = {
|
const settings: SettingsFile = {
|
||||||
|
...currentSettings,
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
provider: modelConfig.provider,
|
provider: modelConfig.provider,
|
||||||
baseUrl: modelConfig.baseUrl,
|
baseUrl: modelConfig.baseUrl,
|
||||||
@@ -86,7 +148,7 @@ export async function saveSettings(modelConfig: ModelConfig): Promise<SaveSettin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeFile(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8')
|
await writeSettingsFile(settings)
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,3 +158,24 @@ export async function saveSettings(modelConfig: ModelConfig): Promise<SaveSettin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveClassTypeConfigs(
|
||||||
|
classTypeConfigs: ClassTypeConfig[]
|
||||||
|
): Promise<SaveSettingsResponse> {
|
||||||
|
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 : '保存班级类型配置失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,10 +17,40 @@ import type {
|
|||||||
ListStudentProfilesInput,
|
ListStudentProfilesInput,
|
||||||
LoadStudentDataInput
|
LoadStudentDataInput
|
||||||
} from '../types/student'
|
} from '../types/student'
|
||||||
|
import type { ClassTypeConfig } from '../types/settings'
|
||||||
import { getAppDataSource } from './databaseService'
|
import { getAppDataSource } from './databaseService'
|
||||||
import { isDataUrlImage, readImageAsDataUrl, storeImageValue } from './imageStorageService'
|
import { isDataUrlImage, readImageAsDataUrl, storeImageValue } from './imageStorageService'
|
||||||
import { getChatCompletionsUrl, logLargeModelRequest } from './modelService'
|
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<string, CommentGenerationController>()
|
||||||
|
const CHINESE_ZODIAC_BY_BRANCH: Record<string, string> = {
|
||||||
|
子: '鼠',
|
||||||
|
丑: '牛',
|
||||||
|
寅: '虎',
|
||||||
|
卯: '兔',
|
||||||
|
辰: '龙',
|
||||||
|
巳: '蛇',
|
||||||
|
午: '马',
|
||||||
|
未: '羊',
|
||||||
|
申: '猴',
|
||||||
|
酉: '鸡',
|
||||||
|
戌: '狗',
|
||||||
|
亥: '猪'
|
||||||
|
}
|
||||||
|
const GREGORIAN_ZODIACS = ['猴', '鸡', '狗', '猪', '鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊']
|
||||||
|
const chineseYearFormatter = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', {
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
})
|
||||||
|
|
||||||
function parseJsonList(value: string): string[] {
|
function parseJsonList(value: string): string[] {
|
||||||
try {
|
try {
|
||||||
@@ -35,8 +65,143 @@ function stringifyList(items: string[]): string {
|
|||||||
return JSON.stringify(items)
|
return JSON.stringify(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeClassType(type: string): 'cheap' | 'noble' {
|
function normalizeClassType(type: string): string {
|
||||||
return type === 'noble' ? 'noble' : 'cheap'
|
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<ChildProfile, 'id' | 'name' | 'englishName' | 'birthday'>
|
||||||
|
): string[] {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
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[] {
|
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 [
|
return [
|
||||||
'请为这名幼儿生成一段成长报告评语。',
|
'请为这名幼儿生成一段学期末成长评语。',
|
||||||
'要求:语气温暖、具体、积极,适合幼儿园成长报告;不要编造资料里没有的姓名、日期或家庭信息;长度控制在 120 到 180 字。',
|
'严格要求:',
|
||||||
|
`1. 第一句必须是“${getGivenName(profile.name || profile.englishName)}宝贝:你好,${courseContent}”。`,
|
||||||
|
'2. 正文保持一段完整段落,不要换行。',
|
||||||
|
'3. 只能依据下方资料描写表现,不要编造具体课程名称、课堂活动、比赛、绘本、故事创编、阅读课等资料中没有的信息。',
|
||||||
|
'4. 语气温暖、具体、积极,适合幼儿园成长报告;结尾委婉提出一个期望并送上祝福。',
|
||||||
|
'5. 字数控制在 150 到 250 字。',
|
||||||
'',
|
'',
|
||||||
`姓名:${profile.name || '未填写'}`,
|
`姓名:${profile.name || '未填写'}`,
|
||||||
`英文名:${profile.englishName || '未填写'}`,
|
`英文名:${profile.englishName || '未填写'}`,
|
||||||
`班级:${profile.className || '未分班'}`,
|
`班级:${profile.className || '未分班'}`,
|
||||||
|
`课程内容:${courseContent || '未配置'}`,
|
||||||
`性别:${profile.gender || '未填写'}`,
|
`性别:${profile.gender || '未填写'}`,
|
||||||
`生日:${profile.birthday || '未填写'}`,
|
`生日:${profile.birthday || '未填写'}`,
|
||||||
`属相:${profile.zodiac || '未填写'}`,
|
`属相:${profile.zodiac || '未填写'}`,
|
||||||
@@ -213,6 +408,23 @@ function buildStudentCommentPrompt(profile: ChildProfile): string {
|
|||||||
].join('\n')
|
].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 {
|
function getStudentDisplayName(profile: ChildProfile | StudentProfileEntity): string {
|
||||||
return profile.name || profile.englishName || '未命名学生'
|
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<void> {
|
||||||
|
if (!controller.paused || controller.stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCommentControllerProgress(controller, {
|
||||||
|
...controller.lastProgress,
|
||||||
|
status: 'paused',
|
||||||
|
studentId: undefined,
|
||||||
|
studentName: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((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<{
|
async function getRepositories(): Promise<{
|
||||||
classRepository: Repository<ClassEntity>
|
classRepository: Repository<ClassEntity>
|
||||||
childRepository: Repository<StudentProfileEntity>
|
childRepository: Repository<StudentProfileEntity>
|
||||||
@@ -241,6 +544,7 @@ export async function loadStudentData(input: LoadStudentDataInput = {}): Promise
|
|||||||
}> {
|
}> {
|
||||||
const includeProfiles = input.includeProfiles ?? true
|
const includeProfiles = input.includeProfiles ?? true
|
||||||
const includeImages = input.includeImages ?? true
|
const includeImages = input.includeImages ?? true
|
||||||
|
const includeClassImages = input.includeClassImages ?? includeImages
|
||||||
const { classRepository, childRepository } = await getRepositories()
|
const { classRepository, childRepository } = await getRepositories()
|
||||||
const classes = await classRepository.find({
|
const classes = await classRepository.find({
|
||||||
order: {
|
order: {
|
||||||
@@ -286,7 +590,7 @@ export async function loadStudentData(input: LoadStudentDataInput = {}): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydratedClasses = includeImages
|
const hydratedClasses = includeClassImages
|
||||||
? await Promise.all(normalizedClasses.map(hydrateClassImages))
|
? await Promise.all(normalizedClasses.map(hydrateClassImages))
|
||||||
: normalizedClasses
|
: normalizedClasses
|
||||||
|
|
||||||
@@ -391,9 +695,73 @@ export async function updateStudentProfile(profile: ChildProfile): Promise<Child
|
|||||||
return mapChildEntity(await hydrateStudentImages(storedProfile))
|
return mapChildEntity(await hydrateStudentImages(storedProfile))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addClassZodiacs(classId: string): Promise<{
|
||||||
|
profiles: ChildProfile[]
|
||||||
|
updatedCount: number
|
||||||
|
skipped: Array<{ studentName: string; reason: string }>
|
||||||
|
}> {
|
||||||
|
const source = await getAppDataSource()
|
||||||
|
const repository = source.getRepository<StudentProfileEntity>(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(
|
async function generateStudentCommentForEntity(
|
||||||
entity: StudentProfileEntity,
|
entity: StudentProfileEntity,
|
||||||
repository: Repository<StudentProfileEntity>
|
repository: Repository<StudentProfileEntity>,
|
||||||
|
classRepository: Repository<ClassEntity>,
|
||||||
|
classTypeConfigs: ClassTypeConfig[]
|
||||||
): Promise<ChildProfile> {
|
): Promise<ChildProfile> {
|
||||||
const profile = mapChildEntity(entity)
|
const profile = mapChildEntity(entity)
|
||||||
const settings = await loadSettings()
|
const settings = await loadSettings()
|
||||||
@@ -409,7 +777,10 @@ async function generateStudentCommentForEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestUrl = getChatCompletionsUrl(modelConfig.baseUrl)
|
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({
|
logLargeModelRequest({
|
||||||
label: '生成学生评语',
|
label: '生成学生评语',
|
||||||
@@ -459,7 +830,7 @@ async function generateStudentCommentForEntity(
|
|||||||
|
|
||||||
const nextProfile: ChildProfile = {
|
const nextProfile: ChildProfile = {
|
||||||
...profile,
|
...profile,
|
||||||
comment: comment.trim(),
|
comment: ensureCommentIncludesCourseContent(comment, courseContent),
|
||||||
commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false })
|
commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,21 +842,41 @@ async function generateStudentCommentForEntity(
|
|||||||
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
|
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
|
||||||
const source = await getAppDataSource()
|
const source = await getAppDataSource()
|
||||||
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||||
|
const classRepository = source.getRepository<ClassEntity>(ClassEntitySchema)
|
||||||
const entity = await repository.findOneBy({ id: profileId })
|
const entity = await repository.findOneBy({ id: profileId })
|
||||||
|
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
throw new Error('学生信息不存在')
|
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<{
|
export async function generateStudentComments(input: GenerateStudentCommentsInput): Promise<{
|
||||||
profiles: ChildProfile[]
|
profiles: ChildProfile[]
|
||||||
skipped: Array<{ studentName: string; reason: string }>
|
skipped: Array<{ studentName: string; reason: string }>
|
||||||
|
stopped?: boolean
|
||||||
}> {
|
}> {
|
||||||
const source = await getAppDataSource()
|
const source = await getAppDataSource()
|
||||||
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||||
|
const classRepository = source.getRepository<ClassEntity>(ClassEntitySchema)
|
||||||
|
const settings = await loadSettings()
|
||||||
|
|
||||||
|
if (!settings.ok) {
|
||||||
|
throw new Error(settings.message)
|
||||||
|
}
|
||||||
|
|
||||||
const selectedIds = new Set(input.profileIds ?? [])
|
const selectedIds = new Set(input.profileIds ?? [])
|
||||||
const allProfiles = await repository.find({
|
const allProfiles = await repository.find({
|
||||||
where: input.classId ? { classId: input.classId } : undefined,
|
where: input.classId ? { classId: input.classId } : undefined,
|
||||||
@@ -507,76 +898,104 @@ export async function generateStudentComments(input: GenerateStudentCommentsInpu
|
|||||||
const className = targetProfiles[0]?.className
|
const className = targetProfiles[0]?.className
|
||||||
const profiles: ChildProfile[] = []
|
const profiles: ChildProfile[] = []
|
||||||
const skipped: Array<{ studentName: string; reason: string }> = []
|
const skipped: Array<{ studentName: string; reason: string }> = []
|
||||||
|
const controller: CommentGenerationController = {
|
||||||
sendCommentProgress({
|
|
||||||
batchId,
|
batchId,
|
||||||
status: 'started',
|
paused: false,
|
||||||
classId: input.classId,
|
stopped: false,
|
||||||
className,
|
lastProgress: {
|
||||||
current: 0,
|
batchId,
|
||||||
total: targetProfiles.length
|
status: 'started',
|
||||||
})
|
classId: input.classId,
|
||||||
|
className,
|
||||||
for (const [profileIndex, profile] of targetProfiles.entries()) {
|
current: 0,
|
||||||
const studentName = getStudentDisplayName(profile)
|
total: targetProfiles.length
|
||||||
|
},
|
||||||
try {
|
resumeWaiters: []
|
||||||
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({
|
commentGenerationControllers.set(batchId, controller)
|
||||||
batchId,
|
|
||||||
status: 'finished',
|
|
||||||
classId: input.classId,
|
|
||||||
className,
|
|
||||||
current: targetProfiles.length,
|
|
||||||
total: targetProfiles.length
|
|
||||||
})
|
|
||||||
|
|
||||||
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<void> {
|
export async function saveClasses(classes: ClassProfile[]): Promise<void> {
|
||||||
@@ -628,16 +1047,91 @@ export async function migrateLocalStudentData(
|
|||||||
export async function replaceProfilesForClass(
|
export async function replaceProfilesForClass(
|
||||||
classItem: ClassProfile,
|
classItem: ClassProfile,
|
||||||
profiles: ChildProfile[]
|
profiles: ChildProfile[]
|
||||||
): Promise<void> {
|
): Promise<{
|
||||||
|
profiles: ChildProfile[]
|
||||||
|
insertedCount: number
|
||||||
|
updatedCount: number
|
||||||
|
skippedCount: number
|
||||||
|
}> {
|
||||||
const source = await getAppDataSource()
|
const source = await getAppDataSource()
|
||||||
|
|
||||||
await source.transaction(async (manager) => {
|
return source.transaction(async (manager) => {
|
||||||
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||||
|
const existingProfiles = await childRepository.find({
|
||||||
|
where: { classId: classItem.id },
|
||||||
|
order: {
|
||||||
|
name: 'ASC',
|
||||||
|
id: 'ASC'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await childRepository.delete({ classId: classItem.id })
|
if (existingProfiles.length === 0) {
|
||||||
await childRepository.save(
|
const storedProfiles = await Promise.all(
|
||||||
await Promise.all(profiles.map((profile) => mapChildProfile(profile, classItem)))
|
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<string, StudentProfileEntity[]>()
|
||||||
|
|
||||||
|
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<string>()
|
||||||
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ export type GenerateReportsInput = {
|
|||||||
|
|
||||||
export type ReportGenerationProgress = {
|
export type ReportGenerationProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
@@ -35,6 +42,19 @@ export type ReportGenerationProgress = {
|
|||||||
error?: string
|
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 =
|
export type LoadReportsResponse =
|
||||||
| {
|
| {
|
||||||
ok: true
|
ok: true
|
||||||
@@ -50,6 +70,16 @@ export type GenerateReportsResponse =
|
|||||||
ok: true
|
ok: true
|
||||||
reports: ReportItem[]
|
reports: ReportItem[]
|
||||||
skipped: Array<{ studentName: string; reason: string }>
|
skipped: Array<{ studentName: string; reason: string }>
|
||||||
|
stopped?: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportGenerationControlResponse =
|
||||||
|
| {
|
||||||
|
ok: true
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
ok: false
|
ok: false
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { ModelConfig } from './model'
|
import type { ModelConfig } from './model'
|
||||||
|
|
||||||
|
export type ClassTypeConfig = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
courseContent: string
|
||||||
|
}
|
||||||
|
|
||||||
export type StoredApiKey = {
|
export type StoredApiKey = {
|
||||||
encoding: 'safeStorage' | 'plain'
|
encoding: 'safeStorage' | 'plain'
|
||||||
value: string
|
value: string
|
||||||
@@ -11,6 +17,7 @@ export type StoredModelConfig = Omit<ModelConfig, 'apiKey'> & {
|
|||||||
|
|
||||||
export type SettingsFile = {
|
export type SettingsFile = {
|
||||||
modelConfig?: StoredModelConfig
|
modelConfig?: StoredModelConfig
|
||||||
|
classTypeConfigs?: ClassTypeConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SaveSettingsResponse =
|
export type SaveSettingsResponse =
|
||||||
@@ -26,6 +33,7 @@ export type LoadSettingsResponse =
|
|||||||
| {
|
| {
|
||||||
ok: true
|
ok: true
|
||||||
modelConfig: ModelConfig | null
|
modelConfig: ModelConfig | null
|
||||||
|
classTypeConfigs: ClassTypeConfig[]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
ok: false
|
ok: false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ClassType = 'cheap' | 'noble'
|
export type ClassType = string
|
||||||
|
|
||||||
export type ClassProfile = {
|
export type ClassProfile = {
|
||||||
id: string
|
id: string
|
||||||
@@ -45,6 +45,7 @@ export type LoadStudentDataResponse =
|
|||||||
export type LoadStudentDataInput = {
|
export type LoadStudentDataInput = {
|
||||||
includeProfiles?: boolean
|
includeProfiles?: boolean
|
||||||
includeImages?: boolean
|
includeImages?: boolean
|
||||||
|
includeClassImages?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListStudentProfilesInput = {
|
export type ListStudentProfilesInput = {
|
||||||
@@ -98,9 +99,32 @@ export type GenerateStudentCommentsInput = {
|
|||||||
profileIds?: string[]
|
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 = {
|
export type CommentGenerationProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId?: string
|
classId?: string
|
||||||
className?: string
|
className?: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
@@ -126,6 +150,7 @@ export type GenerateStudentCommentsResponse =
|
|||||||
ok: true
|
ok: true
|
||||||
profiles: ChildProfile[]
|
profiles: ChildProfile[]
|
||||||
skipped: Array<{ studentName: string; reason: string }>
|
skipped: Array<{ studentName: string; reason: string }>
|
||||||
|
stopped?: boolean
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
ok: false
|
ok: false
|
||||||
@@ -141,9 +166,22 @@ export type SaveClassesResponse =
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaskControlResponse =
|
||||||
|
| {
|
||||||
|
ok: true
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ReplaceClassProfilesResponse =
|
export type ReplaceClassProfilesResponse =
|
||||||
| {
|
| {
|
||||||
ok: true
|
ok: true
|
||||||
|
profiles: ChildProfile[]
|
||||||
|
insertedCount: number
|
||||||
|
updatedCount: number
|
||||||
|
skippedCount: number
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
ok: false
|
ok: false
|
||||||
|
|||||||
Vendored
+75
-4
@@ -30,7 +30,13 @@ type ModelConfig = {
|
|||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClassType = 'cheap' | 'noble'
|
type ClassType = string
|
||||||
|
|
||||||
|
type ClassTypeConfig = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
courseContent: string
|
||||||
|
}
|
||||||
|
|
||||||
type ClassProfile = {
|
type ClassProfile = {
|
||||||
id: string
|
id: string
|
||||||
@@ -82,7 +88,14 @@ type ReportItem = {
|
|||||||
|
|
||||||
type ReportGenerationProgress = {
|
type ReportGenerationProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
@@ -93,9 +106,29 @@ type ReportGenerationProgress = {
|
|||||||
error?: string
|
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 = {
|
type CommentGenerationProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId?: string
|
classId?: string
|
||||||
className?: string
|
className?: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
@@ -142,6 +175,7 @@ type LoadSettingsResponse =
|
|||||||
| {
|
| {
|
||||||
ok: true
|
ok: true
|
||||||
modelConfig: ModelConfig | null
|
modelConfig: ModelConfig | null
|
||||||
|
classTypeConfigs: ClassTypeConfig[]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
ok: false
|
ok: false
|
||||||
@@ -174,6 +208,7 @@ type LoadStudentDataResponse =
|
|||||||
type LoadStudentDataInput = {
|
type LoadStudentDataInput = {
|
||||||
includeProfiles?: boolean
|
includeProfiles?: boolean
|
||||||
includeImages?: boolean
|
includeImages?: boolean
|
||||||
|
includeClassImages?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListStudentProfilesResponse =
|
type ListStudentProfilesResponse =
|
||||||
@@ -265,6 +300,7 @@ type GenerateReportsResponse =
|
|||||||
ok: true
|
ok: true
|
||||||
reports: ReportItem[]
|
reports: ReportItem[]
|
||||||
skipped: Array<{ studentName: string; reason: string }>
|
skipped: Array<{ studentName: string; reason: string }>
|
||||||
|
stopped?: boolean
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
ok: false
|
ok: false
|
||||||
@@ -276,6 +312,32 @@ type GenerateStudentCommentsResponse =
|
|||||||
ok: true
|
ok: true
|
||||||
profiles: ChildProfile[]
|
profiles: ChildProfile[]
|
||||||
skipped: Array<{ studentName: string; reason: string }>
|
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
|
ok: false
|
||||||
@@ -339,12 +401,16 @@ type AppAPI = {
|
|||||||
classId?: string
|
classId?: string
|
||||||
profileIds?: string[]
|
profileIds?: string[]
|
||||||
}) => Promise<GenerateStudentCommentsResponse>
|
}) => Promise<GenerateStudentCommentsResponse>
|
||||||
|
addClassZodiacs: (payload: { classId: string }) => Promise<AddClassZodiacsResponse>
|
||||||
|
pauseCommentGeneration: (batchId: string) => Promise<BasicMutationResponse>
|
||||||
|
resumeCommentGeneration: (batchId: string) => Promise<BasicMutationResponse>
|
||||||
|
stopCommentGeneration: (batchId: string) => Promise<BasicMutationResponse>
|
||||||
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: {
|
||||||
classItem: ClassProfile
|
classItem: ClassProfile
|
||||||
profiles: ChildProfile[]
|
profiles: ChildProfile[]
|
||||||
}) => Promise<BasicMutationResponse>
|
}) => Promise<ReplaceClassProfilesResponse>
|
||||||
migrateLocalStudentData: (payload: {
|
migrateLocalStudentData: (payload: {
|
||||||
classes: ClassProfile[]
|
classes: ClassProfile[]
|
||||||
profiles: ChildProfile[]
|
profiles: ChildProfile[]
|
||||||
@@ -352,6 +418,7 @@ type AppAPI = {
|
|||||||
saveClasses: (classes: ClassProfile[]) => Promise<BasicMutationResponse>
|
saveClasses: (classes: ClassProfile[]) => Promise<BasicMutationResponse>
|
||||||
loadSettings: () => Promise<LoadSettingsResponse>
|
loadSettings: () => Promise<LoadSettingsResponse>
|
||||||
saveSettings: (modelConfig: ModelConfig) => Promise<SaveSettingsResponse>
|
saveSettings: (modelConfig: ModelConfig) => Promise<SaveSettingsResponse>
|
||||||
|
saveClassTypeConfigs: (classTypeConfigs: ClassTypeConfig[]) => Promise<SaveSettingsResponse>
|
||||||
loadTemplates: () => Promise<LoadTemplatesResponse>
|
loadTemplates: () => Promise<LoadTemplatesResponse>
|
||||||
selectTemplateFile: () => Promise<SelectTemplateFileResponse>
|
selectTemplateFile: () => Promise<SelectTemplateFileResponse>
|
||||||
parseTemplatePlaceholders: (filePath: string) => Promise<ParseTemplatePlaceholdersResponse>
|
parseTemplatePlaceholders: (filePath: string) => Promise<ParseTemplatePlaceholdersResponse>
|
||||||
@@ -372,9 +439,13 @@ type AppAPI = {
|
|||||||
classId: string
|
classId: string
|
||||||
studentIds?: string[]
|
studentIds?: string[]
|
||||||
}) => Promise<GenerateReportsResponse>
|
}) => Promise<GenerateReportsResponse>
|
||||||
|
pauseReportGeneration: (batchId: string) => Promise<BasicMutationResponse>
|
||||||
|
resumeReportGeneration: (batchId: string) => Promise<BasicMutationResponse>
|
||||||
|
stopReportGeneration: (batchId: string) => Promise<BasicMutationResponse>
|
||||||
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>
|
||||||
|
onReportDownloadProgress: (callback: (progress: ReportDownloadProgress) => void) => () => void
|
||||||
onReportGenerationProgress: (callback: (progress: ReportGenerationProgress) => void) => () => void
|
onReportGenerationProgress: (callback: (progress: ReportGenerationProgress) => void) => () => void
|
||||||
onCommentGenerationProgress: (
|
onCommentGenerationProgress: (
|
||||||
callback: (progress: CommentGenerationProgress) => void
|
callback: (progress: CommentGenerationProgress) => void
|
||||||
|
|||||||
+66
-7
@@ -8,8 +8,11 @@ const api = {
|
|||||||
ipcRenderer.invoke('student:delete-profile', profileId),
|
ipcRenderer.invoke('student:delete-profile', profileId),
|
||||||
exportEmptyClassFolder: (payload: { className: string; classId: string; childNames: string[] }) =>
|
exportEmptyClassFolder: (payload: { className: string; classId: string; childNames: string[] }) =>
|
||||||
ipcRenderer.invoke('classes:export-empty-folder', payload),
|
ipcRenderer.invoke('classes:export-empty-folder', payload),
|
||||||
loadStudentData: (payload?: { includeProfiles?: boolean; includeImages?: boolean }) =>
|
loadStudentData: (payload?: {
|
||||||
ipcRenderer.invoke('student:load', payload),
|
includeProfiles?: boolean
|
||||||
|
includeImages?: boolean
|
||||||
|
includeClassImages?: boolean
|
||||||
|
}) => ipcRenderer.invoke('student:load', payload),
|
||||||
listStudentProfiles: (payload: {
|
listStudentProfiles: (payload: {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -43,6 +46,14 @@ const api = {
|
|||||||
ipcRenderer.invoke('student:generate-comment', payload),
|
ipcRenderer.invoke('student:generate-comment', payload),
|
||||||
generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) =>
|
generateStudentComments: (payload: { classId?: string; profileIds?: string[] }) =>
|
||||||
ipcRenderer.invoke('student:generate-comments', payload),
|
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 }) =>
|
listModels: (payload: { baseUrl: string; apiKey: string }) =>
|
||||||
ipcRenderer.invoke('models:list', payload),
|
ipcRenderer.invoke('models:list', payload),
|
||||||
testModelConnection: (modelConfig: {
|
testModelConnection: (modelConfig: {
|
||||||
@@ -58,7 +69,7 @@ const api = {
|
|||||||
classItem: {
|
classItem: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'cheap' | 'noble'
|
type: string
|
||||||
teacherNames: string[]
|
teacherNames: string[]
|
||||||
familyPhoto?: string
|
familyPhoto?: string
|
||||||
}
|
}
|
||||||
@@ -89,7 +100,7 @@ const api = {
|
|||||||
classes: Array<{
|
classes: Array<{
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'cheap' | 'noble'
|
type: string
|
||||||
teacherNames: string[]
|
teacherNames: string[]
|
||||||
familyPhoto?: string
|
familyPhoto?: string
|
||||||
}>
|
}>
|
||||||
@@ -120,7 +131,7 @@ const api = {
|
|||||||
classes: Array<{
|
classes: Array<{
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'cheap' | 'noble'
|
type: string
|
||||||
teacherNames: string[]
|
teacherNames: string[]
|
||||||
familyPhoto?: string
|
familyPhoto?: string
|
||||||
}>
|
}>
|
||||||
@@ -135,6 +146,13 @@ const api = {
|
|||||||
maxTokens: string
|
maxTokens: string
|
||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
}) => ipcRenderer.invoke('settings:save', modelConfig),
|
}) => 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'),
|
loadTemplates: () => ipcRenderer.invoke('templates:load'),
|
||||||
selectTemplateFile: () => ipcRenderer.invoke('templates:select-file'),
|
selectTemplateFile: () => ipcRenderer.invoke('templates:select-file'),
|
||||||
parseTemplatePlaceholders: (filePath: string) =>
|
parseTemplatePlaceholders: (filePath: string) =>
|
||||||
@@ -153,14 +171,48 @@ const api = {
|
|||||||
loadReports: () => ipcRenderer.invoke('reports:load'),
|
loadReports: () => ipcRenderer.invoke('reports:load'),
|
||||||
generateReports: (payload: { templateId: string; classId: string; studentIds?: string[] }) =>
|
generateReports: (payload: { templateId: string; classId: string; studentIds?: string[] }) =>
|
||||||
ipcRenderer.invoke('reports:generate', payload),
|
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),
|
openReport: (filePath: string) => ipcRenderer.invoke('reports:open', filePath),
|
||||||
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),
|
||||||
|
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<typeof callback>[0]
|
||||||
|
): void => callback(progress)
|
||||||
|
|
||||||
|
ipcRenderer.on('reports:download-class-progress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('reports:download-class-progress', listener)
|
||||||
|
},
|
||||||
onReportGenerationProgress: (
|
onReportGenerationProgress: (
|
||||||
callback: (progress: {
|
callback: (progress: {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
@@ -182,7 +234,14 @@ const api = {
|
|||||||
onCommentGenerationProgress: (
|
onCommentGenerationProgress: (
|
||||||
callback: (progress: {
|
callback: (progress: {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId?: string
|
classId?: string
|
||||||
className?: string
|
className?: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { GenerationProgressToasts } from '@renderer/components/feedback/GenerationProgressToasts'
|
||||||
import { Toaster } from '@renderer/components/ui/sonner'
|
import { Toaster } from '@renderer/components/ui/sonner'
|
||||||
import { AppLayout } from '@renderer/layouts/AppLayout'
|
import { AppLayout } from '@renderer/layouts/AppLayout'
|
||||||
import { ClassPage } from '@renderer/pages/ClassPage'
|
import { ClassPage } from '@renderer/pages/ClassPage'
|
||||||
@@ -32,6 +33,7 @@ function App(): React.JSX.Element {
|
|||||||
<Route path="*" element={<Navigate to="/student/list" replace />} />
|
<Route path="*" element={<Navigate to="/student/list" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<GenerationProgressToasts />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 { useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
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 { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||||
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 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'
|
import { Field } from './Field'
|
||||||
|
|
||||||
@@ -23,6 +35,9 @@ const defaultModelConfig: ModelConfig = {
|
|||||||
export function ModelSettingsForm(): React.JSX.Element {
|
export function ModelSettingsForm(): React.JSX.Element {
|
||||||
const [setting, setSetting] = useState<SettingKey>('model')
|
const [setting, setSetting] = useState<SettingKey>('model')
|
||||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(defaultModelConfig)
|
const [modelConfig, setModelConfig] = useState<ModelConfig>(defaultModelConfig)
|
||||||
|
const [classTypeConfigs, setClassTypeConfigs] = useState<ClassTypeConfig[]>(
|
||||||
|
DEFAULT_CLASS_TYPE_CONFIGS
|
||||||
|
)
|
||||||
const [modelOptions, setModelOptions] = useState<string[]>([])
|
const [modelOptions, setModelOptions] = useState<string[]>([])
|
||||||
const [modelListLoading, setModelListLoading] = useState(false)
|
const [modelListLoading, setModelListLoading] = useState(false)
|
||||||
const [settingsSaving, setSettingsSaving] = useState(false)
|
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||||
@@ -48,6 +63,8 @@ export function ModelSettingsForm(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
toast.success('已加载本地配置')
|
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<void> => {
|
||||||
|
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<void> => {
|
const testConnection = async (): Promise<void> => {
|
||||||
setConnectionTesting(true)
|
setConnectionTesting(true)
|
||||||
|
|
||||||
@@ -127,136 +208,218 @@ export function ModelSettingsForm(): React.JSX.Element {
|
|||||||
<Bot className="size-4" />
|
<Bot className="size-4" />
|
||||||
大模型配置
|
大模型配置
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-10 w-full justify-start gap-2 px-3"
|
||||||
|
variant={setting === 'classType' ? 'secondary' : 'ghost'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSetting('classType')}
|
||||||
|
>
|
||||||
|
<BookOpen className="size-4" />
|
||||||
|
班级类型配置
|
||||||
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Card className="min-h-0 overflow-hidden rounded-none border-0 shadow-none">
|
<Card className="min-h-0 overflow-hidden rounded-none border-0 shadow-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 space-y-0 border-b">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 space-y-0 border-b">
|
||||||
<div>大模型配置</div>
|
<div>{setting === 'model' ? '大模型配置' : '班级类型配置'}</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{setting === 'model' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={connectionTesting}
|
||||||
|
>
|
||||||
|
{connectionTesting && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" type="button" onClick={addClassTypeConfig}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新增类型
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={testConnection}
|
onClick={setting === 'model' ? saveSettings : saveClassTypeConfigs}
|
||||||
disabled={connectionTesting}
|
disabled={settingsSaving}
|
||||||
>
|
>
|
||||||
{connectionTesting && <Loader2 className="size-4 animate-spin" />}
|
|
||||||
测试连接
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={saveSettings} disabled={settingsSaving}>
|
|
||||||
{settingsSaving ? (
|
{settingsSaving ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
)}
|
)}
|
||||||
保存配置
|
{setting === 'model' ? '保存配置' : '保存班级类型'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<ScrollArea className="h-[calc(100%-5.75rem)] max-[900px]:h-auto">
|
<ScrollArea className="h-[calc(100%-5.75rem)] max-[900px]:h-auto">
|
||||||
<CardContent className="space-y-6 p-6">
|
<CardContent className="space-y-6 p-6">
|
||||||
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
|
{setting === 'model' ? (
|
||||||
<Field label="供应商">
|
<>
|
||||||
<Input
|
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
|
||||||
value={modelConfig.provider}
|
<Field label="供应商">
|
||||||
onChange={(event) => updateModelConfig('provider', event.target.value)}
|
<Input
|
||||||
placeholder="OpenAI Compatible"
|
value={modelConfig.provider}
|
||||||
/>
|
onChange={(event) => updateModelConfig('provider', event.target.value)}
|
||||||
</Field>
|
placeholder="OpenAI Compatible"
|
||||||
<Field label="模型名称">
|
/>
|
||||||
<Input
|
</Field>
|
||||||
list="model-options"
|
<Field label="模型名称">
|
||||||
value={modelConfig.model}
|
<Input
|
||||||
onChange={(event) => updateModelConfig('model', event.target.value)}
|
list="model-options"
|
||||||
placeholder="输入或从候选模型中选择"
|
value={modelConfig.model}
|
||||||
/>
|
onChange={(event) => updateModelConfig('model', event.target.value)}
|
||||||
<datalist id="model-options">
|
placeholder="输入或从候选模型中选择"
|
||||||
{modelOptions.map((model) => (
|
/>
|
||||||
<option value={model} key={model} />
|
<datalist id="model-options">
|
||||||
|
{modelOptions.map((model) => (
|
||||||
|
<option value={model} key={model} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="接口地址">
|
||||||
|
<Input
|
||||||
|
value={modelConfig.baseUrl}
|
||||||
|
onChange={(event) => updateModelConfig('baseUrl', event.target.value)}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="API Key">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<KeyRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
type="password"
|
||||||
|
value={modelConfig.apiKey}
|
||||||
|
onChange={(event) => updateModelConfig('apiKey', event.target.value)}
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="icon" type="button" title="后续可切换明文显示">
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 p-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchModelList}
|
||||||
|
disabled={modelListLoading}
|
||||||
|
>
|
||||||
|
{modelListLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ListRestart className="size-4" />
|
||||||
|
)}
|
||||||
|
获取模型列表
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
根据接口地址和 API Key 获取可用模型后,再选择模型名称。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
|
||||||
|
<Field label="温度">
|
||||||
|
<div className="relative">
|
||||||
|
<SlidersHorizontal className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
value={modelConfig.temperature}
|
||||||
|
onChange={(event) => updateModelConfig('temperature', event.target.value)}
|
||||||
|
placeholder="0.7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="最大输出 Token">
|
||||||
|
<Input
|
||||||
|
value={modelConfig.maxTokens}
|
||||||
|
onChange={(event) => updateModelConfig('maxTokens', event.target.value)}
|
||||||
|
placeholder="1200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="系统提示词">
|
||||||
|
<textarea
|
||||||
|
className="min-h-28 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={modelConfig.systemPrompt}
|
||||||
|
onChange={(event) => updateModelConfig('systemPrompt', event.target.value)}
|
||||||
|
placeholder="请输入生成评语时使用的系统提示词"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<p className="border-t pt-5 text-sm text-muted-foreground">
|
||||||
|
当前配置将作为“生成评语”工具的大模型参数来源。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
班级类型用于班级新建/编辑,也会在生成报告时匹配课程内容。模板可使用
|
||||||
|
<span className="mx-1 font-mono text-foreground">{'{{courseContent}}'}</span>或
|
||||||
|
<span className="mx-1 font-mono text-foreground">{'{{classCourse}}'}</span>
|
||||||
|
插入对应课程。
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{classTypeConfigs.map((config, index) => (
|
||||||
|
<div key={`${config.id}-${index}`} className="rounded-md border p-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[180px_220px_auto]">
|
||||||
|
<Field label="类型 ID">
|
||||||
|
<Input
|
||||||
|
value={config.id}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateClassTypeConfig(index, 'id', event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="例如 cheap"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="显示名称">
|
||||||
|
<Input
|
||||||
|
value={config.label}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateClassTypeConfig(index, 'label', event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="例如 便宜班"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={classTypeConfigs.length <= 1}
|
||||||
|
onClick={() => removeClassTypeConfig(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Field label="课程内容">
|
||||||
|
<textarea
|
||||||
|
className="min-h-24 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={config.courseContent}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateClassTypeConfig(index, 'courseContent', event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="填写该班级类型本学期学习内容"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</datalist>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="接口地址">
|
|
||||||
<Input
|
|
||||||
value={modelConfig.baseUrl}
|
|
||||||
onChange={(event) => updateModelConfig('baseUrl', event.target.value)}
|
|
||||||
placeholder="https://api.openai.com/v1"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="API Key">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="pl-9"
|
|
||||||
type="password"
|
|
||||||
value={modelConfig.apiKey}
|
|
||||||
onChange={(event) => updateModelConfig('apiKey', event.target.value)}
|
|
||||||
placeholder="sk-..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" type="button" title="后续可切换明文显示">
|
</>
|
||||||
<Eye className="size-4" />
|
)}
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 p-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={fetchModelList}
|
|
||||||
disabled={modelListLoading}
|
|
||||||
>
|
|
||||||
{modelListLoading ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ListRestart className="size-4" />
|
|
||||||
)}
|
|
||||||
获取模型列表
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
根据接口地址和 API Key 获取可用模型后,再选择模型名称。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
|
|
||||||
<Field label="温度">
|
|
||||||
<div className="relative">
|
|
||||||
<SlidersHorizontal className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="pl-9"
|
|
||||||
value={modelConfig.temperature}
|
|
||||||
onChange={(event) => updateModelConfig('temperature', event.target.value)}
|
|
||||||
placeholder="0.7"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
<Field label="最大输出 Token">
|
|
||||||
<Input
|
|
||||||
value={modelConfig.maxTokens}
|
|
||||||
onChange={(event) => updateModelConfig('maxTokens', event.target.value)}
|
|
||||||
placeholder="1200"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="系统提示词">
|
|
||||||
<textarea
|
|
||||||
className="min-h-28 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
value={modelConfig.systemPrompt}
|
|
||||||
onChange={(event) => updateModelConfig('systemPrompt', event.target.value)}
|
|
||||||
placeholder="请输入生成评语时使用的系统提示词"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<p className="border-t pt-5 text-sm text-muted-foreground">
|
|
||||||
当前配置将作为“生成评语”工具的大模型参数来源。
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { CheckCircle2, XCircle, type LucideIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
type ProgressToastStatus = 'running' | 'paused' | 'stopped' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
|
type ProgressToastInput = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
status?: ProgressToastStatus
|
||||||
|
statusLabel?: string
|
||||||
|
error?: string
|
||||||
|
icon: LucideIcon
|
||||||
|
duration?: number
|
||||||
|
actionLabel?: string
|
||||||
|
actionDisabled?: boolean
|
||||||
|
onAction?: () => void
|
||||||
|
secondaryActionLabel?: string
|
||||||
|
secondaryActionDisabled?: boolean
|
||||||
|
onSecondaryAction?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressPercent(current: number, total: number): number {
|
||||||
|
if (total <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max((current / total) * 100, current > 0 ? 6 : 0), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgressToastContent(input: ProgressToastInput): React.JSX.Element {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
status = 'running',
|
||||||
|
statusLabel,
|
||||||
|
error,
|
||||||
|
icon: Icon,
|
||||||
|
actionLabel,
|
||||||
|
actionDisabled,
|
||||||
|
onAction,
|
||||||
|
secondaryActionLabel,
|
||||||
|
secondaryActionDisabled,
|
||||||
|
onSecondaryAction
|
||||||
|
} = input
|
||||||
|
const percent = getProgressPercent(current, total)
|
||||||
|
const isError = status === 'error'
|
||||||
|
const isPaused = status === 'paused'
|
||||||
|
const isStopped = status === 'stopped'
|
||||||
|
const isSuccess = status === 'success'
|
||||||
|
const StatusIcon = isSuccess ? CheckCircle2 : isError ? XCircle : null
|
||||||
|
|
||||||
|
const headerClassName =
|
||||||
|
isSuccess || (isError && !description && !error)
|
||||||
|
? 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3'
|
||||||
|
: 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[360px] max-w-[calc(100vw-2rem)] rounded-md border bg-popover p-4 text-popover-foreground shadow-lg">
|
||||||
|
<div className={headerClassName}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isSuccess
|
||||||
|
? 'grid size-8 shrink-0 place-items-center rounded-md bg-emerald-100 text-emerald-700'
|
||||||
|
: isError
|
||||||
|
? 'grid size-8 shrink-0 place-items-center rounded-md bg-destructive/10 text-destructive'
|
||||||
|
: isPaused
|
||||||
|
? 'grid size-9 shrink-0 place-items-center rounded-md bg-amber-100 text-amber-700'
|
||||||
|
: isStopped
|
||||||
|
? 'grid size-9 shrink-0 place-items-center rounded-md bg-slate-100 text-slate-700'
|
||||||
|
: 'grid size-9 shrink-0 place-items-center rounded-md bg-primary/10 text-primary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{StatusIcon ? <StatusIcon className="size-4" /> : <Icon className="size-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{title}</p>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isSuccess
|
||||||
|
? 'rounded-md bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700'
|
||||||
|
: isError
|
||||||
|
? 'rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive'
|
||||||
|
: isPaused
|
||||||
|
? 'rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700'
|
||||||
|
: isStopped
|
||||||
|
? 'rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700'
|
||||||
|
: 'rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusLabel ??
|
||||||
|
(isSuccess
|
||||||
|
? '已完成'
|
||||||
|
: isError
|
||||||
|
? '本项失败'
|
||||||
|
: isPaused
|
||||||
|
? '已暂停'
|
||||||
|
: isStopped
|
||||||
|
? '已停止'
|
||||||
|
: '处理中')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSuccess ? null : (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? 'h-full rounded-full bg-destructive transition-all'
|
||||||
|
: isPaused
|
||||||
|
? 'h-full rounded-full bg-amber-500 transition-all'
|
||||||
|
: isStopped
|
||||||
|
? 'h-full rounded-full bg-slate-500 transition-all'
|
||||||
|
: 'h-full rounded-full bg-primary transition-all'
|
||||||
|
}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{current}/{Math.max(total, 0)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{Math.round(percent)}%</span>
|
||||||
|
{actionLabel && onAction ? (
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={actionDisabled}
|
||||||
|
type="button"
|
||||||
|
onClick={onAction}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{secondaryActionLabel && onSecondaryAction ? (
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-destructive/30 bg-background px-2 py-1 text-xs font-medium text-destructive transition hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={secondaryActionDisabled}
|
||||||
|
type="button"
|
||||||
|
onClick={onSecondaryAction}
|
||||||
|
>
|
||||||
|
{secondaryActionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error ? <p className="mt-2 line-clamp-2 text-xs text-destructive">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showProgressToast(input: ProgressToastInput): void {
|
||||||
|
toast.custom(() => renderProgressToastContent(input), {
|
||||||
|
id: input.id,
|
||||||
|
duration: input.duration ?? Infinity,
|
||||||
|
unstyled: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showProgressSuccess(id: string, title: string, description?: string): void {
|
||||||
|
toast.custom(
|
||||||
|
() =>
|
||||||
|
renderProgressToastContent({
|
||||||
|
id,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
current: 1,
|
||||||
|
total: 1,
|
||||||
|
status: 'success',
|
||||||
|
statusLabel: '已完成'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
duration: 4000,
|
||||||
|
unstyled: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showProgressError(id: string, title: string, description?: string): void {
|
||||||
|
toast.custom(
|
||||||
|
() =>
|
||||||
|
renderProgressToastContent({
|
||||||
|
id,
|
||||||
|
icon: XCircle,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
current: 1,
|
||||||
|
total: 1,
|
||||||
|
status: 'error',
|
||||||
|
statusLabel: '失败'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
duration: 6000,
|
||||||
|
unstyled: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeProgressToast(id: string): void {
|
||||||
|
toast.dismiss(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showDefaultProgressSuccess(id: string, title: string, description?: string): void {
|
||||||
|
toast.success(title, {
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
duration: 4000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showDefaultProgressError(id: string, title: string, description?: string): void {
|
||||||
|
toast.error(title, {
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
|||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Camera,
|
Camera,
|
||||||
|
CalendarPlus,
|
||||||
Construction,
|
Construction,
|
||||||
Download,
|
Download,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -68,14 +69,24 @@ import {
|
|||||||
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'
|
||||||
|
import {
|
||||||
|
showProgressError,
|
||||||
|
showProgressSuccess,
|
||||||
|
showProgressToast
|
||||||
|
} from '@renderer/components/ui/progress-toast'
|
||||||
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 { classTypeOptions, createUuid, getClassTypeLabel } from '@renderer/student/classes'
|
import {
|
||||||
|
DEFAULT_CLASS_TYPE_CONFIGS,
|
||||||
|
classTypeOptions as defaultClassTypeOptions,
|
||||||
|
createUuid,
|
||||||
|
getClassTypeLabel
|
||||||
|
} from '@renderer/student/classes'
|
||||||
import {
|
import {
|
||||||
readChildProfilesFromSpreadsheet,
|
readChildProfilesFromSpreadsheet,
|
||||||
replaceProfilesForClass
|
replaceProfilesForClass
|
||||||
} from '@renderer/student/children'
|
} from '@renderer/student/children'
|
||||||
import type { ChildProfile, ClassProfile, ClassType } from '@renderer/types/app'
|
import type { ChildProfile, ClassProfile, ClassType, ClassTypeConfig } from '@renderer/types/app'
|
||||||
|
|
||||||
type EditingClass = {
|
type EditingClass = {
|
||||||
id: string
|
id: string
|
||||||
@@ -85,6 +96,12 @@ type EditingClass = {
|
|||||||
familyPhoto?: string
|
familyPhoto?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PhotoImportControl = {
|
||||||
|
paused: boolean
|
||||||
|
stopped: boolean
|
||||||
|
resumeWaiters: Array<() => void>
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_CLASS_TEACHERS = 3
|
const MAX_CLASS_TEACHERS = 3
|
||||||
const PHOTO_FILE_NAME_MAP: Record<string, 'meImage' | 'workImage1' | 'workImage2'> = {
|
const PHOTO_FILE_NAME_MAP: Record<string, 'meImage' | 'workImage1' | 'workImage2'> = {
|
||||||
me: 'meImage',
|
me: 'meImage',
|
||||||
@@ -189,8 +206,12 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
const photoZipInputRef = useRef<HTMLInputElement>(null)
|
const photoZipInputRef = useRef<HTMLInputElement>(null)
|
||||||
const classPhotoInputRef = useRef<HTMLInputElement>(null)
|
const classPhotoInputRef = useRef<HTMLInputElement>(null)
|
||||||
const editPhotoInputRef = useRef<HTMLInputElement>(null)
|
const editPhotoInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const photoImportControlRef = useRef<PhotoImportControl | null>(null)
|
||||||
const [classes, setClasses] = useState<ClassProfile[]>([])
|
const [classes, setClasses] = useState<ClassProfile[]>([])
|
||||||
const [profiles, setProfiles] = useState<ChildProfile[]>([])
|
const [profiles, setProfiles] = useState<ChildProfile[]>([])
|
||||||
|
const [classTypeConfigs, setClassTypeConfigs] = useState<ClassTypeConfig[]>(
|
||||||
|
DEFAULT_CLASS_TYPE_CONFIGS
|
||||||
|
)
|
||||||
const [className, setClassName] = useState('')
|
const [className, setClassName] = useState('')
|
||||||
const [classType, setClassType] = useState<ClassType>('cheap')
|
const [classType, setClassType] = useState<ClassType>('cheap')
|
||||||
const [classTeacherNames, setClassTeacherNames] = useState<string[]>([])
|
const [classTeacherNames, setClassTeacherNames] = useState<string[]>([])
|
||||||
@@ -198,6 +219,7 @@ 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 [addingZodiacClassId, setAddingZodiacClassId] = useState('')
|
||||||
const [generatingCommentClassId, setGeneratingCommentClassId] = useState('')
|
const [generatingCommentClassId, setGeneratingCommentClassId] = useState('')
|
||||||
const [generatingReportClassId, setGeneratingReportClassId] = useState('')
|
const [generatingReportClassId, setGeneratingReportClassId] = useState('')
|
||||||
const [downloadingReportClassId, setDownloadingReportClassId] = useState('')
|
const [downloadingReportClassId, setDownloadingReportClassId] = useState('')
|
||||||
@@ -209,7 +231,13 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadStudentData(): Promise<void> {
|
async function loadStudentData(): Promise<void> {
|
||||||
const response = await window.api.loadStudentData({ includeImages: false })
|
const [response, settingsResponse] = await Promise.all([
|
||||||
|
window.api.loadStudentData({
|
||||||
|
includeImages: false,
|
||||||
|
includeClassImages: true
|
||||||
|
}),
|
||||||
|
window.api.loadSettings()
|
||||||
|
])
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
showError('读取数据库失败', response.message)
|
showError('读取数据库失败', response.message)
|
||||||
@@ -218,6 +246,10 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
|
|
||||||
setClasses(response.classes)
|
setClasses(response.classes)
|
||||||
setProfiles(response.profiles)
|
setProfiles(response.profiles)
|
||||||
|
|
||||||
|
if (settingsResponse.ok) {
|
||||||
|
setClassTypeConfigs(settingsResponse.classTypeConfigs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadStudentData()
|
loadStudentData()
|
||||||
@@ -245,13 +277,39 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
classItem.name,
|
classItem.name,
|
||||||
classItem.teacherNames.join(' '),
|
classItem.teacherNames.join(' '),
|
||||||
classItem.id,
|
classItem.id,
|
||||||
getClassTypeLabel(classItem.type)
|
getClassTypeLabel(classItem.type, classTypeConfigs)
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(keyword)
|
.includes(keyword)
|
||||||
)
|
)
|
||||||
}, [classes, query])
|
}, [classes, classTypeConfigs, query])
|
||||||
|
|
||||||
|
const configuredClassTypeOptions = useMemo(() => {
|
||||||
|
const optionMap = new Map<string, { value: ClassType; label: string }>()
|
||||||
|
|
||||||
|
for (const option of defaultClassTypeOptions) {
|
||||||
|
optionMap.set(option.value, option)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of classTypeConfigs) {
|
||||||
|
optionMap.set(config.id, {
|
||||||
|
value: config.id,
|
||||||
|
label: config.label
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const classItem of classes) {
|
||||||
|
if (!optionMap.has(classItem.type)) {
|
||||||
|
optionMap.set(classItem.type, {
|
||||||
|
value: classItem.type,
|
||||||
|
label: getClassTypeLabel(classItem.type, classTypeConfigs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(optionMap.values())
|
||||||
|
}, [classes, classTypeConfigs])
|
||||||
|
|
||||||
function getProfilesByClass(classId: string): ChildProfile[] {
|
function getProfilesByClass(classId: string): ChildProfile[] {
|
||||||
return profiles.filter((profile) => profile.classId === classId)
|
return profiles.filter((profile) => profile.classId === classId)
|
||||||
@@ -486,11 +544,16 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setProfiles(replaceProfilesForClass(profiles, uploadingClass, nextProfiles))
|
setProfiles(replaceProfilesForClass(profiles, uploadingClass, response.profiles))
|
||||||
setUploadingClass(null)
|
setUploadingClass(null)
|
||||||
setSelectedFile(null)
|
setSelectedFile(null)
|
||||||
setIsDragActive(false)
|
setIsDragActive(false)
|
||||||
showSuccess(`已上传「${uploadingClass.name}」幼儿信息`, `共 ${nextProfiles.length} 条`)
|
showSuccess(
|
||||||
|
`已上传「${uploadingClass.name}」幼儿信息`,
|
||||||
|
response.insertedCount > 0
|
||||||
|
? `新增 ${response.insertedCount} 条`
|
||||||
|
: `更新 ${response.updatedCount} 条,跳过未匹配 ${response.skippedCount} 条`
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
showError('上传失败', '请确认表头和示例一致,并使用 .xlsx 文件')
|
showError('上传失败', '请确认表头和示例一致,并使用 .xlsx 文件')
|
||||||
@@ -554,7 +617,9 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
showSuccess(
|
showSuccess(
|
||||||
`已生成「${classItem.name}」评语`,
|
response.stopped
|
||||||
|
? `已停止生成「${classItem.name}」评语`
|
||||||
|
: `已生成「${classItem.name}」评语`,
|
||||||
`成功 ${response.profiles.length} 个,失败 ${response.skipped.length} 个`
|
`成功 ${response.profiles.length} 个,失败 ${response.skipped.length} 个`
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -562,6 +627,48 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddClassZodiacs(classItem: ClassProfile): Promise<void> {
|
||||||
|
const childCount = profileCountByClass.get(classItem.id) ?? 0
|
||||||
|
|
||||||
|
if (childCount === 0) {
|
||||||
|
showError('当前班级没有幼儿数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddingZodiacClassId(classItem.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.api.addClassZodiacs({ classId: classItem.id })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
showError('添加属相失败', response.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfiles((currentProfiles) =>
|
||||||
|
currentProfiles.map(
|
||||||
|
(profile) =>
|
||||||
|
response.profiles.find((nextProfile) => nextProfile.id === profile.id) ?? profile
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const skippedReasonCount = response.skipped.reduce<Record<string, number>>((result, item) => {
|
||||||
|
result[item.reason] = (result[item.reason] ?? 0) + 1
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
const skippedSummary = Object.entries(skippedReasonCount)
|
||||||
|
.map(([reason, count]) => `${reason} ${count} 个`)
|
||||||
|
.join(',')
|
||||||
|
|
||||||
|
showSuccess(
|
||||||
|
`已为「${classItem.name}」添加属相`,
|
||||||
|
`补全 ${response.updatedCount} 个${skippedSummary ? `,跳过:${skippedSummary}` : ''}`
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAddingZodiacClassId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleGenerateClassReports(classItem: ClassProfile): void {
|
function handleGenerateClassReports(classItem: ClassProfile): void {
|
||||||
const childCount = profileCountByClass.get(classItem.id) ?? 0
|
const childCount = profileCountByClass.get(classItem.id) ?? 0
|
||||||
|
|
||||||
@@ -623,14 +730,111 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toastId = `photo-import-${targetClass.id}`
|
||||||
|
const toastTitle = `正在导入「${targetClass.name}」照片`
|
||||||
|
const importControl: PhotoImportControl = {
|
||||||
|
paused: false,
|
||||||
|
stopped: false,
|
||||||
|
resumeWaiters: []
|
||||||
|
}
|
||||||
|
|
||||||
|
photoImportControlRef.current = importControl
|
||||||
|
|
||||||
|
function resumePhotoImport(): void {
|
||||||
|
importControl.paused = false
|
||||||
|
const waiters = importControl.resumeWaiters.splice(0)
|
||||||
|
|
||||||
|
for (const resolve of waiters) {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countUpdateImages(update: Partial<ChildProfile>): number {
|
||||||
|
return [update.meImage, update.workImage1, update.workImage2].filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPhotoImportResume(): Promise<void> {
|
||||||
|
if (!importControl.paused || importControl.stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
importControl.resumeWaiters.push(resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPhotoImportProgress(input: {
|
||||||
|
title?: string
|
||||||
|
description: string
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
}): void {
|
||||||
|
const isStopped = importControl.stopped
|
||||||
|
const isPaused = importControl.paused
|
||||||
|
|
||||||
|
showProgressToast({
|
||||||
|
id: toastId,
|
||||||
|
icon: Images,
|
||||||
|
title: isStopped
|
||||||
|
? '照片导入已停止'
|
||||||
|
: isPaused
|
||||||
|
? '照片导入已暂停'
|
||||||
|
: (input.title ?? toastTitle),
|
||||||
|
description: input.description,
|
||||||
|
current: input.current,
|
||||||
|
total: input.total,
|
||||||
|
status: isStopped ? 'stopped' : isPaused ? 'paused' : 'running',
|
||||||
|
statusLabel: isStopped ? '已停止' : isPaused ? '已暂停' : '导入中',
|
||||||
|
duration: isStopped ? 5000 : undefined,
|
||||||
|
actionLabel: isStopped ? undefined : isPaused ? '继续' : '暂停',
|
||||||
|
onAction: isStopped
|
||||||
|
? undefined
|
||||||
|
: () => {
|
||||||
|
if (importControl.paused) {
|
||||||
|
resumePhotoImport()
|
||||||
|
} else {
|
||||||
|
importControl.paused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
showPhotoImportProgress(input)
|
||||||
|
},
|
||||||
|
secondaryActionLabel: isStopped ? undefined : '停止',
|
||||||
|
onSecondaryAction: isStopped
|
||||||
|
? undefined
|
||||||
|
: () => {
|
||||||
|
importControl.stopped = true
|
||||||
|
resumePhotoImport()
|
||||||
|
showPhotoImportProgress(input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
showPhotoImportProgress({
|
||||||
|
description: '读取学生数据 · 1/4',
|
||||||
|
current: 1,
|
||||||
|
total: 4
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const latestStudentData = await window.api.loadStudentData()
|
const latestStudentData = await window.api.loadStudentData()
|
||||||
|
|
||||||
if (!latestStudentData.ok) {
|
if (!latestStudentData.ok) {
|
||||||
showError('读取学生数据失败', latestStudentData.message)
|
showProgressError(toastId, '读取学生数据失败', latestStudentData.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await waitForPhotoImportResume()
|
||||||
|
|
||||||
|
if (importControl.stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showPhotoImportProgress({
|
||||||
|
description: '解析 ZIP 文件 · 2/4',
|
||||||
|
current: 2,
|
||||||
|
total: 4
|
||||||
|
})
|
||||||
|
|
||||||
const zip = await JSZip.loadAsync(await file.arrayBuffer())
|
const zip = await JSZip.loadAsync(await file.arrayBuffer())
|
||||||
const latestProfiles = latestStudentData.profiles
|
const latestProfiles = latestStudentData.profiles
|
||||||
const classProfiles = latestProfiles.filter((profile) => profile.classId === targetClass.id)
|
const classProfiles = latestProfiles.filter((profile) => profile.classId === targetClass.id)
|
||||||
@@ -649,10 +853,9 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
const updatesByProfileId = new Map<string, Partial<ChildProfile>>()
|
const updatesByProfileId = new Map<string, Partial<ChildProfile>>()
|
||||||
let matchedImageCount = 0
|
let matchedImageCount = 0
|
||||||
let skippedImageCount = 0
|
let skippedImageCount = 0
|
||||||
|
const imageEntries = Object.entries(zip.files).flatMap(([zipPath, entry]) => {
|
||||||
for (const [zipPath, entry] of Object.entries(zip.files)) {
|
|
||||||
if (entry.dir) {
|
if (entry.dir) {
|
||||||
continue
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathParts = getZipPathParts(zipPath)
|
const pathParts = getZipPathParts(zipPath)
|
||||||
@@ -661,25 +864,68 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
const photoField = getPhotoFieldFromFileName(fileName)
|
const photoField = getPhotoFieldFromFileName(fileName)
|
||||||
|
|
||||||
if (!mimeType || !photoField) {
|
if (!mimeType || !photoField) {
|
||||||
continue
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedProfile = findProfileForZipPath(pathParts, targetClass, profileByName)
|
return [
|
||||||
|
{
|
||||||
|
entry,
|
||||||
|
pathParts,
|
||||||
|
mimeType,
|
||||||
|
photoField
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [entryIndex, imageEntry] of imageEntries.entries()) {
|
||||||
|
await waitForPhotoImportResume()
|
||||||
|
|
||||||
|
if (importControl.stopped) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedProfile = findProfileForZipPath(
|
||||||
|
imageEntry.pathParts,
|
||||||
|
targetClass,
|
||||||
|
profileByName
|
||||||
|
)
|
||||||
|
|
||||||
if (!matchedProfile) {
|
if (!matchedProfile) {
|
||||||
skippedImageCount += 1
|
skippedImageCount += 1
|
||||||
|
showPhotoImportProgress({
|
||||||
|
description: `匹配照片 · ${entryIndex + 1}/${Math.max(imageEntries.length, 1)}`,
|
||||||
|
current: entryIndex + 1,
|
||||||
|
total: Math.max(imageEntries.length, 1)
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = await entry.async('base64')
|
const base64 = await imageEntry.entry.async('base64')
|
||||||
const currentUpdate = updatesByProfileId.get(matchedProfile.id) ?? {}
|
const currentUpdate = updatesByProfileId.get(matchedProfile.id) ?? {}
|
||||||
currentUpdate[photoField] = `data:${mimeType};base64,${base64}`
|
currentUpdate[imageEntry.photoField] = `data:${imageEntry.mimeType};base64,${base64}`
|
||||||
updatesByProfileId.set(matchedProfile.id, currentUpdate)
|
updatesByProfileId.set(matchedProfile.id, currentUpdate)
|
||||||
matchedImageCount += 1
|
matchedImageCount += 1
|
||||||
|
|
||||||
|
showPhotoImportProgress({
|
||||||
|
description: `匹配照片 · ${entryIndex + 1}/${Math.max(imageEntries.length, 1)}`,
|
||||||
|
current: entryIndex + 1,
|
||||||
|
total: Math.max(imageEntries.length, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importControl.stopped) {
|
||||||
|
showPhotoImportProgress({
|
||||||
|
title: '照片导入已停止',
|
||||||
|
description: '尚未写入幼儿资料',
|
||||||
|
current: 0,
|
||||||
|
total: Math.max(imageEntries.length, 1)
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatesByProfileId.size === 0) {
|
if (updatesByProfileId.size === 0) {
|
||||||
showError(
|
showProgressError(
|
||||||
|
toastId,
|
||||||
'没有匹配到可导入照片',
|
'没有匹配到可导入照片',
|
||||||
'请确认 ZIP 内是 班级UUID/images/学生姓名/me.jpg、1.jpg、2.jpg'
|
'请确认 ZIP 内是 班级UUID/images/学生姓名/me.jpg、1.jpg、2.jpg'
|
||||||
)
|
)
|
||||||
@@ -691,7 +937,16 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
...(updatesByProfileId.get(profile.id) ?? {})
|
...(updatesByProfileId.get(profile.id) ?? {})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
for (const profileId of updatesByProfileId.keys()) {
|
const profileIdsToUpdate = Array.from(updatesByProfileId.keys())
|
||||||
|
const writtenProfileIds = new Set<string>()
|
||||||
|
|
||||||
|
for (const [profileIndex, profileId] of profileIdsToUpdate.entries()) {
|
||||||
|
await waitForPhotoImportResume()
|
||||||
|
|
||||||
|
if (importControl.stopped) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const nextProfile = nextProfiles.find((profile) => profile.id === profileId)
|
const nextProfile = nextProfiles.find((profile) => profile.id === profileId)
|
||||||
|
|
||||||
if (nextProfile) {
|
if (nextProfile) {
|
||||||
@@ -700,20 +955,61 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(response.message)
|
throw new Error(response.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writtenProfileIds.add(profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showPhotoImportProgress({
|
||||||
|
description: `写入幼儿资料 · ${profileIndex + 1}/${profileIdsToUpdate.length}`,
|
||||||
|
current: profileIndex + 1,
|
||||||
|
total: profileIdsToUpdate.length
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appliedUpdateProfileIds = importControl.stopped
|
||||||
|
? writtenProfileIds
|
||||||
|
: new Set(updatesByProfileId.keys())
|
||||||
|
const appliedImageCount = Array.from(appliedUpdateProfileIds).reduce(
|
||||||
|
(totalCount, profileId) =>
|
||||||
|
totalCount + countUpdateImages(updatesByProfileId.get(profileId) ?? {}),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const appliedProfiles = latestProfiles.map((profile) => ({
|
||||||
|
...profile,
|
||||||
|
...(appliedUpdateProfileIds.has(profile.id) ? updatesByProfileId.get(profile.id) : {})
|
||||||
|
}))
|
||||||
|
|
||||||
setClasses(latestStudentData.classes)
|
setClasses(latestStudentData.classes)
|
||||||
setProfiles(nextProfiles)
|
setProfiles(appliedProfiles)
|
||||||
showSuccess(
|
|
||||||
|
if (importControl.stopped) {
|
||||||
|
showPhotoImportProgress({
|
||||||
|
title: '照片导入已停止',
|
||||||
|
description: `已写入 ${writtenProfileIds.size} 名幼儿,${appliedImageCount} 张照片`,
|
||||||
|
current: writtenProfileIds.size,
|
||||||
|
total: profileIdsToUpdate.length
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showProgressSuccess(
|
||||||
|
toastId,
|
||||||
'已导入幼儿照片',
|
'已导入幼儿照片',
|
||||||
`更新 ${updatesByProfileId.size} 名幼儿,写入 ${matchedImageCount} 张照片${
|
`更新 ${updatesByProfileId.size} 名幼儿,写入 ${matchedImageCount} 张照片${
|
||||||
skippedImageCount > 0 ? `,跳过 ${skippedImageCount} 张未匹配照片` : ''
|
skippedImageCount > 0 ? `,跳过 ${skippedImageCount} 张未匹配照片` : ''
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('导入照片 ZIP 失败', error instanceof Error ? error.message : '请检查压缩包结构')
|
showProgressError(
|
||||||
|
toastId,
|
||||||
|
'导入照片 ZIP 失败',
|
||||||
|
error instanceof Error ? error.message : '请检查压缩包结构'
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (photoImportControlRef.current === importControl) {
|
||||||
|
photoImportControlRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
setPhotoZipClass(null)
|
setPhotoZipClass(null)
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
@@ -779,7 +1075,7 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={classItem.type === 'noble' ? 'warning' : 'secondary'}>
|
<Badge variant={classItem.type === 'noble' ? 'warning' : 'secondary'}>
|
||||||
{getClassTypeLabel(classItem.type)}
|
{getClassTypeLabel(classItem.type, classTypeConfigs)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -830,6 +1126,13 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>一键操作</DropdownMenuLabel>
|
<DropdownMenuLabel>一键操作</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={addingZodiacClassId === classItem.id}
|
||||||
|
onSelect={() => handleAddClassZodiacs(classItem)}
|
||||||
|
>
|
||||||
|
<CalendarPlus />
|
||||||
|
{addingZodiacClassId === classItem.id ? '属相添加中' : '一键添加属相'}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={generatingCommentClassId === classItem.id}
|
disabled={generatingCommentClassId === classItem.id}
|
||||||
onSelect={() => handleGenerateClassComments(classItem)}
|
onSelect={() => handleGenerateClassComments(classItem)}
|
||||||
@@ -871,7 +1174,8 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>删除「{classItem.name}」?</AlertDialogTitle>
|
<AlertDialogTitle>删除「{classItem.name}」?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
当前班级是{getClassTypeLabel(classItem.type)},共有 {childCount}{' '}
|
当前班级是{getClassTypeLabel(classItem.type, classTypeConfigs)}
|
||||||
|
,共有 {childCount}{' '}
|
||||||
条幼儿数据。确认删除后,这个班级和班级里的幼儿数据都会被删除。
|
条幼儿数据。确认删除后,这个班级和班级里的幼儿数据都会被删除。
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
@@ -953,7 +1257,7 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
value={classType}
|
value={classType}
|
||||||
onChange={(event) => setClassType(event.target.value as ClassType)}
|
onChange={(event) => setClassType(event.target.value as ClassType)}
|
||||||
>
|
>
|
||||||
{classTypeOptions.map((option) => (
|
{configuredClassTypeOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -1078,7 +1382,7 @@ export function ClassPage(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{classTypeOptions.map((option) => (
|
{configuredClassTypeOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ export function ReportPage(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.api.onReportGenerationProgress((progress) => {
|
return window.api.onReportGenerationProgress((progress) => {
|
||||||
setGenerationProgress(progress.status === 'finished' ? null : progress)
|
setGenerationProgress(
|
||||||
|
progress.status === 'finished' || progress.status === 'stopped' ? null : progress
|
||||||
|
)
|
||||||
|
|
||||||
if (progress.status === 'student-finished' && progress.report) {
|
if (progress.status === 'student-finished' && progress.report) {
|
||||||
setReports((currentReports) => [progress.report!, ...currentReports])
|
setReports((currentReports) => [progress.report!, ...currentReports])
|
||||||
@@ -296,7 +298,7 @@ export function ReportPage(): React.JSX.Element {
|
|||||||
...currentReports
|
...currentReports
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
toast.success('报告生成完成', {
|
toast.success(response.stopped ? '报告生成已停止' : '报告生成完成', {
|
||||||
description:
|
description:
|
||||||
response.skipped.length > 0
|
response.skipped.length > 0
|
||||||
? `成功 ${response.reports.length} 份,跳过 ${response.skipped.length} 份`
|
? `成功 ${response.reports.length} 份,跳过 ${response.skipped.length} 份`
|
||||||
@@ -452,21 +454,8 @@ export function ReportPage(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<Select
|
|
||||||
value={selectedTemplateId}
|
|
||||||
onChange={(event) => setSelectedTemplateId(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">选择 PPTX 报告模板</option>
|
|
||||||
{pptxTemplates.map((template) => (
|
|
||||||
<option key={template.id} value={template.id}>
|
|
||||||
{template.name} - {template.originalFileName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-end gap-2 sm:flex-row">
|
<div className="flex flex-col justify-end gap-2 sm:flex-row">
|
||||||
<Button disabled={generating || !selectedTemplateId} onClick={openGenerateDrawer}>
|
<Button disabled={generating} onClick={openGenerateDrawer}>
|
||||||
<Wand2 />
|
<Wand2 />
|
||||||
{generating ? '生成中' : '生成班级报告'}
|
{generating ? '生成中' : '生成班级报告'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -500,65 +489,6 @@ export function ReportPage(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{generationProgress ? (
|
|
||||||
<Card className="border-primary/30 bg-primary/5 shadow-none">
|
|
||||||
<CardContent className="grid gap-4 p-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
|
|
||||||
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary/10 text-primary">
|
|
||||||
<Wand2 className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 space-y-2">
|
|
||||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium">
|
|
||||||
{generationProgress.studentName
|
|
||||||
? `正在生成 ${generationProgress.studentName} 的报告`
|
|
||||||
: '正在准备生成报告'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{generationProgress.className} · {generationProgress.current}/
|
|
||||||
{generationProgress.total}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
generationProgress.status === 'student-failed' ? 'destructive' : 'warning'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{generationProgress.status === 'student-failed' ? '本份失败' : '生成中'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-background">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(
|
|
||||||
Math.max(
|
|
||||||
(generationProgress.current / Math.max(generationProgress.total, 1)) *
|
|
||||||
100,
|
|
||||||
generationProgress.current > 0 ? 6 : 0
|
|
||||||
),
|
|
||||||
100
|
|
||||||
)}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{generationProgress.error ? (
|
|
||||||
<p className="text-sm text-destructive">{generationProgress.error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="text-left md:text-right">
|
|
||||||
<p className="text-2xl font-semibold text-primary">
|
|
||||||
{Math.round(
|
|
||||||
(generationProgress.current / Math.max(generationProgress.total, 1)) * 100
|
|
||||||
)}
|
|
||||||
%
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">总体进度</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ScrollArea className="h-140">
|
<ScrollArea className="h-140">
|
||||||
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 pr-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{filteredReports.map((report) => {
|
{filteredReports.map((report) => {
|
||||||
@@ -651,10 +581,27 @@ export function ReportPage(): React.JSX.Element {
|
|||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>生成班级报告</DrawerTitle>
|
<DrawerTitle>生成班级报告</DrawerTitle>
|
||||||
<DrawerDescription className="mt-1">
|
<DrawerDescription className="mt-1">
|
||||||
选择一个班级,系统会使用当前选中的 PPTX 模板为该班级幼儿生成报告。
|
选择一个班级和 PPTX 模板,系统会为该班级幼儿生成报告。
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="space-y-5 p-6">
|
<div className="space-y-5 p-6">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="generate-report-template">
|
||||||
|
报告模板
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="generate-report-template"
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(event) => setSelectedTemplateId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">请选择 PPTX 报告模板</option>
|
||||||
|
{pptxTemplates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.name} - {template.originalFileName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm font-medium" htmlFor="generate-report-class">
|
<label className="text-sm font-medium" htmlFor="generate-report-class">
|
||||||
生成班级
|
生成班级
|
||||||
@@ -672,11 +619,6 @@ export function ReportPage(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '@renderer/components/ui/table'
|
} from '@renderer/components/ui/table'
|
||||||
import { getClassTypeLabel } from '@renderer/student/classes'
|
import { DEFAULT_CLASS_TYPE_CONFIGS, getClassTypeLabel } from '@renderer/student/classes'
|
||||||
import type { ChildProfile, ClassProfile } from '@renderer/types/app'
|
import type { ChildProfile, ClassProfile, ClassTypeConfig } from '@renderer/types/app'
|
||||||
import type { CommentGenerationProgress } from '@renderer/types/app'
|
import type { CommentGenerationProgress } from '@renderer/types/app'
|
||||||
|
|
||||||
function formatList(items: string[]): string {
|
function formatList(items: string[]): string {
|
||||||
@@ -116,6 +116,9 @@ export function StudentPage(): React.JSX.Element {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [profiles, setProfiles] = useState<ChildProfile[]>([])
|
const [profiles, setProfiles] = useState<ChildProfile[]>([])
|
||||||
const [classes, setClasses] = useState<ClassProfile[]>([])
|
const [classes, setClasses] = useState<ClassProfile[]>([])
|
||||||
|
const [classTypeConfigs, setClassTypeConfigs] = useState<ClassTypeConfig[]>(
|
||||||
|
DEFAULT_CLASS_TYPE_CONFIGS
|
||||||
|
)
|
||||||
const [totalProfiles, setTotalProfiles] = useState(0)
|
const [totalProfiles, setTotalProfiles] = useState(0)
|
||||||
const [studentDataReady, setStudentDataReady] = useState(false)
|
const [studentDataReady, setStudentDataReady] = useState(false)
|
||||||
const [selectedId, setSelectedId] = useState<string>('')
|
const [selectedId, setSelectedId] = useState<string>('')
|
||||||
@@ -130,7 +133,10 @@ export function StudentPage(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadStudentData(): Promise<void> {
|
async function loadStudentData(): Promise<void> {
|
||||||
const response = await window.api.loadStudentData({ includeProfiles: false })
|
const [response, settingsResponse] = await Promise.all([
|
||||||
|
window.api.loadStudentData({ includeProfiles: false }),
|
||||||
|
window.api.loadSettings()
|
||||||
|
])
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error('读取数据库失败', { description: response.message })
|
toast.error('读取数据库失败', { description: response.message })
|
||||||
@@ -138,6 +144,9 @@ export function StudentPage(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setClasses(response.classes)
|
setClasses(response.classes)
|
||||||
|
if (settingsResponse.ok) {
|
||||||
|
setClassTypeConfigs(settingsResponse.classTypeConfigs)
|
||||||
|
}
|
||||||
setStudentDataReady(true)
|
setStudentDataReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +180,9 @@ export function StudentPage(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.api.onCommentGenerationProgress((progress) => {
|
return window.api.onCommentGenerationProgress((progress) => {
|
||||||
setCommentProgress(progress.status === 'finished' ? null : progress)
|
setCommentProgress(
|
||||||
|
progress.status === 'finished' || progress.status === 'stopped' ? null : progress
|
||||||
|
)
|
||||||
|
|
||||||
if (progress.status === 'student-started' && progress.studentId) {
|
if (progress.status === 'student-started' && progress.studentId) {
|
||||||
setGeneratingIds((currentIds) =>
|
setGeneratingIds((currentIds) =>
|
||||||
@@ -290,20 +301,31 @@ export function StudentPage(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenerateComment(profile: ChildProfile): Promise<boolean> {
|
async function handleGenerateComment(profile: ChildProfile): Promise<boolean> {
|
||||||
|
const profileName = profile.name || profile.englishName || '学生'
|
||||||
|
|
||||||
setGeneratingIds((currentIds) => [...currentIds, profile.id])
|
setGeneratingIds((currentIds) => [...currentIds, profile.id])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await window.api.generateStudentComment({ profileId: profile.id })
|
const response = await window.api.generateStudentComments({ profileIds: [profile.id] })
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(`生成「${profile.name || profile.englishName || '学生'}」评语失败`, {
|
toast.error(`生成「${profileName}」评语失败`, {
|
||||||
description: response.message
|
description: response.message
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProfileInPage(response.profile)
|
const nextProfile = response.profiles[0]
|
||||||
toast.success(`已生成「${response.profile.name || '学生'}」评语`)
|
|
||||||
|
if (nextProfile) {
|
||||||
|
updateProfileInPage(nextProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
response.stopped
|
||||||
|
? `已停止生成「${profileName}」评语`
|
||||||
|
: `已生成「${nextProfile?.name || profileName}」评语`
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingIds((currentIds) => currentIds.filter((profileId) => profileId !== profile.id))
|
setGeneratingIds((currentIds) => currentIds.filter((profileId) => profileId !== profile.id))
|
||||||
@@ -329,7 +351,7 @@ export function StudentPage(): React.JSX.Element {
|
|||||||
updateProfileInPage(profile)
|
updateProfileInPage(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('批量生成完成', {
|
toast.success(response.stopped ? '批量生成已停止' : '批量生成完成', {
|
||||||
description: `成功 ${response.profiles.length} 个,失败 ${response.skipped.length} 个`
|
description: `成功 ${response.profiles.length} 个,失败 ${response.skipped.length} 个`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -462,64 +484,6 @@ 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>
|
||||||
@@ -786,7 +750,7 @@ export function StudentPage(): React.JSX.Element {
|
|||||||
label="班级类型"
|
label="班级类型"
|
||||||
value={
|
value={
|
||||||
selectedProfileClass
|
selectedProfileClass
|
||||||
? getClassTypeLabel(selectedProfileClass.type)
|
? getClassTypeLabel(selectedProfileClass.type, classTypeConfigs)
|
||||||
: '未记录'
|
: '未记录'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,6 +19,38 @@ function asText(value: unknown): string {
|
|||||||
return String(value).trim()
|
return String(value).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(year: number, month: number, day: number): string {
|
||||||
|
return [
|
||||||
|
String(year).padStart(4, '0'),
|
||||||
|
String(month).padStart(2, '0'),
|
||||||
|
String(day).padStart(2, '0')
|
||||||
|
].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBirthdayText(value: unknown): string {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return formatDate(value.getFullYear(), value.getMonth() + 1, value.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = asText(value)
|
||||||
|
const numericValue =
|
||||||
|
typeof value === 'number'
|
||||||
|
? value
|
||||||
|
: /^\d{5}(?:\.\d+)?$/.test(text)
|
||||||
|
? Number(text)
|
||||||
|
: Number.NaN
|
||||||
|
|
||||||
|
if (Number.isFinite(numericValue)) {
|
||||||
|
const parsedDate = XLSX.SSF.parse_date_code(numericValue)
|
||||||
|
|
||||||
|
if (parsedDate) {
|
||||||
|
return formatDate(parsedDate.y, parsedDate.m, parsedDate.d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
function splitList(value: unknown): string[] {
|
function splitList(value: unknown): string[] {
|
||||||
return asText(value)
|
return asText(value)
|
||||||
.split(/[、,,]/)
|
.split(/[、,,]/)
|
||||||
@@ -44,7 +76,7 @@ function mapRowToChild(
|
|||||||
name: asText(row['姓名']),
|
name: asText(row['姓名']),
|
||||||
englishName: asText(row['英文名']),
|
englishName: asText(row['英文名']),
|
||||||
gender: asText(row['性别']),
|
gender: asText(row['性别']),
|
||||||
birthday: asText(row['生日']),
|
birthday: asBirthdayText(row['生日']),
|
||||||
zodiac: asText(row['属相']),
|
zodiac: asText(row['属相']),
|
||||||
friends: splitList(row['我的好朋友']),
|
friends: splitList(row['我的好朋友']),
|
||||||
hobbies: splitList(row['我的爱好']),
|
hobbies: splitList(row['我的爱好']),
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import type { ClassProfile, ClassType } from '@renderer/types/app'
|
import type { ClassProfile, ClassType, ClassTypeConfig } from '@renderer/types/app'
|
||||||
|
|
||||||
export const CLASSES_STORAGE_KEY = 'growth-report:student-classes'
|
export const CLASSES_STORAGE_KEY = 'growth-report:student-classes'
|
||||||
const LEGACY_CLASSES_STORAGE_KEY = 'growth-report:classes'
|
const LEGACY_CLASSES_STORAGE_KEY = 'growth-report:classes'
|
||||||
export const classTypeOptions: { value: ClassType; label: string }[] = [
|
export const DEFAULT_CLASS_TYPE_CONFIGS: ClassTypeConfig[] = [
|
||||||
{ value: 'cheap', label: '便宜班' },
|
{
|
||||||
{ value: 'noble', label: '贵族班' }
|
id: 'cheap',
|
||||||
|
label: '便宜班',
|
||||||
|
courseContent:
|
||||||
|
'本期开展了小袋鼠整合主题课程:(语言、社会、科学、健康、艺术)、生活数学;特色课程(英语、体能、美工、篮球)。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'noble',
|
||||||
|
label: '贵族班',
|
||||||
|
courseContent:
|
||||||
|
'本学期开展了柏克莱主题课程(语言、社会、科学、艺术、健康);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'big',
|
||||||
|
label: '大大班',
|
||||||
|
courseContent:
|
||||||
|
'本学期开展了双木桥主题课程(图说汉字、妙趣汉音、情智阅读、麦斯思维、专注力训练);英语及特色课程(体能、舞蹈、美工、魔力猴、足球、国学)。'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
export const classTypeOptions: { value: ClassType; label: string }[] =
|
||||||
|
DEFAULT_CLASS_TYPE_CONFIGS.map((config) => ({
|
||||||
|
value: config.id,
|
||||||
|
label: config.label
|
||||||
|
}))
|
||||||
|
|
||||||
const defaultClassNames = ['云朵一班', '星星二班', '彩虹三班']
|
const defaultClassNames = ['云朵一班', '星星二班', '彩虹三班']
|
||||||
|
|
||||||
@@ -30,13 +51,15 @@ export function normalizeClass(classItem: Partial<ClassProfile>): ClassProfile |
|
|||||||
return {
|
return {
|
||||||
id: classItem.id,
|
id: classItem.id,
|
||||||
name: classItem.name,
|
name: classItem.name,
|
||||||
type: classItem.type === 'noble' ? 'noble' : 'cheap',
|
type: classItem.type?.trim() || 'cheap',
|
||||||
teacherNames: normalizeTeacherNames(classItem),
|
teacherNames: normalizeTeacherNames(classItem),
|
||||||
familyPhoto: classItem.familyPhoto
|
familyPhoto: classItem.familyPhoto
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTeacherNames(classItem: Partial<ClassProfile> & { teacherName?: string }): string[] {
|
function normalizeTeacherNames(
|
||||||
|
classItem: Partial<ClassProfile> & { teacherName?: string }
|
||||||
|
): string[] {
|
||||||
if (Array.isArray(classItem.teacherNames)) {
|
if (Array.isArray(classItem.teacherNames)) {
|
||||||
return classItem.teacherNames.map((teacherName) => teacherName.trim()).filter(Boolean)
|
return classItem.teacherNames.map((teacherName) => teacherName.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -44,8 +67,11 @@ function normalizeTeacherNames(classItem: Partial<ClassProfile> & { teacherName?
|
|||||||
return classItem.teacherName?.trim() ? [classItem.teacherName.trim()] : []
|
return classItem.teacherName?.trim() ? [classItem.teacherName.trim()] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClassTypeLabel(type: ClassType): string {
|
export function getClassTypeLabel(
|
||||||
return classTypeOptions.find((item) => item.value === type)?.label ?? '便宜班'
|
type: ClassType,
|
||||||
|
configs: ClassTypeConfig[] = DEFAULT_CLASS_TYPE_CONFIGS
|
||||||
|
): string {
|
||||||
|
return (configs.find((item) => item.id === type)?.label ?? type) || '便宜班'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadClasses(): ClassProfile[] {
|
export function loadClasses(): ClassProfile[] {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
export type SectionKey = 'tools' | 'student' | 'settings'
|
export type SectionKey = 'tools' | 'student' | 'settings'
|
||||||
export type Status = '可运行' | '待配置' | '待导入' | '待审核'
|
export type Status = '可运行' | '待配置' | '待导入' | '待审核'
|
||||||
export type LogLevel = 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR'
|
export type LogLevel = 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR'
|
||||||
export type SettingKey = 'model'
|
export type SettingKey = 'model' | 'classType'
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -50,7 +50,13 @@ export type ChildProfile = {
|
|||||||
importedAt: string
|
importedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassType = 'cheap' | 'noble'
|
export type ClassType = string
|
||||||
|
|
||||||
|
export type ClassTypeConfig = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
courseContent: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ClassProfile = {
|
export type ClassProfile = {
|
||||||
id: string
|
id: string
|
||||||
@@ -102,7 +108,14 @@ export type ReportItem = {
|
|||||||
|
|
||||||
export type ReportGenerationProgress = {
|
export type ReportGenerationProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
@@ -113,9 +126,29 @@ export type ReportGenerationProgress = {
|
|||||||
error?: string
|
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 CommentGenerationProgress = {
|
export type CommentGenerationProgress = {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: 'started' | 'student-started' | 'student-finished' | 'student-failed' | 'finished'
|
status:
|
||||||
|
| 'started'
|
||||||
|
| 'student-started'
|
||||||
|
| 'student-finished'
|
||||||
|
| 'student-failed'
|
||||||
|
| 'paused'
|
||||||
|
| 'stopped'
|
||||||
|
| 'finished'
|
||||||
classId?: string
|
classId?: string
|
||||||
className?: string
|
className?: string
|
||||||
studentId?: string
|
studentId?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user