feat: enhance class report workflows

This commit is contained in:
2026-07-06 17:20:04 +08:00
parent df3b623144
commit 0015063a03
23 changed files with 2500 additions and 478 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules
.pnpm-store
dist
out
.DS_Store
+5
View File
@@ -3,5 +3,10 @@ allowBuilds:
electron: true
electron-winstaller: true
esbuild: true
onlyBuiltDependencies:
- electron
- esbuild
registries:
default: https://registry.npmmirror.com/
+54 -2
View File
@@ -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)
+12 -4
View File
@@ -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)
}
)
}
+68 -2
View File
@@ -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 {
+284 -65
View File
@@ -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 }
}
+95 -12
View File
@@ -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 : '保存班级类型配置失败'
}
}
}
+576 -82
View File
@@ -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
}
})
}
+31 -1
View File
@@ -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
+8
View File
@@ -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
+40 -2
View File
@@ -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
+75 -4
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
})
}
+329 -25
View File
@@ -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>
+23 -81
View File
@@ -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
+32 -68
View File
@@ -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)
: '未记录'
}
/>
+33 -1
View File
@@ -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['我的爱好']),
+34 -8
View File
@@ -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[] {
+37 -4
View File
@@ -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