Files
growth_report/src/main/services/studentService.ts
T

1187 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 {
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<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[] {
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<ClassEntity> {
if (!isDataUrlImage(entity.familyPhoto)) {
return entity
}
return {
...entity,
familyPhoto: await storeImageValue('classes', entity.id, 'familyPhoto', entity.familyPhoto)
}
}
async function hydrateClassImages(entity: ClassEntity): Promise<ClassEntity> {
if (!entity.familyPhoto) {
return entity
}
return {
...entity,
familyPhoto: await readImageAsDataUrl(entity.familyPhoto)
}
}
async function normalizeStoredStudentImages(
entity: StudentProfileEntity
): Promise<StudentProfileEntity> {
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<StudentProfileEntity> {
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<ClassEntity> {
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<StudentProfileEntity> {
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<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>
}> {
const source = await getAppDataSource()
return {
classRepository: source.getRepository<ClassEntity>(ClassEntitySchema),
childRepository: source.getRepository<StudentProfileEntity>(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<StudentProfileEntity>(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<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(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<ChildProfile> {
const source = await getAppDataSource()
const repository = source.getRepository<StudentProfileEntity>(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<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>,
classRepository: Repository<ClassEntity>,
classTypeConfigs: ClassTypeConfig[]
): Promise<ChildProfile> {
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<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('学生信息不存在')
}
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,
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<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
const classRepository = manager.getRepository<ClassEntity>(ClassEntitySchema)
const childRepository = manager.getRepository<StudentProfileEntity>(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<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
const classRepository = manager.getRepository<ClassEntity>(ClassEntitySchema)
const childRepository = manager.getRepository<StudentProfileEntity>(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<StudentProfileEntity>(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<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
}
})
}
export async function deleteClassWithProfiles(classId: string): Promise<void> {
const source = await getAppDataSource()
await source.transaction(async (manager) => {
await manager
.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
.delete({ classId })
await manager.getRepository<ClassEntity>(ClassEntitySchema).delete({ id: classId })
})
}
export async function deleteStudentProfile(profileId: string): Promise<void> {
const source = await getAppDataSource()
const result = await source
.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
.delete({ id: profileId })
if (!result.affected) {
throw new Error('学生信息不存在')
}
}
export async function migrateStoredImagesToFiles(): Promise<void> {
const source = await getAppDataSource()
const classRepository = source.getRepository<ClassEntity>(ClassEntitySchema)
const childRepository = source.getRepository<StudentProfileEntity>(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)
}
}
}