feat: enhance class report workflows
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user