dvc890 commited on
Commit
b33198f
·
verified ·
1 Parent(s): 98030d3

Create CommentGeneratorPanel.tsx

Browse files
components/ai/CommentGeneratorPanel.tsx ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect, useRef } from 'react';
3
+ import { User, StudentProfile, SavedRecord, Zodiac, ClassRole, Discipline, AcademicQuality, Personality, Hobby, Labor } from '../../types';
4
+ import { FileSpreadsheet, Loader2, Sparkles, Copy, Save, Trash2, Download, RefreshCw, PenTool, CheckCircle, Circle, Edit, Box, Upload } from 'lucide-react';
5
+ import { Toast, ToastState } from '../Toast';
6
+ import { ConfirmModal } from '../ConfirmModal';
7
+
8
+ interface CommentGeneratorPanelProps {
9
+ currentUser: User | null;
10
+ }
11
+
12
+ const ZODIACS: Zodiac[] = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'];
13
+ const PERSONALITIES: Personality[] = ['活泼', '内敛', '沉稳', '细心', '热心', '恒心', '爱心', '勇敢', '正义'];
14
+ const HOBBIES: Hobby[] = ['体育', '音乐', '舞蹈', '绘画', '书法', '手工', '其他'];
15
+
16
+ export const CommentGeneratorPanel: React.FC<CommentGeneratorPanelProps> = ({ currentUser }) => {
17
+ // --- State ---
18
+ const [viewMode, setViewMode] = useState<'GENERATOR' | 'MANAGER'>('GENERATOR');
19
+ const [importedNames, setImportedNames] = useState<string[]>([]);
20
+ const [savedRecords, setSavedRecords] = useState<SavedRecord[]>([]);
21
+
22
+ // Form State
23
+ const [profile, setProfile] = useState<StudentProfile>({
24
+ name: '',
25
+ classRole: 'NO',
26
+ discipline: 'YES',
27
+ academic: 'GOOD',
28
+ personality: '活泼',
29
+ hobby: '体育',
30
+ labor: '热爱',
31
+ zodiacYear: '龙',
32
+ wordCount: 150
33
+ });
34
+
35
+ const [generatedComment, setGeneratedComment] = useState('');
36
+
37
+ // Status State
38
+ const [isImporting, setIsImporting] = useState(false);
39
+ const [isGenerating, setIsGenerating] = useState(false);
40
+ const [isExporting, setIsExporting] = useState(false);
41
+ const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
42
+ const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
43
+
44
+ // Manager Edit State
45
+ const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
46
+ const [editForm, setEditForm] = useState<SavedRecord | null>(null);
47
+
48
+ // Refs
49
+ const fileInputRef = useRef<HTMLInputElement>(null);
50
+ const abortControllerRef = useRef<AbortController | null>(null);
51
+
52
+ // Load saved records from localStorage
53
+ useEffect(() => {
54
+ try {
55
+ const saved = localStorage.getItem(`comments_backup_${currentUser?._id}`);
56
+ if (saved) setSavedRecords(JSON.parse(saved));
57
+ } catch (e) {}
58
+ }, [currentUser]);
59
+
60
+ // Persist records
61
+ useEffect(() => {
62
+ if (currentUser?._id) {
63
+ localStorage.setItem(`comments_backup_${currentUser._id}`, JSON.stringify(savedRecords));
64
+ }
65
+ }, [savedRecords, currentUser]);
66
+
67
+ // --- Excel Import Logic ---
68
+ const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
69
+ const file = e.target.files?.[0];
70
+ if (!file) return;
71
+
72
+ setIsImporting(true);
73
+
74
+ let XLSX: any;
75
+ try {
76
+ // @ts-ignore
77
+ XLSX = await import('xlsx');
78
+ } catch (err) {
79
+ setToast({ show: true, message: '无法加载Excel组件', type: 'error' });
80
+ setIsImporting(false);
81
+ return;
82
+ }
83
+
84
+ const reader = new FileReader();
85
+ reader.onload = (evt) => {
86
+ try {
87
+ const data = evt.target?.result;
88
+ const workbook = XLSX.read(data, { type: 'array' });
89
+ const sheetName = workbook.SheetNames[0];
90
+ const worksheet = workbook.Sheets[sheetName];
91
+ const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
92
+
93
+ // Smart Header Recognition
94
+ let nameColIndex = -1;
95
+ let headerRowIndex = 0;
96
+
97
+ // Scan first 20 rows for "姓名" or "Name"
98
+ for (let i = 0; i < Math.min(jsonData.length, 20); i++) {
99
+ const row = jsonData[i];
100
+ const idx = row.findIndex((cell: any) => typeof cell === 'string' && (cell.includes('姓名') || cell.toLowerCase() === 'name'));
101
+ if (idx !== -1) {
102
+ headerRowIndex = i;
103
+ nameColIndex = idx;
104
+ break;
105
+ }
106
+ }
107
+
108
+ // Fallback: Density Scan if no header found
109
+ if (nameColIndex === -1) {
110
+ const colDensity: number[] = [];
111
+ const scanLimit = Math.min(jsonData.length, 50);
112
+ for (let i = 0; i < scanLimit; i++) {
113
+ const row = jsonData[i];
114
+ row.forEach((cell, idx) => {
115
+ if (typeof cell === 'string' && cell.length > 1 && cell.length < 5) {
116
+ colDensity[idx] = (colDensity[idx] || 0) + 1;
117
+ }
118
+ });
119
+ }
120
+ let maxDensity = 0;
121
+ colDensity.forEach((d, idx) => {
122
+ if (d > maxDensity) { maxDensity = d; nameColIndex = idx; }
123
+ });
124
+ }
125
+
126
+ if (nameColIndex === -1) {
127
+ setToast({ show: true, message: '无法识别“姓名”列,请检查文件', type: 'error' });
128
+ setIsImporting(false);
129
+ return;
130
+ }
131
+
132
+ // Extract Names
133
+ const names: string[] = [];
134
+ let emptyStreak = 0;
135
+
136
+ // Async processing to prevent freeze
137
+ const processRows = async () => {
138
+ for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
139
+ if (emptyStreak > 50) break; // Safety break
140
+ const row = jsonData[i];
141
+ const val = row[nameColIndex];
142
+ if (val && typeof val === 'string' && val.trim()) {
143
+ names.push(val.trim());
144
+ emptyStreak = 0;
145
+ } else {
146
+ emptyStreak++;
147
+ }
148
+
149
+ if (i % 100 === 0) await new Promise(r => setTimeout(r, 0));
150
+ }
151
+
152
+ setImportedNames(names);
153
+ setToast({ show: true, message: `成功导入 ${names.length} 名学生`, type: 'success' });
154
+ if (names.length > 0) setProfile(p => ({ ...p, name: names[0] }));
155
+ setIsImporting(false);
156
+ };
157
+ processRows();
158
+
159
+ } catch (err) {
160
+ console.error(err);
161
+ setToast({ show: true, message: '文件解析失败', type: 'error' });
162
+ setIsImporting(false);
163
+ }
164
+ };
165
+ reader.readAsArrayBuffer(file);
166
+ };
167
+
168
+ // --- AI Generation Logic ---
169
+ const handleGenerate = async () => {
170
+ if (!profile.name) return setToast({ show: true, message: '请输入或选择学生姓名', type: 'error' });
171
+
172
+ setIsGenerating(true);
173
+ setGeneratedComment('');
174
+ abortControllerRef.current = new AbortController();
175
+
176
+ const systemPrompt = `你是一位充满爱心、文笔优美的资深班主任。
177
+ 你的任务是为学生生成一段期末评语。
178
+
179
+ ### 核心规则 (必须严格遵守)
180
+ 1. **隐喻开头**:必须以“老师送你一只[形容词][生肖]……”开头。例如生肖是龙,可以说“老师送你一只腾飞的自信龙”或“老师送你一只威武的智慧龙”。形容词要结合学生的性格和表现。
181
+ 2. **内容覆盖**:必须自然地融合以下维度:纪律表现、学业质量、性格特点、兴趣爱好、劳动表现、以及班干部职责(如果是)。
182
+ 3. **语气风格**:温暖、真诚、富有文学性。多用排比、比喻。
183
+ 4. **缺点处理**:对于不足之处(如纪律差、学业不合格),必须委婉表达,用“希望你...”或“如果在...方面更进一步”的句式,体现教育的期待感。
184
+ 5. **格式要求**:纯文本输出,**不要**包含标题、Markdown符号或任何解释性文字。直接输出评语内容。字数控制在 ${profile.wordCount} 字左右。`;
185
+
186
+ const userPrompt = `学生画像:
187
+ - 姓名:${profile.name}
188
+ - 生肖年份:${profile.zodiacYear}
189
+ - 班干部:${profile.classRole === 'YES' ? '是 (请肯定其服务精神)' : '否'}
190
+ - 纪律:${profile.discipline === 'YES' ? '优秀' : profile.discipline === 'AVERAGE' ? '一般' : '较差 (需委婉提醒)'}
191
+ - 学业:${profile.academic === 'EXCELLENT' ? '优秀' : profile.academic === 'GOOD' ? '良好' : profile.academic === 'PASS' ? '合格' : '需努力'}
192
+ - 性格:${profile.personality}
193
+ - 爱好:${profile.hobby}
194
+ - 劳动:${profile.labor === '热爱' ? '积极' : profile.labor === '一般' ? '一般' : '较少参与'}
195
+
196
+ 请生成评语:`;
197
+
198
+ try {
199
+ const response = await fetch('/api/ai/chat', {
200
+ method: 'POST',
201
+ headers: {
202
+ 'Content-Type': 'application/json',
203
+ 'x-user-username': currentUser?.username || '',
204
+ 'x-user-role': currentUser?.role || '',
205
+ 'x-school-id': currentUser?.schoolId || ''
206
+ },
207
+ body: JSON.stringify({
208
+ text: userPrompt,
209
+ overrideSystemPrompt: systemPrompt,
210
+ enableThinking: false, // Turn off thinking for faster generation
211
+ disableAudio: true
212
+ }),
213
+ signal: abortControllerRef.current.signal
214
+ });
215
+
216
+ if (!response.ok) throw new Error('Network error');
217
+ const reader = response.body?.getReader();
218
+ const decoder = new TextDecoder();
219
+ let accumulated = '';
220
+
221
+ while (true) {
222
+ const { done, value } = await reader!.read();
223
+ if (done) break;
224
+ const chunk = decoder.decode(value, { stream: true });
225
+ const lines = chunk.split('\n\n');
226
+ for (const line of lines) {
227
+ if (line.startsWith('data: ')) {
228
+ try {
229
+ const data = JSON.parse(line.replace('data: ', ''));
230
+ if (data.type === 'text') {
231
+ accumulated += data.content;
232
+ setGeneratedComment(accumulated);
233
+ }
234
+ } catch (e) {}
235
+ }
236
+ }
237
+ }
238
+ } catch (e: any) {
239
+ if (e.name !== 'AbortError') setToast({ show: true, message: '生成失败', type: 'error' });
240
+ } finally {
241
+ setIsGenerating(false);
242
+ abortControllerRef.current = null;
243
+ }
244
+ };
245
+
246
+ const handleSave = () => {
247
+ if (!generatedComment) return;
248
+ const newRecord: SavedRecord = {
249
+ id: Date.now().toString(),
250
+ name: profile.name,
251
+ comment: generatedComment,
252
+ zodiac: profile.zodiacYear,
253
+ timestamp: Date.now()
254
+ };
255
+ // Remove old record for same student if exists
256
+ const filtered = savedRecords.filter(r => r.name !== profile.name);
257
+ setSavedRecords([newRecord, ...filtered]);
258
+ setToast({ show: true, message: '已保存到管理箱', type: 'success' });
259
+
260
+ // Auto-advance logic: If importing, pick next name?
261
+ if (importedNames.length > 0) {
262
+ const idx = importedNames.indexOf(profile.name);
263
+ if (idx !== -1 && idx < importedNames.length - 1) {
264
+ setProfile(p => ({ ...p, name: importedNames[idx + 1] }));
265
+ setGeneratedComment(''); // Clear for next
266
+ }
267
+ }
268
+ };
269
+
270
+ // --- Manager Logic ---
271
+ const handleDeleteRecord = (id: string) => {
272
+ setSavedRecords(prev => prev.filter(r => r.id !== id));
273
+ };
274
+
275
+ const handleBatchZodiac = (zodiac: Zodiac) => {
276
+ setConfirmModal({
277
+ isOpen: true,
278
+ title: '批量修改生肖',
279
+ message: `确定将管理箱中所有记录的生肖标记修改为“${zodiac}”吗?\n(注意:这不会修改评语文本内容,只修改分类标记)`,
280
+ onConfirm: () => {
281
+ setSavedRecords(prev => prev.map(r => ({ ...r, zodiac })));
282
+ setToast({ show: true, message: '同步完成', type: 'success' });
283
+ }
284
+ });
285
+ };
286
+
287
+ const handleExportWord = async () => {
288
+ if (savedRecords.length === 0) return setToast({ show: true, message: '没有可导出的记录', type: 'error' });
289
+
290
+ setIsExporting(true);
291
+ let docx;
292
+ try {
293
+ // @ts-ignore
294
+ docx = await import('docx');
295
+ } catch (e) {
296
+ setToast({ show: true, message: '无法加载导出组件', type: 'error' });
297
+ setIsExporting(false);
298
+ return;
299
+ }
300
+
301
+ const { Document, Packer, Paragraph, TextRun, AlignmentType, HeadingLevel } = docx;
302
+
303
+ const doc = new Document({
304
+ sections: [{
305
+ properties: {},
306
+ children: [
307
+ new Paragraph({
308
+ text: "期末学生评语",
309
+ heading: HeadingLevel.HEADING_1,
310
+ alignment: AlignmentType.CENTER,
311
+ spacing: { after: 400 }
312
+ }),
313
+ ...savedRecords.flatMap(record => [
314
+ new Paragraph({
315
+ children: [
316
+ new TextRun({ text: record.name, bold: true, size: 28 }),
317
+ new TextRun({ text: ":" })
318
+ ],
319
+ spacing: { before: 200, after: 100 }
320
+ }),
321
+ new Paragraph({
322
+ text: record.comment,
323
+ alignment: AlignmentType.JUSTIFIED,
324
+ spacing: { after: 300 },
325
+ indent: { firstLine: 480 } // 2 chars indent roughly
326
+ })
327
+ ])
328
+ ]
329
+ }]
330
+ });
331
+
332
+ const blob = await Packer.toBlob(doc);
333
+ const url = window.URL.createObjectURL(blob);
334
+ const a = document.createElement('a');
335
+ a.href = url;
336
+ a.download = `期末评语导出_${new Date().toLocaleDateString()}.docx`;
337
+ a.click();
338
+ window.URL.revokeObjectURL(url);
339
+ setIsExporting(false);
340
+ setToast({ show: true, message: '导出成功', type: 'success' });
341
+ };
342
+
343
+ const isStudentDone = (name: string) => savedRecords.some(r => r.name === name);
344
+
345
+ return (
346
+ <div className="h-full flex flex-col bg-amber-50/30 overflow-hidden relative">
347
+ {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
348
+ <ConfirmModal isOpen={confirmModal.isOpen} title={confirmModal.title} message={confirmModal.message} onClose={()=>setConfirmModal({...confirmModal, isOpen: false})} onConfirm={confirmModal.onConfirm}/>
349
+
350
+ {/* Top Bar */}
351
+ <div className="bg-white border-b border-amber-100 px-6 py-3 flex justify-between items-center shrink-0 shadow-sm z-10">
352
+ <div className="flex items-center gap-2 text-amber-700 font-bold text-lg">
353
+ <PenTool className="fill-amber-500 text-amber-600" />
354
+ 暖心评语助手
355
+ </div>
356
+ <div className="flex bg-amber-100/50 p-1 rounded-lg">
357
+ <button onClick={()=>setViewMode('GENERATOR')} className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all flex items-center gap-2 ${viewMode==='GENERATOR' ? 'bg-white text-amber-600 shadow-sm' : 'text-amber-800/60'}`}>
358
+ <Sparkles size={16}/> 生成器
359
+ </button>
360
+ <button onClick={()=>setViewMode('MANAGER')} className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all flex items-center gap-2 ${viewMode==='MANAGER' ? 'bg-white text-amber-600 shadow-sm' : 'text-amber-800/60'}`}>
361
+ <Box size={16}/> 管理箱
362
+ {savedRecords.length > 0 && <span className="bg-red-500 text-white text-[10px] px-1.5 rounded-full">{savedRecords.length}</span>}
363
+ </button>
364
+ </div>
365
+ </div>
366
+
367
+ {/* GENERATOR MODE */}
368
+ {viewMode === 'GENERATOR' && (
369
+ <div className="flex-1 overflow-hidden flex flex-col md:flex-row">
370
+ {/* Left: Controls */}
371
+ <div className="w-full md:w-80 lg:w-96 border-r border-amber-100 bg-white overflow-y-auto p-6 custom-scrollbar flex flex-col gap-6">
372
+ {/* 1. Name Input */}
373
+ <div className="space-y-3">
374
+ <label className="text-xs font-bold text-gray-500 uppercase block">学生姓名</label>
375
+ {importedNames.length > 0 ? (
376
+ <div className="relative">
377
+ <select
378
+ className="w-full border border-amber-200 rounded-lg p-2.5 bg-amber-50/50 text-sm appearance-none focus:ring-2 focus:ring-amber-400 outline-none"
379
+ value={profile.name}
380
+ onChange={e => setProfile({...profile, name: e.target.value})}
381
+ >
382
+ {importedNames.map(name => (
383
+ <option key={name} value={name}>
384
+ {isStudentDone(name) ? '✅' : '⬜'} {name}
385
+ </option>
386
+ ))}
387
+ </select>
388
+ </div>
389
+ ) : (
390
+ <div className="flex gap-2">
391
+ <input
392
+ className="flex-1 border border-gray-200 rounded-lg p-2 text-sm focus:ring-2 focus:ring-amber-400 outline-none"
393
+ placeholder="输入姓名..."
394
+ value={profile.name}
395
+ onChange={e => setProfile({...profile, name: e.target.value})}
396
+ />
397
+ <button
398
+ onClick={() => fileInputRef.current?.click()}
399
+ className="px-3 bg-green-50 text-green-600 rounded-lg border border-green-200 hover:bg-green-100 flex items-center"
400
+ title="Excel 导入"
401
+ >
402
+ <FileSpreadsheet size={18}/>
403
+ </button>
404
+ <input type="file" accept=".xlsx,.xls,.csv" ref={fileInputRef} className="hidden" onChange={handleFileUpload} />
405
+ </div>
406
+ )}
407
+ {isImporting && <div className="text-xs text-gray-400 flex items-center gap-1"><Loader2 size={10} className="animate-spin"/> 正在解析名单...</div>}
408
+ </div>
409
+
410
+ {/* 2. Attributes */}
411
+ <div className="space-y-4">
412
+ <div>
413
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">生肖年份</label>
414
+ <div className="flex flex-wrap gap-2">
415
+ {ZODIACS.map(z => (
416
+ <button
417
+ key={z}
418
+ onClick={() => setProfile({...profile, zodiacYear: z})}
419
+ className={`text-xs px-2 py-1 rounded border ${profile.zodiacYear === z ? 'bg-red-50 text-red-600 border-red-200 font-bold' : 'bg-gray-50 text-gray-500 border-gray-100 hover:bg-gray-100'}`}
420
+ >
421
+ {z}
422
+ </button>
423
+ ))}
424
+ </div>
425
+ </div>
426
+
427
+ <div className="grid grid-cols-2 gap-4">
428
+ <div>
429
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">班干部</label>
430
+ <select className="w-full text-sm border border-gray-200 rounded p-2" value={profile.classRole} onChange={e=>setProfile({...profile, classRole: e.target.value as any})}>
431
+ <option value="NO">否</option>
432
+ <option value="YES">是 (重点表扬)</option>
433
+ </select>
434
+ </div>
435
+ <div>
436
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">纪律表现</label>
437
+ <select className="w-full text-sm border border-gray-200 rounded p-2" value={profile.discipline} onChange={e=>setProfile({...profile, discipline: e.target.value as any})}>
438
+ <option value="YES">遵守纪律</option>
439
+ <option value="AVERAGE">一般</option>
440
+ <option value="NO">较差 (需提醒)</option>
441
+ </select>
442
+ </div>
443
+ </div>
444
+
445
+ <div className="grid grid-cols-2 gap-4">
446
+ <div>
447
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">学业质量</label>
448
+ <select className="w-full text-sm border border-gray-200 rounded p-2" value={profile.academic} onChange={e=>setProfile({...profile, academic: e.target.value as any})}>
449
+ <option value="EXCELLENT">优秀</option>
450
+ <option value="GOOD">良好</option>
451
+ <option value="PASS">合格</option>
452
+ <option value="FAIL">需努力</option>
453
+ </select>
454
+ </div>
455
+ <div>
456
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">劳动表现</label>
457
+ <select className="w-full text-sm border border-gray-200 rounded p-2" value={profile.labor} onChange={e=>setProfile({...profile, labor: e.target.value as any})}>
458
+ <option value="热爱">热爱劳动</option>
459
+ <option value="一般">一般</option>
460
+ <option value="较少参与">较少参与</option>
461
+ </select>
462
+ </div>
463
+ </div>
464
+
465
+ <div>
466
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">性格特点</label>
467
+ <div className="flex flex-wrap gap-2">
468
+ {PERSONALITIES.map(p => (
469
+ <button
470
+ key={p}
471
+ onClick={() => setProfile({...profile, personality: p})}
472
+ className={`text-xs px-2 py-1 rounded border transition-colors ${profile.personality === p ? 'bg-blue-50 text-blue-600 border-blue-200 font-bold' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}
473
+ >
474
+ {p}
475
+ </button>
476
+ ))}
477
+ </div>
478
+ </div>
479
+
480
+ <div>
481
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">兴趣爱好</label>
482
+ <select className="w-full text-sm border border-gray-200 rounded p-2" value={profile.hobby} onChange={e=>setProfile({...profile, hobby: e.target.value as any})}>
483
+ {HOBBIES.map(h => <option key={h} value={h}>{h}</option>)}
484
+ </select>
485
+ </div>
486
+
487
+ <div>
488
+ <label className="text-xs font-bold text-gray-500 uppercase block mb-1">字数控制: {profile.wordCount}</label>
489
+ <input type="range" min="50" max="300" step="10" className="w-full accent-amber-500" value={profile.wordCount} onChange={e=>setProfile({...profile, wordCount: Number(e.target.value)})}/>
490
+ </div>
491
+ </div>
492
+
493
+ <button
494
+ onClick={handleGenerate}
495
+ disabled={isGenerating || !profile.name}
496
+ className="w-full py-3 bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-xl font-bold shadow-md hover:shadow-lg transition-all flex items-center justify-center gap-2 disabled:opacity-50"
497
+ >
498
+ {isGenerating ? <Loader2 className="animate-spin" size={20}/> : <Sparkles size={20}/>}
499
+ {isGenerating ? 'AI 正在撰写...' : '生成评语'}
500
+ </button>
501
+ </div>
502
+
503
+ {/* Right: Preview */}
504
+ <div className="flex-1 p-6 flex flex-col items-center justify-center bg-amber-50/50 relative">
505
+ {generatedComment ? (
506
+ <div className="w-full max-w-2xl bg-white rounded-2xl shadow-xl border border-amber-100 p-8 relative animate-in fade-in zoom-in-95">
507
+ <div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-amber-100 text-amber-800 px-4 py-1 rounded-full text-xs font-bold shadow-sm border border-amber-200">
508
+ {profile.name} 的评语
509
+ </div>
510
+ <textarea
511
+ className="w-full min-h-[200px] text-lg leading-loose text-gray-700 outline-none resize-none bg-transparent font-medium"
512
+ value={generatedComment}
513
+ onChange={(e) => setGeneratedComment(e.target.value)}
514
+ />
515
+ <div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
516
+ <button onClick={() => { navigator.clipboard.writeText(generatedComment); setToast({show:true, message:'已复制', type:'success'}); }} className="flex items-center gap-1 text-gray-500 hover:text-blue-600 px-3 py-1.5 rounded hover:bg-gray-50 transition-colors">
517
+ <Copy size={16}/> 复制
518
+ </button>
519
+ <button onClick={handleSave} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-transform active:scale-95">
520
+ <Save size={18}/> 保存到管理箱
521
+ </button>
522
+ </div>
523
+ </div>
524
+ ) : (
525
+ <div className="text-center text-amber-800/40">
526
+ <Sparkles size={64} className="mx-auto mb-4 opacity-50"/>
527
+ <p className="text-lg font-bold">等待生成...</p>
528
+ <p className="text-sm mt-2">请在左侧配置学生信息并点击生成</p>
529
+ </div>
530
+ )}
531
+ </div>
532
+ </div>
533
+ )}
534
+
535
+ {/* MANAGER MODE */}
536
+ {viewMode === 'MANAGER' && (
537
+ <div className="flex-1 overflow-hidden flex flex-col p-6">
538
+ <div className="flex justify-between items-center mb-6">
539
+ <div className="flex gap-2">
540
+ <button onClick={handleExportWord} disabled={isExporting} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-bold flex items-center gap-2 hover:bg-blue-700 shadow-sm disabled:opacity-50 transition-colors">
541
+ {isExporting ? <Loader2 className="animate-spin" size={18}/> : <Download size={18}/>}
542
+ 导出 Word 文档
543
+ </button>
544
+ <div className="relative group">
545
+ <button className="bg-white text-gray-600 border border-gray-200 px-4 py-2 rounded-lg font-bold flex items-center gap-2 hover:bg-gray-50 shadow-sm">
546
+ <RefreshCw size={16}/> 批量同步年份
547
+ </button>
548
+ <div className="absolute top-full left-0 mt-2 w-32 bg-white border border-gray-200 rounded-lg shadow-xl hidden group-hover:block z-20 py-1">
549
+ {ZODIACS.map(z => (
550
+ <button key={z} onClick={() => handleBatchZodiac(z)} className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm text-gray-700">
551
+ 改为 {z}年
552
+ </button>
553
+ ))}
554
+ </div>
555
+ </div>
556
+ </div>
557
+ <div className="text-sm text-gray-500 font-bold">
558
+ 已保存 {savedRecords.length} 条记录
559
+ </div>
560
+ </div>
561
+
562
+ <div className="flex-1 overflow-y-auto custom-scrollbar">
563
+ {savedRecords.length === 0 ? (
564
+ <div className="h-full flex flex-col items-center justify-center text-gray-400 border-2 border-dashed border-gray-200 rounded-2xl bg-white/50">
565
+ <Box size={48} className="mb-4 opacity-50"/>
566
+ <p>管理箱是空的</p>
567
+ </div>
568
+ ) : (
569
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-10">
570
+ {savedRecords.map(record => (
571
+ <div key={record.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 flex flex-col hover:shadow-md transition-shadow group">
572
+ <div className="flex justify-between items-center mb-3">
573
+ <div className="font-bold text-lg text-gray-800 flex items-center gap-2">
574
+ {record.name}
575
+ <span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full border border-amber-200">{record.zodiac}年</span>
576
+ </div>
577
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
578
+ <button
579
+ onClick={() => { setEditingRecordId(record.id); setEditForm(record); }}
580
+ className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded"
581
+ >
582
+ <Edit size={16}/>
583
+ </button>
584
+ <button
585
+ onClick={() => handleDeleteRecord(record.id)}
586
+ className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
587
+ >
588
+ <Trash2 size={16}/>
589
+ </button>
590
+ </div>
591
+ </div>
592
+
593
+ {editingRecordId === record.id && editForm ? (
594
+ <div className="flex-1 flex flex-col gap-2">
595
+ <input
596
+ className="w-full border rounded p-1 text-sm font-bold"
597
+ value={editForm.name}
598
+ onChange={e => setEditForm({...editForm, name: e.target.value})}
599
+ />
600
+ <textarea
601
+ className="w-full border rounded p-2 text-sm resize-none flex-1 focus:ring-1 focus:ring-blue-500 outline-none"
602
+ rows={5}
603
+ value={editForm.comment}
604
+ onChange={e => setEditForm({...editForm, comment: e.target.value})}
605
+ />
606
+ <div className="flex justify-end gap-2 mt-2">
607
+ <button onClick={() => setEditingRecordId(null)} className="text-xs text-gray-500 px-2 py-1">取消</button>
608
+ <button
609
+ onClick={() => {
610
+ setSavedRecords(prev => prev.map(r => r.id === record.id ? editForm! : r));
611
+ setEditingRecordId(null);
612
+ setToast({ show: true, message: '修改已保存', type: 'success' });
613
+ }}
614
+ className="text-xs bg-blue-600 text-white px-3 py-1 rounded"
615
+ >
616
+ 保存
617
+ </button>
618
+ </div>
619
+ </div>
620
+ ) : (
621
+ <div className="flex-1 text-sm text-gray-600 leading-relaxed overflow-hidden text-ellipsis line-clamp-6 bg-gray-50 p-3 rounded-lg border border-gray-50">
622
+ {record.comment}
623
+ </div>
624
+ )}
625
+
626
+ <div className="mt-3 pt-3 border-t border-gray-100 flex justify-between items-center text-xs text-gray-400">
627
+ <span>{new Date(record.timestamp).toLocaleDateString()}</span>
628
+ <span className="font-mono">{record.comment.length}字</span>
629
+ </div>
630
+ </div>
631
+ ))}
632
+ </div>
633
+ )}
634
+ </div>
635
+ </div>
636
+ )}
637
+ </div>
638
+ );
639
+ };