Spaces:
Paused
Paused
chih.yikuan Claude Opus 4.6 (1M context) commited on
Commit ·
40aa09c
1
Parent(s): f002ae5
Add prompt editor and data preview back to Step 2
Browse filesStep 2 now has: student checkboxes → prompt editor (save/load) →
"預覽分析資料" button → DataPreviewModal → "生成報告".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chatkit/frontend/src/App.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { StepIndicator } from "./components/StepIndicator";
|
|
| 6 |
import { FileUploadPanel } from "./components/step1/FileUploadPanel";
|
| 7 |
import { ParsedDataSummary } from "./components/step2/ParsedDataSummary";
|
| 8 |
import { StudentSelector } from "./components/step2/StudentSelector";
|
|
|
|
| 9 |
import { ReportViewer } from "./components/step3/ReportViewer";
|
| 10 |
import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
|
| 11 |
|
|
@@ -38,7 +39,9 @@ export default function App() {
|
|
| 38 |
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
| 39 |
const [sessionId, setSessionId] = useState<number | null>(null);
|
| 40 |
const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
|
|
|
|
| 41 |
const [students, setStudents] = useState<StudentInfo[]>([]);
|
|
|
|
| 42 |
const [reports, setReports] = useState<StudentReport[]>([]);
|
| 43 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 44 |
|
|
@@ -55,6 +58,15 @@ export default function App() {
|
|
| 55 |
}
|
| 56 |
}, []);
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
// Create session when user is authenticated and no session exists
|
| 59 |
useEffect(() => {
|
| 60 |
if (user && !sessionId) {
|
|
@@ -82,7 +94,9 @@ export default function App() {
|
|
| 82 |
setUser(null);
|
| 83 |
setSessionId(null);
|
| 84 |
setParsedData({});
|
|
|
|
| 85 |
setStudents([]);
|
|
|
|
| 86 |
setReports([]);
|
| 87 |
setCurrentStep(1);
|
| 88 |
};
|
|
@@ -96,10 +110,9 @@ export default function App() {
|
|
| 96 |
}, []);
|
| 97 |
|
| 98 |
// Sequential per-student report generation
|
| 99 |
-
const handleGenerateReports = useCallback(async (
|
| 100 |
-
if (!sessionId) return;
|
| 101 |
|
| 102 |
-
// Initialize report slots
|
| 103 |
const initial: StudentReport[] = selectedIndices.map((idx) => {
|
| 104 |
const s = students.find((s) => s.index === idx);
|
| 105 |
return {
|
|
@@ -113,11 +126,9 @@ export default function App() {
|
|
| 113 |
setIsGenerating(true);
|
| 114 |
setCurrentStep(3);
|
| 115 |
|
| 116 |
-
// Generate sequentially
|
| 117 |
for (let i = 0; i < selectedIndices.length; i++) {
|
| 118 |
const idx = selectedIndices[i];
|
| 119 |
|
| 120 |
-
// Mark current as generating
|
| 121 |
setReports((prev) =>
|
| 122 |
prev.map((r, j) => (j === i ? { ...r, status: "generating" } : r))
|
| 123 |
);
|
|
@@ -148,13 +159,12 @@ export default function App() {
|
|
| 148 |
}
|
| 149 |
|
| 150 |
setIsGenerating(false);
|
| 151 |
-
}, [sessionId, students]);
|
| 152 |
|
| 153 |
const handleExportAllHtml = useCallback(() => {
|
| 154 |
const doneReports = reports.filter((r) => r.status === "done");
|
| 155 |
if (doneReports.length === 0) return;
|
| 156 |
|
| 157 |
-
// Combine all reports into one HTML file with page breaks
|
| 158 |
const combined = `<!DOCTYPE html>
|
| 159 |
<html lang="zh-TW"><head><meta charset="UTF-8"><title>ClassLens - Student Reports</title>
|
| 160 |
<style>
|
|
@@ -182,7 +192,6 @@ ${doneReports
|
|
| 182 |
if (step <= currentStep) setCurrentStep(step);
|
| 183 |
};
|
| 184 |
|
| 185 |
-
// Loading state
|
| 186 |
if (loading) {
|
| 187 |
return (
|
| 188 |
<div className="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
|
|
@@ -191,7 +200,6 @@ ${doneReports
|
|
| 191 |
);
|
| 192 |
}
|
| 193 |
|
| 194 |
-
// Invite code gate
|
| 195 |
if (!invited) {
|
| 196 |
return (
|
| 197 |
<InviteGate
|
|
@@ -204,7 +212,6 @@ ${doneReports
|
|
| 204 |
);
|
| 205 |
}
|
| 206 |
|
| 207 |
-
// Login gate
|
| 208 |
if (!user) {
|
| 209 |
return <LoginForm onLogin={setUser} />;
|
| 210 |
}
|
|
@@ -242,19 +249,34 @@ ${doneReports
|
|
| 242 |
<div className="space-y-6 animate-fade-in-up">
|
| 243 |
<div className="text-center mb-4">
|
| 244 |
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
|
| 245 |
-
選擇學生
|
| 246 |
</h2>
|
| 247 |
<p className="text-[var(--color-text-muted)] mt-2">
|
| 248 |
-
勾選要生成
|
| 249 |
</p>
|
| 250 |
</div>
|
|
|
|
| 251 |
<ParsedDataSummary parsedData={parsedData} />
|
|
|
|
| 252 |
{students.length > 0 ? (
|
| 253 |
-
<
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
) : (
|
| 259 |
<div className="card p-8 text-center">
|
| 260 |
<p className="text-[var(--color-text-muted)]">
|
|
|
|
| 6 |
import { FileUploadPanel } from "./components/step1/FileUploadPanel";
|
| 7 |
import { ParsedDataSummary } from "./components/step2/ParsedDataSummary";
|
| 8 |
import { StudentSelector } from "./components/step2/StudentSelector";
|
| 9 |
+
import { PromptEditor } from "./components/step2/PromptEditor";
|
| 10 |
import { ReportViewer } from "./components/step3/ReportViewer";
|
| 11 |
import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
|
| 12 |
|
|
|
|
| 39 |
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
| 40 |
const [sessionId, setSessionId] = useState<number | null>(null);
|
| 41 |
const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
|
| 42 |
+
const [promptContent, setPromptContent] = useState("");
|
| 43 |
const [students, setStudents] = useState<StudentInfo[]>([]);
|
| 44 |
+
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
| 45 |
const [reports, setReports] = useState<StudentReport[]>([]);
|
| 46 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 47 |
|
|
|
|
| 58 |
}
|
| 59 |
}, []);
|
| 60 |
|
| 61 |
+
// Load default prompt
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
if (user && !promptContent) {
|
| 64 |
+
apiGet<{ content: string }>("/api/prompts/default")
|
| 65 |
+
.then((res) => setPromptContent(res.content))
|
| 66 |
+
.catch(() => {});
|
| 67 |
+
}
|
| 68 |
+
}, [user]);
|
| 69 |
+
|
| 70 |
// Create session when user is authenticated and no session exists
|
| 71 |
useEffect(() => {
|
| 72 |
if (user && !sessionId) {
|
|
|
|
| 94 |
setUser(null);
|
| 95 |
setSessionId(null);
|
| 96 |
setParsedData({});
|
| 97 |
+
setPromptContent("");
|
| 98 |
setStudents([]);
|
| 99 |
+
setSelectedIndices([]);
|
| 100 |
setReports([]);
|
| 101 |
setCurrentStep(1);
|
| 102 |
};
|
|
|
|
| 110 |
}, []);
|
| 111 |
|
| 112 |
// Sequential per-student report generation
|
| 113 |
+
const handleGenerateReports = useCallback(async (model: string) => {
|
| 114 |
+
if (!sessionId || selectedIndices.length === 0) return;
|
| 115 |
|
|
|
|
| 116 |
const initial: StudentReport[] = selectedIndices.map((idx) => {
|
| 117 |
const s = students.find((s) => s.index === idx);
|
| 118 |
return {
|
|
|
|
| 126 |
setIsGenerating(true);
|
| 127 |
setCurrentStep(3);
|
| 128 |
|
|
|
|
| 129 |
for (let i = 0; i < selectedIndices.length; i++) {
|
| 130 |
const idx = selectedIndices[i];
|
| 131 |
|
|
|
|
| 132 |
setReports((prev) =>
|
| 133 |
prev.map((r, j) => (j === i ? { ...r, status: "generating" } : r))
|
| 134 |
);
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
setIsGenerating(false);
|
| 162 |
+
}, [sessionId, selectedIndices, students]);
|
| 163 |
|
| 164 |
const handleExportAllHtml = useCallback(() => {
|
| 165 |
const doneReports = reports.filter((r) => r.status === "done");
|
| 166 |
if (doneReports.length === 0) return;
|
| 167 |
|
|
|
|
| 168 |
const combined = `<!DOCTYPE html>
|
| 169 |
<html lang="zh-TW"><head><meta charset="UTF-8"><title>ClassLens - Student Reports</title>
|
| 170 |
<style>
|
|
|
|
| 192 |
if (step <= currentStep) setCurrentStep(step);
|
| 193 |
};
|
| 194 |
|
|
|
|
| 195 |
if (loading) {
|
| 196 |
return (
|
| 197 |
<div className="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
|
|
|
|
| 200 |
);
|
| 201 |
}
|
| 202 |
|
|
|
|
| 203 |
if (!invited) {
|
| 204 |
return (
|
| 205 |
<InviteGate
|
|
|
|
| 212 |
);
|
| 213 |
}
|
| 214 |
|
|
|
|
| 215 |
if (!user) {
|
| 216 |
return <LoginForm onLogin={setUser} />;
|
| 217 |
}
|
|
|
|
| 249 |
<div className="space-y-6 animate-fade-in-up">
|
| 250 |
<div className="text-center mb-4">
|
| 251 |
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
|
| 252 |
+
選擇學生 & 編輯提示詞
|
| 253 |
</h2>
|
| 254 |
<p className="text-[var(--color-text-muted)] mt-2">
|
| 255 |
+
勾選要生成報告的學生,編輯提示詞,預覽資料後生成報告
|
| 256 |
</p>
|
| 257 |
</div>
|
| 258 |
+
|
| 259 |
<ParsedDataSummary parsedData={parsedData} />
|
| 260 |
+
|
| 261 |
{students.length > 0 ? (
|
| 262 |
+
<>
|
| 263 |
+
<StudentSelector
|
| 264 |
+
students={students}
|
| 265 |
+
onSelectionChange={setSelectedIndices}
|
| 266 |
+
/>
|
| 267 |
+
{sessionId && (
|
| 268 |
+
<PromptEditor
|
| 269 |
+
promptContent={promptContent}
|
| 270 |
+
onPromptChange={setPromptContent}
|
| 271 |
+
sessionId={sessionId}
|
| 272 |
+
onGenerate={handleGenerateReports}
|
| 273 |
+
isGenerating={isGenerating}
|
| 274 |
+
selectedCount={selectedIndices.length}
|
| 275 |
+
parsedData={parsedData}
|
| 276 |
+
onParsedDataUpdate={handleParsedDataUpdate}
|
| 277 |
+
/>
|
| 278 |
+
)}
|
| 279 |
+
</>
|
| 280 |
) : (
|
| 281 |
<div className="card p-8 text-center">
|
| 282 |
<p className="text-[var(--color-text-muted)]">
|
chatkit/frontend/src/components/step2/PromptEditor.tsx
CHANGED
|
@@ -15,8 +15,9 @@ interface PromptEditorProps {
|
|
| 15 |
promptContent: string;
|
| 16 |
onPromptChange: (content: string) => void;
|
| 17 |
sessionId: number;
|
| 18 |
-
|
| 19 |
isGenerating: boolean;
|
|
|
|
| 20 |
parsedData: Record<string, unknown>;
|
| 21 |
onParsedDataUpdate: (dataType: string, data: unknown) => void;
|
| 22 |
}
|
|
@@ -25,8 +26,9 @@ export function PromptEditor({
|
|
| 25 |
promptContent,
|
| 26 |
onPromptChange,
|
| 27 |
sessionId,
|
| 28 |
-
|
| 29 |
isGenerating,
|
|
|
|
| 30 |
parsedData,
|
| 31 |
onParsedDataUpdate,
|
| 32 |
}: PromptEditorProps) {
|
|
@@ -165,19 +167,21 @@ export function PromptEditor({
|
|
| 165 |
<textarea
|
| 166 |
value={promptContent}
|
| 167 |
onChange={(e) => onPromptChange(e.target.value)}
|
| 168 |
-
rows={
|
| 169 |
className="input !rounded-xl font-mono text-sm leading-relaxed resize-y"
|
| 170 |
-
placeholder="輸入分析提示詞...
|
| 171 |
/>
|
| 172 |
|
| 173 |
{/* Preview button */}
|
| 174 |
<div className="text-center">
|
| 175 |
<button
|
| 176 |
onClick={() => setShowPreview(true)}
|
| 177 |
-
disabled={isGenerating ||
|
| 178 |
className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 179 |
>
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
</button>
|
| 182 |
</div>
|
| 183 |
|
|
@@ -188,7 +192,7 @@ export function PromptEditor({
|
|
| 188 |
onParsedDataUpdate={onParsedDataUpdate}
|
| 189 |
onGenerate={() => {
|
| 190 |
setShowPreview(false);
|
| 191 |
-
|
| 192 |
}}
|
| 193 |
onClose={() => setShowPreview(false)}
|
| 194 |
isGenerating={isGenerating}
|
|
|
|
| 15 |
promptContent: string;
|
| 16 |
onPromptChange: (content: string) => void;
|
| 17 |
sessionId: number;
|
| 18 |
+
onGenerate: (model: string) => void;
|
| 19 |
isGenerating: boolean;
|
| 20 |
+
selectedCount: number;
|
| 21 |
parsedData: Record<string, unknown>;
|
| 22 |
onParsedDataUpdate: (dataType: string, data: unknown) => void;
|
| 23 |
}
|
|
|
|
| 26 |
promptContent,
|
| 27 |
onPromptChange,
|
| 28 |
sessionId,
|
| 29 |
+
onGenerate,
|
| 30 |
isGenerating,
|
| 31 |
+
selectedCount,
|
| 32 |
parsedData,
|
| 33 |
onParsedDataUpdate,
|
| 34 |
}: PromptEditorProps) {
|
|
|
|
| 167 |
<textarea
|
| 168 |
value={promptContent}
|
| 169 |
onChange={(e) => onPromptChange(e.target.value)}
|
| 170 |
+
rows={10}
|
| 171 |
className="input !rounded-xl font-mono text-sm leading-relaxed resize-y"
|
| 172 |
+
placeholder="輸入分析提示詞..."
|
| 173 |
/>
|
| 174 |
|
| 175 |
{/* Preview button */}
|
| 176 |
<div className="text-center">
|
| 177 |
<button
|
| 178 |
onClick={() => setShowPreview(true)}
|
| 179 |
+
disabled={isGenerating || selectedCount === 0}
|
| 180 |
className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 181 |
>
|
| 182 |
+
{selectedCount === 0
|
| 183 |
+
? "請先選擇學生"
|
| 184 |
+
: `預覽分析資料(${selectedCount} 位學生)→`}
|
| 185 |
</button>
|
| 186 |
</div>
|
| 187 |
|
|
|
|
| 192 |
onParsedDataUpdate={onParsedDataUpdate}
|
| 193 |
onGenerate={() => {
|
| 194 |
setShowPreview(false);
|
| 195 |
+
onGenerate(model);
|
| 196 |
}}
|
| 197 |
onClose={() => setShowPreview(false)}
|
| 198 |
isGenerating={isGenerating}
|
chatkit/frontend/src/components/step2/StudentSelector.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
-
import { ModelSelector } from "../ModelSelector";
|
| 3 |
|
| 4 |
interface Student {
|
| 5 |
index: number;
|
|
@@ -9,19 +8,20 @@ interface Student {
|
|
| 9 |
|
| 10 |
interface StudentSelectorProps {
|
| 11 |
students: Student[];
|
| 12 |
-
|
| 13 |
-
isGenerating: boolean;
|
| 14 |
}
|
| 15 |
|
| 16 |
export function StudentSelector({
|
| 17 |
students,
|
| 18 |
-
|
| 19 |
-
isGenerating,
|
| 20 |
}: StudentSelectorProps) {
|
| 21 |
const [selected, setSelected] = useState<Set<number>>(
|
| 22 |
() => new Set(students.map((s) => s.index))
|
| 23 |
);
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
const toggleAll = () => {
|
| 27 |
if (selected.size === students.length) {
|
|
@@ -46,42 +46,40 @@ export function StudentSelector({
|
|
| 46 |
const allSelected = selected.size === students.length;
|
| 47 |
|
| 48 |
return (
|
| 49 |
-
<div className="space-y-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
</
|
| 63 |
-
<ModelSelector value={model} onChange={setModel} label="報告模型:" />
|
| 64 |
</div>
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
<div className="grid gap-2">
|
| 69 |
{students.map((s) => (
|
| 70 |
<label
|
| 71 |
key={s.index}
|
| 72 |
-
className={`flex items-center gap-3
|
| 73 |
selected.has(s.index)
|
| 74 |
-
? "bg-[var(--color-primary)]/10
|
| 75 |
-
: "hover:bg-[var(--color-surface)]
|
| 76 |
}`}
|
| 77 |
>
|
| 78 |
<input
|
| 79 |
type="checkbox"
|
| 80 |
checked={selected.has(s.index)}
|
| 81 |
onChange={() => toggle(s.index)}
|
| 82 |
-
className="w-
|
| 83 |
/>
|
| 84 |
-
<span className="font-
|
| 85 |
{s.name}
|
| 86 |
</span>
|
| 87 |
{s.id && (
|
|
@@ -93,24 +91,6 @@ export function StudentSelector({
|
|
| 93 |
))}
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
-
|
| 97 |
-
{/* Generate button */}
|
| 98 |
-
<div className="text-center">
|
| 99 |
-
<button
|
| 100 |
-
onClick={() => onGenerate(Array.from(selected).sort((a, b) => a - b), model)}
|
| 101 |
-
disabled={isGenerating || selected.size === 0}
|
| 102 |
-
className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 103 |
-
>
|
| 104 |
-
{isGenerating ? (
|
| 105 |
-
<span className="flex items-center gap-2">
|
| 106 |
-
<span className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
| 107 |
-
生成報告中...
|
| 108 |
-
</span>
|
| 109 |
-
) : (
|
| 110 |
-
`為 ${selected.size} 位學生生成報告 →`
|
| 111 |
-
)}
|
| 112 |
-
</button>
|
| 113 |
-
</div>
|
| 114 |
</div>
|
| 115 |
);
|
| 116 |
}
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
|
|
|
| 2 |
|
| 3 |
interface Student {
|
| 4 |
index: number;
|
|
|
|
| 8 |
|
| 9 |
interface StudentSelectorProps {
|
| 10 |
students: Student[];
|
| 11 |
+
onSelectionChange: (selectedIndices: number[]) => void;
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
export function StudentSelector({
|
| 15 |
students,
|
| 16 |
+
onSelectionChange,
|
|
|
|
| 17 |
}: StudentSelectorProps) {
|
| 18 |
const [selected, setSelected] = useState<Set<number>>(
|
| 19 |
() => new Set(students.map((s) => s.index))
|
| 20 |
);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
onSelectionChange(Array.from(selected).sort((a, b) => a - b));
|
| 24 |
+
}, [selected]);
|
| 25 |
|
| 26 |
const toggleAll = () => {
|
| 27 |
if (selected.size === students.length) {
|
|
|
|
| 46 |
const allSelected = selected.size === students.length;
|
| 47 |
|
| 48 |
return (
|
| 49 |
+
<div className="space-y-3">
|
| 50 |
+
<div className="flex items-center gap-3">
|
| 51 |
+
<h3 className="font-display text-sm font-semibold text-[var(--color-text)]">
|
| 52 |
+
選擇學生
|
| 53 |
+
</h3>
|
| 54 |
+
<button
|
| 55 |
+
onClick={toggleAll}
|
| 56 |
+
className="btn btn-outline text-xs !py-1 !px-3"
|
| 57 |
+
>
|
| 58 |
+
{allSelected ? "取消全選" : "全選"}
|
| 59 |
+
</button>
|
| 60 |
+
<span className="text-sm text-[var(--color-text-muted)]">
|
| 61 |
+
已選 {selected.size} / {students.length} 位學生
|
| 62 |
+
</span>
|
|
|
|
| 63 |
</div>
|
| 64 |
|
| 65 |
+
<div className="card p-4 max-h-64 overflow-y-auto">
|
| 66 |
+
<div className="grid gap-1.5 sm:grid-cols-2">
|
|
|
|
| 67 |
{students.map((s) => (
|
| 68 |
<label
|
| 69 |
key={s.index}
|
| 70 |
+
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
|
| 71 |
selected.has(s.index)
|
| 72 |
+
? "bg-[var(--color-primary)]/10"
|
| 73 |
+
: "hover:bg-[var(--color-surface)]"
|
| 74 |
}`}
|
| 75 |
>
|
| 76 |
<input
|
| 77 |
type="checkbox"
|
| 78 |
checked={selected.has(s.index)}
|
| 79 |
onChange={() => toggle(s.index)}
|
| 80 |
+
className="w-4 h-4 rounded accent-[var(--color-primary)]"
|
| 81 |
/>
|
| 82 |
+
<span className="text-sm font-medium text-[var(--color-text)]">
|
| 83 |
{s.name}
|
| 84 |
</span>
|
| 85 |
{s.id && (
|
|
|
|
| 91 |
))}
|
| 92 |
</div>
|
| 93 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
</div>
|
| 95 |
);
|
| 96 |
}
|