import React, { useState, useEffect, useRef } from 'react'; import { User, StudentProfile, SavedRecord, Zodiac, Personality, Hobby } from '../../types'; import { api } from '../../services/api'; import { FileSpreadsheet, Loader2, Sparkles, Copy, Save, Trash2, Download, RefreshCw, PenTool, Edit, Box, ChevronLeft, Settings2, Check, X } from 'lucide-react'; import { Toast, ToastState } from '../Toast'; import { ConfirmModal } from '../ConfirmModal'; interface CommentGeneratorPanelProps { currentUser: User | null; } const ZODIACS: Zodiac[] = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']; const PERSONALITIES: Personality[] = ['活泼', '内敛', '沉稳', '细心', '热心', '恒心', '爱心', '勇敢', '正义']; const HOBBIES: Hobby[] = ['体育', '音乐', '舞蹈', '绘画', '书法', '手工', '其他']; export const CommentGeneratorPanel: React.FC = ({ currentUser }) => { // --- State --- const [viewMode, setViewMode] = useState<'GENERATOR' | 'MANAGER'>('GENERATOR'); // Mobile View State: 'CONFIG' (Left) or 'RESULT' (Right) const [mobileTab, setMobileTab] = useState<'CONFIG' | 'RESULT'>('CONFIG'); const [importedNames, setImportedNames] = useState([]); const [savedRecords, setSavedRecords] = useState([]); // Form State const [profile, setProfile] = useState({ name: '', classRole: 'NO', discipline: 'YES', academic: 'GOOD', personality: '活泼', hobby: '体育', labor: '热爱', zodiacYear: '龙', wordCount: 150 }); const [generatedComment, setGeneratedComment] = useState(''); // Status State const [isImporting, setIsImporting] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [isExporting, setIsExporting] = useState(false); const [toast, setToast] = useState({ show: false, message: '', type: 'success' }); const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({ isOpen: false, title: '', message: '', onConfirm: () => {} }); // Manager Edit State const [editingRecordId, setEditingRecordId] = useState(null); const [editForm, setEditForm] = useState(null); // Refs const fileInputRef = useRef(null); const abortControllerRef = useRef(null); // Load saved records from localStorage useEffect(() => { try { const saved = localStorage.getItem(`comments_backup_${currentUser?._id}`); if (saved) setSavedRecords(JSON.parse(saved)); } catch (e) {} }, [currentUser]); // Persist records useEffect(() => { if (currentUser?._id) { localStorage.setItem(`comments_backup_${currentUser._id}`, JSON.stringify(savedRecords)); } }, [savedRecords, currentUser]); // Auto-load class students useEffect(() => { const fetchClassStudents = async () => { // If list is empty and user has a class, try to load from API if (importedNames.length === 0 && currentUser?.homeroomClass) { try { const allStudents = await api.students.getAll(); const classStudents = allStudents .filter((s: any) => s.className === currentUser.homeroomClass) .map((s: any) => s.name); if (classStudents.length > 0) { setImportedNames(classStudents); setProfile(prev => ({ ...prev, name: classStudents[0] })); setToast({ show: true, message: `已加载 ${currentUser.homeroomClass} 学生名单`, type: 'success' }); } } catch (e) { console.error("Failed to load students", e); } } }; fetchClassStudents(); }, [currentUser]); // --- Excel Import Logic --- const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setIsImporting(true); let XLSX: any; try { // @ts-ignore XLSX = await import('xlsx'); } catch (err) { setToast({ show: true, message: '无法加载Excel组件', type: 'error' }); setIsImporting(false); return; } const reader = new FileReader(); reader.onload = (evt) => { try { const data = evt.target?.result; const workbook = XLSX.read(data, { type: 'array' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); // Smart Header Recognition let nameColIndex = -1; let headerRowIndex = 0; // Scan first 20 rows for "姓名" or "Name" for (let i = 0; i < Math.min(jsonData.length, 20); i++) { const row = jsonData[i]; const idx = row.findIndex((cell: any) => typeof cell === 'string' && (cell.includes('姓名') || cell.toLowerCase() === 'name')); if (idx !== -1) { headerRowIndex = i; nameColIndex = idx; break; } } // Fallback: Density Scan if no header found if (nameColIndex === -1) { const colDensity: number[] = []; const scanLimit = Math.min(jsonData.length, 50); for (let i = 0; i < scanLimit; i++) { const row = jsonData[i]; row.forEach((cell, idx) => { if (typeof cell === 'string' && cell.length > 1 && cell.length < 5) { colDensity[idx] = (colDensity[idx] || 0) + 1; } }); } let maxDensity = 0; colDensity.forEach((d, idx) => { if (d > maxDensity) { maxDensity = d; nameColIndex = idx; } }); } if (nameColIndex === -1) { setToast({ show: true, message: '无法识别“姓名”列,请检查文件', type: 'error' }); setIsImporting(false); return; } // Extract Names const names: string[] = []; let emptyStreak = 0; // Async processing to prevent freeze const processRows = async () => { for (let i = headerRowIndex + 1; i < jsonData.length; i++) { if (emptyStreak > 50) break; // Safety break const row = jsonData[i]; const val = row[nameColIndex]; if (val && typeof val === 'string' && val.trim()) { names.push(val.trim()); emptyStreak = 0; } else { emptyStreak++; } if (i % 100 === 0) await new Promise(r => setTimeout(r, 0)); } setImportedNames(names); setToast({ show: true, message: `成功导入 ${names.length} 名学生`, type: 'success' }); if (names.length > 0) setProfile(p => ({ ...p, name: names[0] })); setIsImporting(false); }; processRows(); } catch (err) { console.error(err); setToast({ show: true, message: '文件解析失败', type: 'error' }); setIsImporting(false); } }; reader.readAsArrayBuffer(file); }; // --- AI Generation Logic --- const handleGenerate = async () => { if (!profile.name) return setToast({ show: true, message: '请输入或选择学生姓名', type: 'error' }); // Mobile: Switch to result tab automatically when generating if (window.innerWidth < 768) { setMobileTab('RESULT'); } setIsGenerating(true); setGeneratedComment(''); abortControllerRef.current = new AbortController(); const systemPrompt = `你是一位充满爱心、文笔优美的资深班主任。 你的任务是为学生生成一段期末评语。 ### 核心规则 (必须严格遵守) 1. **隐喻开头**:必须以“老师送你一只[形容词][生肖]……”开头。例如生肖是龙,可以说“老师送你一只腾飞的自信龙”或“老师送你一只威武的智慧龙”。形容词要结合学生的性格和表现。 2. **内容覆盖**:必须自然地融合以下维度:纪律表现、学业质量、性格特点、兴趣爱好、劳动表现、以及班干部职责(如果是)。 3. **语气风格**:温暖、真诚、富有文学性。多用排比、比喻。 4. **缺点处理**:对于不足之处(如纪律差、学业不合格),必须委婉表达,用“希望你...”或“如果在...方面更进一步”的句式,体现教育的期待感。 5. **格式要求**:纯文本输出,**不要**包含标题、Markdown符号或任何解释性文字。直接输出评语内容。字数控制在 ${profile.wordCount} 字左右。`; const userPrompt = `学生画像: - 姓名:${profile.name} - 生肖年份:${profile.zodiacYear} - 班干部:${profile.classRole === 'YES' ? '是 (请肯定其服务精神)' : '否'} - 纪律:${profile.discipline === 'YES' ? '优秀' : profile.discipline === 'AVERAGE' ? '一般' : '较差 (需委婉提醒)'} - 学业:${profile.academic === 'EXCELLENT' ? '优秀' : profile.academic === 'GOOD' ? '良好' : profile.academic === 'PASS' ? '合格' : '需努力'} - 性格:${profile.personality} - 爱好:${profile.hobby} - 劳动:${profile.labor === '热爱' ? '积极' : profile.labor === '一般' ? '一般' : '较少参与'} 请生成评语:`; try { const response = await fetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-user-username': currentUser?.username || '', 'x-user-role': currentUser?.role || '', 'x-school-id': currentUser?.schoolId || '' }, body: JSON.stringify({ text: userPrompt, overrideSystemPrompt: systemPrompt, enableThinking: false, disableAudio: true }), signal: abortControllerRef.current.signal }); if (!response.ok) throw new Error('Network error'); const reader = response.body?.getReader(); const decoder = new TextDecoder(); let accumulated = ''; let buffer = ''; while (true) { const { done, value } = await reader!.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.replace('data: ', '')); if (data.type === 'text') { accumulated += data.content; setGeneratedComment(accumulated); } } catch (e) {} } } } } catch (e: any) { if (e.name !== 'AbortError') setToast({ show: true, message: '生成失败', type: 'error' }); } finally { setIsGenerating(false); abortControllerRef.current = null; } }; const handleSave = () => { if (!generatedComment) return; const newRecord: SavedRecord = { id: Date.now().toString(), name: profile.name, comment: generatedComment, zodiac: profile.zodiacYear, timestamp: Date.now() }; // Remove old record for same student if exists const filtered = savedRecords.filter(r => r.name !== profile.name); setSavedRecords([newRecord, ...filtered]); setToast({ show: true, message: '已保存到管理箱', type: 'success' }); // Auto-advance logic if (importedNames.length > 0) { const idx = importedNames.indexOf(profile.name); if (idx !== -1 && idx < importedNames.length - 1) { setProfile(p => ({ ...p, name: importedNames[idx + 1] })); setGeneratedComment(''); // Clear for next // If mobile, ask if they want to go back to config to generate next if (window.innerWidth < 768) { // Stay on result for a moment or give a toast hint setToast({ show: true, message: `已保存。已切换到下一位:${importedNames[idx + 1]}`, type: 'success' }); } } } }; // --- Manager Logic --- const handleDeleteRecord = (id: string) => { setSavedRecords(prev => prev.filter(r => r.id !== id)); }; const handleBatchZodiac = (zodiac: Zodiac) => { setConfirmModal({ isOpen: true, title: '批量修改生肖', message: `确定将管理箱中所有记录的生肖标记修改为“${zodiac}”吗?\n(注意:这不会修改评语文本内容,只修改分类标记)`, onConfirm: () => { setSavedRecords(prev => prev.map(r => ({ ...r, zodiac }))); setToast({ show: true, message: '同步完成', type: 'success' }); } }); }; const handleExportWord = async () => { if (savedRecords.length === 0) return setToast({ show: true, message: '没有可导出的记录', type: 'error' }); setIsExporting(true); let docx: any; try { // @ts-ignore docx = await import('docx'); } catch (e) { setToast({ show: true, message: '无法加载导出组件', type: 'error' }); setIsExporting(false); return; } const { Document, Packer, Paragraph, TextRun, AlignmentType, HeadingLevel } = docx; const doc = new Document({ sections: [{ properties: {}, children: [ new Paragraph({ text: "期末学生评语", heading: HeadingLevel.HEADING_1, alignment: AlignmentType.CENTER, spacing: { after: 400 } }), ...savedRecords.flatMap(record => [ new Paragraph({ children: [ new TextRun({ text: record.name, bold: true, size: 28 }), new TextRun({ text: ":" }) ], spacing: { before: 200, after: 100 } }), new Paragraph({ text: record.comment, alignment: AlignmentType.JUSTIFIED, spacing: { after: 300 }, indent: { firstLine: 480 } // 2 chars indent roughly }) ]) ] }] }); const blob = await Packer.toBlob(doc); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `期末评语导出_${new Date().toLocaleDateString()}.docx`; a.click(); window.URL.revokeObjectURL(url); setIsExporting(false); setToast({ show: true, message: '导出成功', type: 'success' }); }; const isStudentDone = (name: string) => savedRecords.some(r => r.name === name); return (
{toast.show && setToast({...toast, show: false})}/>} setConfirmModal({...confirmModal, isOpen: false})} onConfirm={confirmModal.onConfirm}/> {/* Top Bar */}
暖心评语助手 评语助手
{/* GENERATOR MODE */} {viewMode === 'GENERATOR' && (
{/* --- Left Panel: Controls --- */} {/* Mobile: Shown only when mobileTab is CONFIG */} {/* Desktop: Always shown, fixed width 450px */}
{/* 1. Header (Name Input) */}
{importedNames.length > 0 ? (
) : ( setProfile({...profile, name: e.target.value})} /> )}
{isImporting &&
正在解析名单...
}
{/* 2. Scrollable Config Body */}
{/* Zodiac */}
{ZODIACS.map(z => ( ))}
{/* Personality */}
{PERSONALITIES.map(p => ( ))}
{/* Hobby & Length */}
setProfile({...profile, wordCount: Number(e.target.value)})}/>
{/* 3. Fixed Footer Button (Left Panel) */}
{/* --- Right Panel: Result Preview --- */} {/* Mobile: Shown only when mobileTab is RESULT */} {/* Desktop: Always shown, takes remaining space */}
{/* Mobile Header for Result */}
生成结果
{generatedComment ? (
{profile.name} 的专属评语
{profile.name} {profile.zodiacYear}年主题