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