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
+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
}
})
}