import 'reflect-metadata' import { randomUUID } from 'crypto' import { Brackets, type Repository } from 'typeorm' import { BrowserWindow } from 'electron' import { StudentProfileEntitySchema, type StudentProfileEntity } from '../entities/StudentProfileEntity' import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity' import type { ChildProfile, ClassProfile, CommentGenerationProgress, GenerateStudentCommentsInput, 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 { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService' type CommentGenerationController = { batchId: string paused: boolean stopped: boolean lastProgress: CommentGenerationProgress resumeWaiters: Array<() => void> } const commentGenerationControllers = new Map() const CHINESE_ZODIAC_BY_BRANCH: Record = { 子: '鼠', 丑: '牛', 寅: '虎', 卯: '兔', 辰: '龙', 巳: '蛇', 午: '马', 未: '羊', 申: '猴', 酉: '鸡', 戌: '狗', 亥: '猪' } const GREGORIAN_ZODIACS = ['猴', '鸡', '狗', '猪', '鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊'] const chineseYearFormatter = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', { year: 'numeric', timeZone: 'UTC' }) function parseJsonList(value: string): string[] { try { const parsed = JSON.parse(value) return Array.isArray(parsed) ? parsed : [] } catch { return [] } } function stringifyList(items: string[]): string { return JSON.stringify(items) } function normalizeClassType(type: string): string { return type.trim() || 'cheap' } function createUtcDate(year: number, month: number, day: number): Date | null { const date = new Date(Date.UTC(year, month - 1, day)) if ( date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day ) { return null } return date } function parseBirthdayDate(value: string): Date | null { const trimmedValue = value.trim() if (!trimmedValue) { return null } if (/^\d{5}(?:\.\d+)?$/.test(trimmedValue)) { const excelSerialDate = Number(trimmedValue) if (Number.isFinite(excelSerialDate)) { const excelEpoch = Date.UTC(1899, 11, 30) return new Date(excelEpoch + Math.floor(excelSerialDate) * 24 * 60 * 60 * 1000) } } const compactMatch = trimmedValue.match(/^(\d{4})(\d{2})(\d{2})$/) if (compactMatch) { return createUtcDate(Number(compactMatch[1]), Number(compactMatch[2]), Number(compactMatch[3])) } const datePartsMatch = trimmedValue.match(/^(\d{4})\D+(\d{1,2})\D+(\d{1,2})/) if (datePartsMatch) { return createUtcDate( Number(datePartsMatch[1]), Number(datePartsMatch[2]), Number(datePartsMatch[3]) ) } const parsedTime = Date.parse(trimmedValue) if (!Number.isNaN(parsedTime)) { const parsedDate = new Date(parsedTime) return createUtcDate(parsedDate.getFullYear(), parsedDate.getMonth() + 1, parsedDate.getDate()) } return null } function getChineseZodiac(date: Date): string { const chineseYear = chineseYearFormatter.format(date) const branch = chineseYear.match(/[子丑寅卯辰巳午未申酉戌亥]/)?.[0] if (branch && CHINESE_ZODIAC_BY_BRANCH[branch]) { return CHINESE_ZODIAC_BY_BRANCH[branch] } return GREGORIAN_ZODIACS[date.getUTCFullYear() % 12] } function normalizeProfileMatchValue(value: string): string { return String(value ?? '') .trim() .replace(/\s+/g, '') .toLowerCase() } function getProfileMatchKeys( profile: Pick ): string[] { const keys = new Set() const id = normalizeProfileMatchValue(profile.id) const name = normalizeProfileMatchValue(profile.name) const englishName = normalizeProfileMatchValue(profile.englishName) const birthday = normalizeProfileMatchValue(profile.birthday) if (id) { keys.add(`id:${id}`) } if (name && birthday) { keys.add(`name-birthday:${name}|${birthday}`) } if (englishName && birthday) { keys.add(`english-birthday:${englishName}|${birthday}`) } if (name) { keys.add(`name:${name}`) } if (englishName) { keys.add(`english:${englishName}`) } return [...keys] } function mergeImportedProfileIntoExisting( existingProfile: StudentProfileEntity, importedProfile: ChildProfile, classItem: ClassProfile ): ChildProfile { return { id: existingProfile.id, classId: classItem.id, className: classItem.name, name: importedProfile.name, englishName: importedProfile.englishName, gender: importedProfile.gender, birthday: importedProfile.birthday, zodiac: importedProfile.zodiac, friends: importedProfile.friends, hobbies: importedProfile.hobbies, favoriteGames: importedProfile.favoriteGames, favoriteFoods: importedProfile.favoriteFoods, traits: importedProfile.traits, comment: existingProfile.comment ?? '', commentGeneratedAt: existingProfile.commentGeneratedAt ?? '', reportGenerated: Boolean(existingProfile.reportGenerated), meImage: existingProfile.meImage ?? '', workImage1: existingProfile.workImage1 ?? '', workImage2: existingProfile.workImage2 ?? '', importedAt: importedProfile.importedAt } } function parseTeacherNames(value: string): string[] { try { const parsed = JSON.parse(value) if (Array.isArray(parsed)) { return parsed .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter(Boolean) } } catch { // Older data may contain a single teacher name instead of a JSON array. } return value.trim() ? [value.trim()] : [] } function stringifyTeacherNames(teacherNames: string[]): string { return JSON.stringify( teacherNames.map((teacherName) => teacherName.trim()).filter((teacherName) => teacherName) ) } async function normalizeStoredClassImages(entity: ClassEntity): Promise { if (!isDataUrlImage(entity.familyPhoto)) { return entity } return { ...entity, familyPhoto: await storeImageValue('classes', entity.id, 'familyPhoto', entity.familyPhoto) } } async function hydrateClassImages(entity: ClassEntity): Promise { if (!entity.familyPhoto) { return entity } return { ...entity, familyPhoto: await readImageAsDataUrl(entity.familyPhoto) } } async function normalizeStoredStudentImages( entity: StudentProfileEntity ): Promise { const [meImage, workImage1, workImage2] = await Promise.all([ storeImageValue('students', entity.id, 'meImage', entity.meImage), storeImageValue('students', entity.id, 'workImage1', entity.workImage1), storeImageValue('students', entity.id, 'workImage2', entity.workImage2) ]) return { ...entity, meImage, workImage1, workImage2 } } async function hydrateStudentImages(entity: StudentProfileEntity): Promise { const [meImage, workImage1, workImage2] = await Promise.all([ readImageAsDataUrl(entity.meImage), readImageAsDataUrl(entity.workImage1), readImageAsDataUrl(entity.workImage2) ]) return { ...entity, meImage, workImage1, workImage2 } } function mapClassEntity(entity: ClassEntity): ClassProfile { return { id: entity.id, name: entity.name, type: normalizeClassType(entity.type), teacherNames: parseTeacherNames(entity.teacherNames || entity.teacherName || ''), familyPhoto: entity.familyPhoto || undefined } } function mapChildEntity( entity: StudentProfileEntity, options: { includeImages?: boolean } = {} ): ChildProfile { return { id: entity.id, classId: entity.classId, className: entity.className, name: entity.name, englishName: entity.englishName, gender: entity.gender, birthday: entity.birthday, zodiac: entity.zodiac, friends: parseJsonList(entity.friends), hobbies: parseJsonList(entity.hobbies), favoriteGames: parseJsonList(entity.favoriteGames), favoriteFoods: parseJsonList(entity.favoriteFoods), traits: entity.traits, comment: entity.comment ?? '', commentGeneratedAt: entity.commentGeneratedAt ?? '', reportGenerated: Boolean(entity.reportGenerated), meImage: options.includeImages === false ? '' : (entity.meImage ?? ''), workImage1: options.includeImages === false ? '' : (entity.workImage1 ?? ''), workImage2: options.includeImages === false ? '' : (entity.workImage2 ?? ''), importedAt: entity.importedAt } } async function mapClassProfile(profile: ClassProfile): Promise { return { id: profile.id, name: profile.name, type: profile.type, teacherName: (profile.teacherNames ?? []).join(' ') || null, teacherNames: stringifyTeacherNames(profile.teacherNames ?? []), familyPhoto: (await storeImageValue('classes', profile.id, 'familyPhoto', profile.familyPhoto)) || null } } async function mapChildProfile( profile: ChildProfile, classItem?: ClassProfile ): Promise { return normalizeStoredStudentImages({ id: profile.id, classId: classItem?.id ?? profile.classId, className: classItem?.name ?? profile.className, name: profile.name, englishName: profile.englishName, gender: profile.gender, birthday: profile.birthday, zodiac: profile.zodiac, friends: stringifyList(profile.friends), hobbies: stringifyList(profile.hobbies), favoriteGames: stringifyList(profile.favoriteGames), favoriteFoods: stringifyList(profile.favoriteFoods), traits: profile.traits, comment: profile.comment ?? '', commentGeneratedAt: profile.commentGeneratedAt ?? '', reportGenerated: profile.reportGenerated ?? false, meImage: profile.meImage ?? '', workImage1: profile.workImage1 ?? '', workImage2: profile.workImage2 ?? '', importedAt: profile.importedAt }) } 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 [ '请为这名幼儿生成一段学期末成长评语。', '严格要求:', `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 || '未填写'}`, `好朋友:${profile.friends.length > 0 ? profile.friends.join('、') : '未填写'}`, `爱好:${profile.hobbies.length > 0 ? profile.hobbies.join('、') : '未填写'}`, `喜欢的游戏:${profile.favoriteGames.length > 0 ? profile.favoriteGames.join('、') : '未填写'}`, `喜欢吃的食物:${profile.favoriteFoods.length > 0 ? profile.favoriteFoods.join('、') : '未填写'}`, `表现特征:${profile.traits || '未填写'}` ].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 || '未命名学生' } function sendCommentProgress(progress: CommentGenerationProgress): void { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('comments:generation-progress', progress) } } function updateCommentControllerProgress( controller: CommentGenerationController, progress: CommentGenerationProgress ): void { controller.lastProgress = progress sendCommentProgress(progress) } async function waitForCommentGenerationResume( controller: CommentGenerationController ): Promise { if (!controller.paused || controller.stopped) { return } updateCommentControllerProgress(controller, { ...controller.lastProgress, status: 'paused', studentId: undefined, studentName: undefined }) await new Promise((resolve) => { controller.resumeWaiters.push(resolve) }) } export function pauseCommentGeneration(batchId: string): boolean { const controller = commentGenerationControllers.get(batchId) if (!controller || controller.stopped) { return false } controller.paused = true updateCommentControllerProgress(controller, { ...controller.lastProgress, status: 'paused', studentId: undefined, studentName: undefined }) return true } export function resumeCommentGeneration(batchId: string): boolean { const controller = commentGenerationControllers.get(batchId) if (!controller || controller.stopped) { return false } controller.paused = false const waiters = controller.resumeWaiters.splice(0) for (const resolve of waiters) { resolve() } updateCommentControllerProgress(controller, { ...controller.lastProgress, status: 'started', studentId: undefined, studentName: undefined }) return true } export function stopCommentGeneration(batchId: string): boolean { const controller = commentGenerationControllers.get(batchId) if (!controller) { return false } controller.stopped = true controller.paused = false const waiters = controller.resumeWaiters.splice(0) for (const resolve of waiters) { resolve() } updateCommentControllerProgress(controller, { ...controller.lastProgress, status: 'stopped', studentId: undefined, studentName: undefined }) return true } async function getRepositories(): Promise<{ classRepository: Repository childRepository: Repository }> { const source = await getAppDataSource() return { classRepository: source.getRepository(ClassEntitySchema), childRepository: source.getRepository(StudentProfileEntitySchema) } } export async function loadStudentData(input: LoadStudentDataInput = {}): Promise<{ classes: ClassProfile[] profiles: ChildProfile[] }> { 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: { id: 'ASC' } }) const profiles = includeProfiles ? await childRepository.find({ order: { id: 'ASC' } }) : [] const normalizedClasses = await Promise.all(classes.map(normalizeStoredClassImages)) const normalizedProfiles = includeImages ? await Promise.all(profiles.map(normalizeStoredStudentImages)) : profiles const hydratedProfiles = includeImages ? await Promise.all(normalizedProfiles.map(hydrateStudentImages)) : normalizedProfiles for (const normalizedClass of normalizedClasses) { const originalClass = classes.find((classItem) => classItem.id === normalizedClass.id) if (originalClass && normalizedClass.familyPhoto !== originalClass.familyPhoto) { await classRepository.save(normalizedClass) } } if (includeImages) { for (const normalizedProfile of normalizedProfiles) { const originalProfile = profiles.find((profile) => profile.id === normalizedProfile.id) if ( originalProfile && (normalizedProfile.meImage !== originalProfile.meImage || normalizedProfile.workImage1 !== originalProfile.workImage1 || normalizedProfile.workImage2 !== originalProfile.workImage2) ) { await childRepository.save(normalizedProfile) } } } const hydratedClasses = includeClassImages ? await Promise.all(normalizedClasses.map(hydrateClassImages)) : normalizedClasses return { classes: hydratedClasses.map(mapClassEntity), profiles: hydratedProfiles.map((profile) => mapChildEntity(profile, { includeImages })) } } export async function listStudentProfiles(input: ListStudentProfilesInput): Promise<{ profiles: ChildProfile[] total: number page: number pageSize: number }> { const pageSize = Math.min(Math.max(Number(input.pageSize) || 10, 1), 100) const page = Math.max(Number(input.page) || 1, 1) const keyword = input.query?.trim() const source = await getAppDataSource() const queryBuilder = source .getRepository(StudentProfileEntitySchema) .createQueryBuilder('profile') .orderBy('profile.className', 'ASC') .addOrderBy('profile.name', 'ASC') .addOrderBy('profile.id', 'ASC') if (input.classId && input.classId !== 'all') { queryBuilder.andWhere('profile.classId = :classId', { classId: input.classId }) } if (keyword) { queryBuilder.andWhere( new Brackets((builder) => { builder .where('profile.className LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.name LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.englishName LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.gender LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.birthday LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.zodiac LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.friends LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.hobbies LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.favoriteGames LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.favoriteFoods LIKE :keyword', { keyword: `%${keyword}%` }) .orWhere('profile.traits LIKE :keyword', { keyword: `%${keyword}%` }) }) ) } const [profiles, total] = await queryBuilder .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount() return { profiles: profiles.map((profile) => mapChildEntity(profile, { includeImages: false })), total, page, pageSize } } export async function getStudentProfile(profileId: string): Promise { const source = await getAppDataSource() const repository = source.getRepository(StudentProfileEntitySchema) const profile = await repository.findOneBy({ id: profileId }) if (!profile) { throw new Error('学生信息不存在') } const normalizedProfile = await normalizeStoredStudentImages(profile) if ( normalizedProfile.meImage !== profile.meImage || normalizedProfile.workImage1 !== profile.workImage1 || normalizedProfile.workImage2 !== profile.workImage2 ) { await repository.save(normalizedProfile) } return mapChildEntity(await hydrateStudentImages(normalizedProfile)) } export async function updateStudentProfile(profile: ChildProfile): Promise { const source = await getAppDataSource() const repository = source.getRepository(StudentProfileEntitySchema) const existingProfile = await repository.findOneBy({ id: profile.id }) if (!existingProfile) { throw new Error('学生信息不存在') } const nextProfile: ChildProfile = { ...profile, comment: profile.comment ?? '', commentGeneratedAt: profile.commentGeneratedAt ?? existingProfile.commentGeneratedAt ?? '' } const storedProfile = await mapChildProfile(nextProfile) await repository.save(storedProfile) return mapChildEntity(await hydrateStudentImages(storedProfile)) } export async function addClassZodiacs(classId: string): Promise<{ profiles: ChildProfile[] updatedCount: number skipped: Array<{ studentName: string; reason: string }> }> { const source = await getAppDataSource() const repository = source.getRepository(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, classRepository: Repository, classTypeConfigs: ClassTypeConfig[] ): Promise { const profile = mapChildEntity(entity) const settings = await loadSettings() if (!settings.ok) { throw new Error(settings.message) } const modelConfig = settings.modelConfig if (!modelConfig?.baseUrl || !modelConfig.apiKey || !modelConfig.model) { throw new Error('请先在设置中配置模型接口、API Key 和模型') } const requestUrl = getChatCompletionsUrl(modelConfig.baseUrl) 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: '生成学生评语', url: requestUrl, apiKey: modelConfig.apiKey, model: modelConfig.model, prompt: userPrompt }) const response = await fetch(requestUrl, { method: 'POST', headers: { Authorization: `Bearer ${modelConfig.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: modelConfig.model, temperature: Number(modelConfig.temperature || 0.7), max_tokens: Number(modelConfig.maxTokens || 512), messages: [ { role: 'system', content: modelConfig.systemPrompt || '你是一名幼儿园老师,擅长撰写真诚、具体、积极的成长报告评语。' }, { role: 'user', content: userPrompt } ] }) }) if (!response.ok) { throw new Error(`生成评语失败:HTTP ${response.status}`) } const result = (await response.json()) as { choices?: Array<{ message?: { content?: unknown } }> } const comment = result.choices?.[0]?.message?.content if (typeof comment !== 'string' || !comment.trim()) { throw new Error('模型没有返回可用评语') } const nextProfile: ChildProfile = { ...profile, comment: ensureCommentIncludesCourseContent(comment, courseContent), commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false }) } const storedProfile = await mapChildProfile(nextProfile) await repository.save(storedProfile) return mapChildEntity(await hydrateStudentImages(storedProfile)) } export async function generateStudentComment(profileId: string): Promise { const source = await getAppDataSource() const repository = source.getRepository(StudentProfileEntitySchema) const classRepository = source.getRepository(ClassEntitySchema) const entity = await repository.findOneBy({ id: profileId }) if (!entity) { throw new Error('学生信息不存在') } const settings = await loadSettings() if (!settings.ok) { throw new Error(settings.message) } return generateStudentCommentForEntity( entity, repository, classRepository, settings.classTypeConfigs ) } export async function generateStudentComments(input: GenerateStudentCommentsInput): Promise<{ profiles: ChildProfile[] skipped: Array<{ studentName: string; reason: string }> stopped?: boolean }> { const source = await getAppDataSource() const repository = source.getRepository(StudentProfileEntitySchema) const classRepository = source.getRepository(ClassEntitySchema) const settings = await loadSettings() if (!settings.ok) { throw new Error(settings.message) } const selectedIds = new Set(input.profileIds ?? []) const allProfiles = await repository.find({ where: input.classId ? { classId: input.classId } : undefined, order: { name: 'ASC', id: 'ASC' } }) const targetProfiles = selectedIds.size > 0 ? allProfiles.filter((profile) => selectedIds.has(profile.id)) : allProfiles if (targetProfiles.length === 0) { throw new Error('没有可生成评语的学生') } const batchId = randomUUID() const className = targetProfiles[0]?.className const profiles: ChildProfile[] = [] const skipped: Array<{ studentName: string; reason: string }> = [] const controller: CommentGenerationController = { batchId, paused: false, stopped: false, lastProgress: { batchId, status: 'started', classId: input.classId, className, current: 0, total: targetProfiles.length }, resumeWaiters: [] } commentGenerationControllers.set(batchId, controller) 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 { const source = await getAppDataSource() await source.transaction(async (manager) => { const classRepository = manager.getRepository(ClassEntitySchema) const childRepository = manager.getRepository(StudentProfileEntitySchema) const nextClassIds = new Set(classes.map((classItem) => classItem.id)) const existingClasses = await classRepository.find() for (const classItem of existingClasses) { if (!nextClassIds.has(classItem.id)) { await childRepository.delete({ classId: classItem.id }) await classRepository.delete({ id: classItem.id }) } } for (const classItem of classes) { await classRepository.save(await mapClassProfile(classItem)) await childRepository.update({ classId: classItem.id }, { className: classItem.name }) } }) } export async function migrateLocalStudentData( classes: ClassProfile[], profiles: ChildProfile[] ): Promise { const source = await getAppDataSource() await source.transaction(async (manager) => { const classRepository = manager.getRepository(ClassEntitySchema) const childRepository = manager.getRepository(StudentProfileEntitySchema) const classCount = await classRepository.count() const profileCount = await childRepository.count() if (classCount > 0 || profileCount > 0) { return } await classRepository.save(await Promise.all(classes.map(mapClassProfile))) await childRepository.save( await Promise.all(profiles.map((profile) => mapChildProfile(profile))) ) }) } export async function replaceProfilesForClass( classItem: ClassProfile, profiles: ChildProfile[] ): Promise<{ profiles: ChildProfile[] insertedCount: number updatedCount: number skippedCount: number }> { const source = await getAppDataSource() return source.transaction(async (manager) => { const childRepository = manager.getRepository(StudentProfileEntitySchema) const existingProfiles = await childRepository.find({ where: { classId: classItem.id }, order: { name: 'ASC', id: 'ASC' } }) if (existingProfiles.length === 0) { const storedProfiles = await Promise.all( profiles.map((profile) => mapChildProfile(profile, classItem)) ) if (storedProfiles.length > 0) { await childRepository.save(storedProfiles) } return { profiles: storedProfiles.map((profile) => mapChildEntity(profile, { includeImages: false }) ), insertedCount: storedProfiles.length, updatedCount: 0, skippedCount: 0 } } const existingByMatchKey = new Map() for (const existingProfile of existingProfiles) { for (const matchKey of getProfileMatchKeys( mapChildEntity(existingProfile, { includeImages: false }) )) { existingByMatchKey.set(matchKey, [ ...(existingByMatchKey.get(matchKey) ?? []), existingProfile ]) } } const usedExistingIds = new Set() const updatedProfiles: StudentProfileEntity[] = [] let skippedCount = 0 for (const profile of profiles) { const matchedProfile = getProfileMatchKeys(profile) .flatMap((matchKey) => existingByMatchKey.get(matchKey) ?? []) .find((existingProfile) => !usedExistingIds.has(existingProfile.id)) if (!matchedProfile) { skippedCount += 1 continue } usedExistingIds.add(matchedProfile.id) updatedProfiles.push( await mapChildProfile(mergeImportedProfileIntoExisting(matchedProfile, profile, classItem)) ) } if (updatedProfiles.length > 0) { await childRepository.save(updatedProfiles) } const updatedProfileById = new Map(updatedProfiles.map((profile) => [profile.id, profile])) const nextProfiles = existingProfiles.map( (profile) => updatedProfileById.get(profile.id) ?? profile ) return { profiles: nextProfiles.map((profile) => mapChildEntity(profile, { includeImages: false })), insertedCount: 0, updatedCount: updatedProfiles.length, skippedCount } }) } export async function deleteClassWithProfiles(classId: string): Promise { const source = await getAppDataSource() await source.transaction(async (manager) => { await manager .getRepository(StudentProfileEntitySchema) .delete({ classId }) await manager.getRepository(ClassEntitySchema).delete({ id: classId }) }) } export async function deleteStudentProfile(profileId: string): Promise { const source = await getAppDataSource() const result = await source .getRepository(StudentProfileEntitySchema) .delete({ id: profileId }) if (!result.affected) { throw new Error('学生信息不存在') } } export async function migrateStoredImagesToFiles(): Promise { const source = await getAppDataSource() const classRepository = source.getRepository(ClassEntitySchema) const childRepository = source.getRepository(StudentProfileEntitySchema) const classes = await classRepository.find() const profiles = await childRepository.find() for (const classItem of classes) { const nextClassItem = await normalizeStoredClassImages(classItem) if (nextClassItem.familyPhoto !== classItem.familyPhoto) { await classRepository.save(nextClassItem) } } for (const profile of profiles) { const nextProfile = await normalizeStoredStudentImages(profile) if ( nextProfile.meImage !== profile.meImage || nextProfile.workImage1 !== profile.workImage1 || nextProfile.workImage2 !== profile.workImage2 ) { await childRepository.save(nextProfile) } } }