1187 lines
35 KiB
TypeScript
1187 lines
35 KiB
TypeScript
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)
|
||
}
|
||
}
|
||
}
|