Features: - PatientWechatCallbackController for URL verification and message handling - PatientWechatService for template and customer messages - Support for secure mode (message encryption/decryption) - Simplified route /wechat/patient/callback for WeChat config - Event handlers for subscribe/unsubscribe/text messages - Template message for visit reminders Technical details: - Reuse @wecom/crypto for encryption (compatible with Official Account) - Relaxed Fastify schema validation to prevent early request blocking - Access token caching (7000s with 5min pre-refresh) - Comprehensive logging for debugging Testing: Local URL verification passed, ready for SAE deployment Status: Code complete, waiting for WeChat platform configuration
1316 lines
38 KiB
Markdown
1316 lines
38 KiB
Markdown
# 工具C Day 4-5 前端开发计划(最终版)
|
||
|
||
> **制定日期**: 2025-12-07
|
||
> **开发目标**: Tool C前端MVP - AI驱动的科研数据编辑器
|
||
> **预计工时**: 12-16小时(Day 4: 6-8h, Day 5: 6-8h)
|
||
> **核心目标**: 端到端AI数据清洗流程可用
|
||
|
||
---
|
||
|
||
## 🎯 核心决策
|
||
|
||
| 决策项 | 最终方案 | 理由 |
|
||
|--------|---------|------|
|
||
| **表格组件** | **AG Grid Community** | 用户强烈要求,Excel级体验 |
|
||
| **开发优先级** | **AI核心功能优先** | 先验证后端,UI细节后续优化 |
|
||
| **UI风格** | **严格还原原型V6** | Emerald绿色主题,符合Tool C定位 |
|
||
| **测试数据** | **真实医疗数据** | cqol-demo.csv (21列x300+行) |
|
||
| **Day 4-5目标** | **AI核心功能可用** | 上传→AI对话→执行→更新表格 |
|
||
| **代码风格** | **同Tool B标准** | TypeScript + Tailwind + Lucide |
|
||
|
||
---
|
||
|
||
## 📦 技术栈
|
||
|
||
### 核心依赖
|
||
```json
|
||
{
|
||
"ag-grid-community": "^31.0.0", // 表格核心
|
||
"ag-grid-react": "^31.0.0", // React集成
|
||
"react": "^18.2.0",
|
||
"react-router-dom": "^6.x",
|
||
"lucide-react": "^0.x", // 图标库
|
||
"axios": "^1.x" // HTTP客户端
|
||
}
|
||
```
|
||
|
||
### 技术选型
|
||
- **React 18**: Hooks + TypeScript
|
||
- **Tailwind CSS**: 原子化CSS
|
||
- **AG Grid Community**: 开源版,功能足够MVP
|
||
- **Prism.js**: 代码语法高亮
|
||
- **React Markdown**: Markdown渲染(AI回复)
|
||
|
||
---
|
||
|
||
## 📐 页面布局设计
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────┐
|
||
│ Header (h-14) │
|
||
│ [🟢 科研数据编辑器] lung_cancer.csv [撤销/重做] [导出] │
|
||
└────────────────────────────────────────────────────────────┘
|
||
┌──────────────────────────┬─────────────────────────────────┐
|
||
│ Left Panel (flex-1) │ Right Sidebar (w-[420px]) │
|
||
│ │ │
|
||
│ ┌──────────────────────┐ │ ┌───────────────────────────┐ │
|
||
│ │ Toolbar (h-16) │ │ │ Tab: [Chat] [Insights] │ │
|
||
│ │ 7个快捷按钮 + 搜索 │ │ └───────────────────────────┘ │
|
||
│ └──────────────────────┘ │ │
|
||
│ │ ┌───────────────────────────┐ │
|
||
│ ┌──────────────────────┐ │ │ Chat Messages Area │ │
|
||
│ │ │ │ │ - 用户消息 │ │
|
||
│ │ AG Grid Table │ │ │ - AI消息(含代码块) │ │
|
||
│ │ (Excel风格) │ │ │ - 系统消息 │ │
|
||
│ │ │ │ │ │ │
|
||
│ │ 21列 x 300+行 │ │ └───────────────────────────┘ │
|
||
│ │ │ │ │
|
||
│ │ 支持: │ │ ┌───────────────────────────┐ │
|
||
│ │ - 列排序 │ │ │ Input + Send Button │ │
|
||
│ │ - 列宽调整 │ │ └───────────────────────────┘ │
|
||
│ │ - 单元格编辑(后期) │ │ │
|
||
│ │ - 选择高亮 │ │ │
|
||
│ └──────────────────────┘ │ │
|
||
└──────────────────────────┴─────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 🗂️ 文件结构规划
|
||
|
||
```
|
||
frontend-v2/src/modules/dc/pages/tool-c/
|
||
├── index.tsx # 主入口(状态管理+布局)
|
||
├── components/
|
||
│ ├── Header.tsx # 顶部栏
|
||
│ ├── Toolbar.tsx # 工具栏(7个按钮)
|
||
│ ├── DataGrid.tsx # AG Grid表格(核心)
|
||
│ ├── Sidebar.tsx # 右侧栏容器
|
||
│ ├── ChatPanel.tsx # Chat面板
|
||
│ ├── InsightsPanel.tsx # Insights面板
|
||
│ ├── MessageList.tsx # 消息列表
|
||
│ ├── MessageItem.tsx # 单条消息
|
||
│ ├── CodeBlock.tsx # 代码块渲染
|
||
│ └── InputArea.tsx # 输入框
|
||
├── hooks/
|
||
│ ├── useToolC.ts # 核心状态管理Hook
|
||
│ ├── useSession.ts # Session管理
|
||
│ ├── useChat.ts # AI对话逻辑
|
||
│ └── useDataGrid.ts # 表格数据管理
|
||
└── types/
|
||
└── index.ts # TypeScript类型定义
|
||
|
||
frontend-v2/src/modules/dc/api/
|
||
└── toolC.ts # API封装(6个方法)
|
||
```
|
||
|
||
---
|
||
|
||
## ⏱️ Day 4 开发计划(6-8小时)
|
||
|
||
### 阶段1:项目初始化(1小时)
|
||
|
||
#### 1.1 安装依赖
|
||
```bash
|
||
cd frontend-v2
|
||
npm install ag-grid-community ag-grid-react
|
||
npm install prismjs @types/prismjs
|
||
npm install react-markdown
|
||
```
|
||
|
||
#### 1.2 创建文件结构
|
||
```bash
|
||
mkdir -p src/modules/dc/pages/tool-c/{components,hooks,types}
|
||
touch src/modules/dc/pages/tool-c/index.tsx
|
||
# ... 创建其他文件
|
||
```
|
||
|
||
#### 1.3 更新路由配置
|
||
```typescript
|
||
// src/App.tsx 或路由配置文件
|
||
<Route path="/data-cleaning/tool-c" element={<ToolC />} />
|
||
```
|
||
|
||
#### 1.4 更新Portal页面
|
||
```typescript
|
||
// src/modules/dc/pages/Portal.tsx
|
||
// 将Tool C的status从'disabled'改为'ready'
|
||
{
|
||
id: 'tool-c',
|
||
title: '科研数据编辑器',
|
||
status: 'ready', // ⭐ 修改这里
|
||
route: '/data-cleaning/tool-c'
|
||
}
|
||
```
|
||
|
||
**交付物**:
|
||
- ✅ 依赖安装完成
|
||
- ✅ 文件结构创建
|
||
- ✅ Portal可点击进入Tool C
|
||
|
||
---
|
||
|
||
### 阶段2:页面框架搭建(2小时)
|
||
|
||
#### 2.1 创建主入口 (index.tsx)
|
||
```typescript
|
||
import { useState } from 'react';
|
||
import Header from './components/Header';
|
||
import Toolbar from './components/Toolbar';
|
||
import DataGrid from './components/DataGrid';
|
||
import Sidebar from './components/Sidebar';
|
||
|
||
interface ToolCState {
|
||
sessionId: string | null;
|
||
fileName: string;
|
||
data: any[];
|
||
columns: any[];
|
||
messages: any[];
|
||
isLoading: boolean;
|
||
}
|
||
|
||
const ToolC = () => {
|
||
const [state, setState] = useState<ToolCState>({
|
||
sessionId: null,
|
||
fileName: 'cqol-demo.csv',
|
||
data: [],
|
||
columns: [],
|
||
messages: [],
|
||
isLoading: false,
|
||
});
|
||
|
||
return (
|
||
<div className="h-screen w-screen flex flex-col bg-slate-50">
|
||
<Header fileName={state.fileName} />
|
||
<div className="flex-1 flex overflow-hidden">
|
||
<div className="flex-1 flex flex-col">
|
||
<Toolbar />
|
||
<div className="flex-1 overflow-auto p-6">
|
||
<DataGrid data={state.data} columns={state.columns} />
|
||
</div>
|
||
</div>
|
||
<Sidebar messages={state.messages} />
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ToolC;
|
||
```
|
||
|
||
#### 2.2 创建Header组件
|
||
```typescript
|
||
// components/Header.tsx
|
||
import { Table2, Undo2, Redo2, Download, ArrowLeft } from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
interface HeaderProps {
|
||
fileName: string;
|
||
onUndo?: () => void;
|
||
onRedo?: () => void;
|
||
onExport?: () => void;
|
||
}
|
||
|
||
const Header: React.FC<HeaderProps> = ({ fileName, onUndo, onRedo, onExport }) => {
|
||
const navigate = useNavigate();
|
||
|
||
return (
|
||
<header className="bg-white border-b border-slate-200 h-14 flex-none flex items-center justify-between px-4 z-20 shadow-sm">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => navigate('/data-cleaning')}
|
||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 text-slate-600"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
<span className="text-sm">返回</span>
|
||
</button>
|
||
<div className="h-4 w-px bg-slate-300"></div>
|
||
<div className="w-8 h-8 bg-emerald-600 rounded-lg flex items-center justify-center text-white shadow-md">
|
||
<Table2 size={20} />
|
||
</div>
|
||
<span className="font-bold text-lg text-slate-900">
|
||
科研数据编辑器
|
||
<span className="text-emerald-600 text-xs px-1.5 py-0.5 bg-emerald-50 rounded-full ml-1">
|
||
Pro
|
||
</span>
|
||
</span>
|
||
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
|
||
<span className="text-xs text-slate-500 font-mono">{fileName}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center bg-slate-100 rounded-lg p-1">
|
||
<button
|
||
onClick={onUndo}
|
||
className="p-1.5 text-slate-400 hover:text-slate-700 rounded-md hover:bg-white transition-all"
|
||
disabled
|
||
>
|
||
<Undo2 size={16} />
|
||
</button>
|
||
<button
|
||
onClick={onRedo}
|
||
className="p-1.5 text-slate-400 hover:text-slate-700 rounded-md hover:bg-white transition-all"
|
||
disabled
|
||
>
|
||
<Redo2 size={16} />
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={onExport}
|
||
className="flex items-center gap-2 px-4 py-1.5 bg-slate-900 text-white rounded-lg text-xs font-medium hover:bg-slate-800 transition-all shadow-md"
|
||
>
|
||
<Download size={12} /> 导出结果
|
||
</button>
|
||
</div>
|
||
</header>
|
||
);
|
||
};
|
||
|
||
export default Header;
|
||
```
|
||
|
||
#### 2.3 创建Toolbar组件
|
||
```typescript
|
||
// components/Toolbar.tsx
|
||
import { Calculator, CalendarClock, ArrowLeftRight, FileSearch, Wand2, Filter, Search } from 'lucide-react';
|
||
|
||
const ToolbarButton = ({ icon: Icon, label, colorClass }: any) => (
|
||
<button className={`flex flex-col items-center justify-center w-20 h-14 rounded-lg transition-all hover:shadow-sm ${colorClass}`}>
|
||
<Icon className="w-5 h-5 mb-1" />
|
||
<span className="text-[10px] font-medium">{label}</span>
|
||
</button>
|
||
);
|
||
|
||
const Toolbar = () => {
|
||
return (
|
||
<div className="bg-white border-b border-slate-200 px-4 py-2 flex items-center gap-1 overflow-x-auto flex-none shadow-sm z-10">
|
||
<ToolbarButton icon={Calculator} label="生成新变量" colorClass="text-emerald-600 bg-emerald-50 hover:bg-emerald-100" />
|
||
<ToolbarButton icon={CalendarClock} label="时间差" colorClass="text-blue-600 bg-blue-50 hover:bg-blue-100" />
|
||
<ToolbarButton icon={ArrowLeftRight} label="横纵转换" colorClass="text-cyan-600 bg-cyan-50 hover:bg-cyan-100" />
|
||
<div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
|
||
<ToolbarButton icon={FileSearch} label="查重" colorClass="text-orange-600 bg-orange-50 hover:bg-orange-100" />
|
||
<ToolbarButton icon={Wand2} label="多重插补" colorClass="text-rose-600 bg-rose-50 hover:bg-rose-100" />
|
||
<div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
|
||
<ToolbarButton icon={Filter} label="筛选分析集" colorClass="text-indigo-600 bg-indigo-50 hover:bg-indigo-100" />
|
||
<div className="flex-1"></div>
|
||
<div className="relative">
|
||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
className="pl-9 pr-4 py-1.5 text-sm bg-slate-100 border-none rounded-full w-48 focus:w-64 transition-all outline-none focus:ring-2 focus:ring-emerald-500/20"
|
||
placeholder="搜索值..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Toolbar;
|
||
```
|
||
|
||
**交付物**:
|
||
- ✅ Header完成(带返回按钮)
|
||
- ✅ Toolbar完成(7个按钮)
|
||
- ✅ 基础布局可见
|
||
|
||
---
|
||
|
||
### 阶段3:AG Grid集成(3-4小时)
|
||
|
||
#### 3.1 创建DataGrid组件
|
||
```typescript
|
||
// components/DataGrid.tsx
|
||
import { AgGridReact } from 'ag-grid-react';
|
||
import { ColDef } from 'ag-grid-community';
|
||
import 'ag-grid-community/styles/ag-grid.css';
|
||
import 'ag-grid-community/styles/ag-theme-alpine.css';
|
||
import { useMemo } from 'react';
|
||
|
||
interface DataGridProps {
|
||
data: any[];
|
||
columns: Array<{ id: string; name: string; type?: string }>;
|
||
onCellValueChanged?: (params: any) => void;
|
||
}
|
||
|
||
const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }) => {
|
||
// 将列定义转换为AG Grid格式
|
||
const columnDefs: ColDef[] = useMemo(() => {
|
||
return columns.map(col => ({
|
||
field: col.id,
|
||
headerName: col.name,
|
||
sortable: true,
|
||
filter: true,
|
||
resizable: true,
|
||
editable: false, // MVP阶段暂不支持编辑
|
||
width: 120,
|
||
minWidth: 80,
|
||
// 根据类型设置样式
|
||
cellClass: (params) => {
|
||
if (params.value === null || params.value === undefined || params.value === '') {
|
||
return 'bg-red-50 text-red-400 italic';
|
||
}
|
||
return '';
|
||
},
|
||
}));
|
||
}, [columns]);
|
||
|
||
// 默认列配置
|
||
const defaultColDef: ColDef = {
|
||
flex: 0,
|
||
sortable: true,
|
||
filter: true,
|
||
resizable: true,
|
||
};
|
||
|
||
// 如果没有数据,显示空状态
|
||
if (data.length === 0) {
|
||
return (
|
||
<div className="bg-white border border-slate-200 shadow-sm rounded-xl p-12 text-center">
|
||
<div className="text-slate-400 text-sm">
|
||
<p className="mb-2">暂无数据</p>
|
||
<p>请在右侧AI助手中上传文件</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white border border-slate-200 shadow-sm rounded-xl overflow-hidden h-full">
|
||
<div className="ag-theme-alpine h-full">
|
||
<AgGridReact
|
||
rowData={data}
|
||
columnDefs={columnDefs}
|
||
defaultColDef={defaultColDef}
|
||
animateRows={true}
|
||
rowSelection="multiple"
|
||
onCellValueChanged={onCellValueChanged}
|
||
domLayout="normal"
|
||
suppressCellFocus={false}
|
||
enableCellTextSelection={true}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default DataGrid;
|
||
```
|
||
|
||
#### 3.2 自定义AG Grid样式
|
||
```css
|
||
/* 在全局CSS或组件内添加 */
|
||
.ag-theme-alpine {
|
||
--ag-header-background-color: #f8fafc;
|
||
--ag-header-foreground-color: #475569;
|
||
--ag-border-color: #e2e8f0;
|
||
--ag-row-hover-color: #f0fdf4;
|
||
--ag-selected-row-background-color: #d1fae5;
|
||
--ag-font-family: inherit;
|
||
--ag-font-size: 13px;
|
||
}
|
||
|
||
.ag-theme-alpine .ag-header-cell {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.ag-theme-alpine .ag-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
```
|
||
|
||
#### 3.3 测试数据加载
|
||
```typescript
|
||
// 临时测试:在index.tsx中硬编码测试数据
|
||
const mockData = [
|
||
{ id: 'P001', age: 27, sex: '女', bmi: 23.1, smoke: '否' },
|
||
{ id: 'P002', age: 24, sex: '男', bmi: 16.7, smoke: '是' },
|
||
{ id: 'P003', age: null, sex: '男', bmi: null, smoke: '是' },
|
||
];
|
||
|
||
const mockColumns = [
|
||
{ id: 'id', name: '病人ID', type: 'text' },
|
||
{ id: 'age', name: '年龄', type: 'number' },
|
||
{ id: 'sex', name: '性别', type: 'category' },
|
||
{ id: 'bmi', name: 'BMI', type: 'number' },
|
||
{ id: 'smoke', name: '吸烟史', type: 'category' },
|
||
];
|
||
```
|
||
|
||
**交付物**:
|
||
- ✅ AG Grid成功渲染
|
||
- ✅ 缺失值高亮显示
|
||
- ✅ 列排序/过滤可用
|
||
- ✅ 列宽可调整
|
||
|
||
---
|
||
|
||
## ⏱️ Day 5 开发计划(6-8小时)
|
||
|
||
### 阶段4:AI Chat面板(3小时)
|
||
|
||
#### 4.1 创建Sidebar组件
|
||
```typescript
|
||
// components/Sidebar.tsx
|
||
import { useState } from 'react';
|
||
import { MessageSquare, Lightbulb, X } from 'lucide-react';
|
||
import ChatPanel from './ChatPanel';
|
||
import InsightsPanel from './InsightsPanel';
|
||
|
||
interface SidebarProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
messages: any[];
|
||
onSendMessage: (message: string) => void;
|
||
dataStats?: any;
|
||
}
|
||
|
||
const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, messages, onSendMessage, dataStats }) => {
|
||
const [activeTab, setActiveTab] = useState<'chat' | 'insights'>('chat');
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="w-[420px] bg-white border-l border-slate-200 flex flex-col">
|
||
{/* Tab Header */}
|
||
<div className="h-14 border-b border-slate-200 flex items-center justify-between px-4">
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => setActiveTab('chat')}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
activeTab === 'chat'
|
||
? 'bg-emerald-50 text-emerald-700'
|
||
: 'text-slate-600 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
<MessageSquare size={16} />
|
||
AI助手
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('insights')}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||
activeTab === 'insights'
|
||
? 'bg-emerald-50 text-emerald-700'
|
||
: 'text-slate-600 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
<Lightbulb size={16} />
|
||
数据洞察
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-1 rounded-md hover:bg-slate-100 text-slate-400 hover:text-slate-600"
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
<div className="flex-1 overflow-hidden">
|
||
{activeTab === 'chat' ? (
|
||
<ChatPanel messages={messages} onSendMessage={onSendMessage} />
|
||
) : (
|
||
<InsightsPanel dataStats={dataStats} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Sidebar;
|
||
```
|
||
|
||
#### 4.2 创建ChatPanel组件
|
||
```typescript
|
||
// components/ChatPanel.tsx
|
||
import { useRef, useEffect } from 'react';
|
||
import MessageItem from './MessageItem';
|
||
import InputArea from './InputArea';
|
||
|
||
interface ChatPanelProps {
|
||
messages: any[];
|
||
onSendMessage: (message: string) => void;
|
||
isLoading?: boolean;
|
||
}
|
||
|
||
const ChatPanel: React.FC<ChatPanelProps> = ({ messages, onSendMessage, isLoading }) => {
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Messages Area */}
|
||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||
{messages.length === 0 && (
|
||
<div className="text-center text-slate-400 text-sm py-12">
|
||
<p className="mb-2">👋 您好!我是您的AI数据分析师</p>
|
||
<p>试试说:"把年龄大于60的标记为老年组"</p>
|
||
</div>
|
||
)}
|
||
{messages.map((msg) => (
|
||
<MessageItem key={msg.id} message={msg} />
|
||
))}
|
||
{isLoading && (
|
||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-emerald-500 border-t-transparent"></div>
|
||
<span>AI正在思考...</span>
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* Input Area */}
|
||
<InputArea onSend={onSendMessage} disabled={isLoading} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ChatPanel;
|
||
```
|
||
|
||
#### 4.3 创建MessageItem组件
|
||
```typescript
|
||
// components/MessageItem.tsx
|
||
import { User, Bot, CheckCircle2, XCircle, Play } from 'lucide-react';
|
||
import CodeBlock from './CodeBlock';
|
||
|
||
interface MessageItemProps {
|
||
message: {
|
||
id: string;
|
||
role: 'user' | 'assistant' | 'system';
|
||
content: string;
|
||
code?: {
|
||
content: string;
|
||
status: 'pending' | 'running' | 'success' | 'error';
|
||
};
|
||
onExecute?: () => void;
|
||
};
|
||
}
|
||
|
||
const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
|
||
const { role, content, code } = message;
|
||
|
||
// 系统消息
|
||
if (role === 'system') {
|
||
return (
|
||
<div className="text-center">
|
||
<div className="inline-block bg-slate-100 text-slate-600 text-xs px-3 py-1 rounded-full">
|
||
{content}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 用户消息
|
||
if (role === 'user') {
|
||
return (
|
||
<div className="flex items-start gap-3 justify-end">
|
||
<div className="bg-emerald-600 text-white px-4 py-2 rounded-2xl rounded-tr-sm max-w-[80%] text-sm">
|
||
{content}
|
||
</div>
|
||
<div className="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center flex-shrink-0">
|
||
<User size={16} className="text-slate-600" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// AI消息
|
||
return (
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||
<Bot size={16} className="text-emerald-600" />
|
||
</div>
|
||
<div className="flex-1 space-y-2">
|
||
<div className="bg-slate-50 text-slate-800 px-4 py-2 rounded-2xl rounded-tl-sm text-sm">
|
||
{content}
|
||
</div>
|
||
{code && (
|
||
<div className="space-y-2">
|
||
<CodeBlock code={code.content} language="python" />
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={message.onExecute}
|
||
disabled={code.status === 'running'}
|
||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||
code.status === 'pending'
|
||
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||
: code.status === 'running'
|
||
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
|
||
: code.status === 'success'
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-red-100 text-red-700'
|
||
}`}
|
||
>
|
||
{code.status === 'pending' && (
|
||
<>
|
||
<Play size={12} /> 执行代码
|
||
</>
|
||
)}
|
||
{code.status === 'running' && '执行中...'}
|
||
{code.status === 'success' && (
|
||
<>
|
||
<CheckCircle2 size={12} /> 执行成功
|
||
</>
|
||
)}
|
||
{code.status === 'error' && (
|
||
<>
|
||
<XCircle size={12} /> 执行失败
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MessageItem;
|
||
```
|
||
|
||
#### 4.4 创建CodeBlock组件
|
||
```typescript
|
||
// components/CodeBlock.tsx
|
||
import { Copy, Check } from 'lucide-react';
|
||
import { useState } from 'react';
|
||
import Prism from 'prismjs';
|
||
import 'prismjs/themes/prism-tomorrow.css';
|
||
import 'prismjs/components/prism-python';
|
||
|
||
interface CodeBlockProps {
|
||
code: string;
|
||
language?: string;
|
||
}
|
||
|
||
const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = 'python' }) => {
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(code);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
const highlightedCode = Prism.highlight(
|
||
code,
|
||
Prism.languages[language] || Prism.languages.python,
|
||
language
|
||
);
|
||
|
||
return (
|
||
<div className="relative bg-slate-900 rounded-lg overflow-hidden">
|
||
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
||
<span className="text-slate-400 text-xs font-mono">{language}</span>
|
||
<button
|
||
onClick={handleCopy}
|
||
className="flex items-center gap-1 text-slate-400 hover:text-white text-xs transition-colors"
|
||
>
|
||
{copied ? (
|
||
<>
|
||
<Check size={12} /> 已复制
|
||
</>
|
||
) : (
|
||
<>
|
||
<Copy size={12} /> 复制
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
<pre className="p-4 overflow-x-auto text-xs">
|
||
<code
|
||
className={`language-${language}`}
|
||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||
/>
|
||
</pre>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CodeBlock;
|
||
```
|
||
|
||
#### 4.5 创建InputArea组件
|
||
```typescript
|
||
// components/InputArea.tsx
|
||
import { Send } from 'lucide-react';
|
||
import { useState, KeyboardEvent } from 'react';
|
||
|
||
interface InputAreaProps {
|
||
onSend: (message: string) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
const InputArea: React.FC<InputAreaProps> = ({ onSend, disabled }) => {
|
||
const [input, setInput] = useState('');
|
||
|
||
const handleSend = () => {
|
||
if (!input.trim() || disabled) return;
|
||
onSend(input);
|
||
setInput('');
|
||
};
|
||
|
||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="border-t border-slate-200 p-4">
|
||
<div className="flex gap-2">
|
||
<textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="告诉AI你想做什么..."
|
||
className="flex-1 resize-none border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
|
||
rows={3}
|
||
disabled={disabled}
|
||
/>
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={!input.trim() || disabled}
|
||
className="bg-emerald-600 text-white p-3 rounded-lg hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
<Send size={18} />
|
||
</button>
|
||
</div>
|
||
<div className="text-xs text-slate-400 mt-2">
|
||
按 <kbd className="px-1 py-0.5 bg-slate-100 rounded">Enter</kbd> 发送,
|
||
<kbd className="px-1 py-0.5 bg-slate-100 rounded">Shift + Enter</kbd> 换行
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default InputArea;
|
||
```
|
||
|
||
**交付物**:
|
||
- ✅ Chat面板完整UI
|
||
- ✅ 消息列表渲染
|
||
- ✅ 代码块语法高亮
|
||
- ✅ 输入框交互
|
||
|
||
---
|
||
|
||
### 阶段5:API集成(2-3小时)
|
||
|
||
#### 5.1 创建API封装
|
||
```typescript
|
||
// api/toolC.ts
|
||
import axios from 'axios';
|
||
|
||
const BASE_URL = '/api/v1/dc/tool-c';
|
||
|
||
// Session管理
|
||
export const uploadFile = async (file: File) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const response = await axios.post(`${BASE_URL}/sessions/upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
|
||
return response.data;
|
||
};
|
||
|
||
export const getSession = async (sessionId: string) => {
|
||
const response = await axios.get(`${BASE_URL}/sessions/${sessionId}`);
|
||
return response.data;
|
||
};
|
||
|
||
export const getPreviewData = async (sessionId: string) => {
|
||
const response = await axios.get(`${BASE_URL}/sessions/${sessionId}/preview`);
|
||
return response.data;
|
||
};
|
||
|
||
export const updateHeartbeat = async (sessionId: string) => {
|
||
const response = await axios.post(`${BASE_URL}/sessions/${sessionId}/heartbeat`);
|
||
return response.data;
|
||
};
|
||
|
||
// AI功能
|
||
export const generateCode = async (sessionId: string, message: string) => {
|
||
const response = await axios.post(`${BASE_URL}/ai/generate`, {
|
||
sessionId,
|
||
message,
|
||
});
|
||
return response.data;
|
||
};
|
||
|
||
export const executeCode = async (sessionId: string, code: string, messageId: string) => {
|
||
const response = await axios.post(`${BASE_URL}/ai/execute`, {
|
||
sessionId,
|
||
code,
|
||
messageId,
|
||
});
|
||
return response.data;
|
||
};
|
||
|
||
export const processMessage = async (sessionId: string, message: string) => {
|
||
const response = await axios.post(`${BASE_URL}/ai/process`, {
|
||
sessionId,
|
||
message,
|
||
maxRetries: 3,
|
||
});
|
||
return response.data;
|
||
};
|
||
|
||
export const getChatHistory = async (sessionId: string, limit = 10) => {
|
||
const response = await axios.get(`${BASE_URL}/ai/history/${sessionId}?limit=${limit}`);
|
||
return response.data;
|
||
};
|
||
```
|
||
|
||
#### 5.2 创建核心Hook
|
||
```typescript
|
||
// hooks/useToolC.ts
|
||
import { useState, useCallback } from 'react';
|
||
import * as api from '../../api/toolC';
|
||
|
||
export const useToolC = () => {
|
||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||
const [fileName, setFileName] = useState('');
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [columns, setColumns] = useState<any[]>([]);
|
||
const [messages, setMessages] = useState<any[]>([]);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
|
||
// 上传文件
|
||
const handleFileUpload = useCallback(async (file: File) => {
|
||
try {
|
||
setIsLoading(true);
|
||
const result = await api.uploadFile(file);
|
||
|
||
if (result.success) {
|
||
setSessionId(result.data.sessionId);
|
||
setFileName(file.name);
|
||
|
||
// 获取预览数据
|
||
const preview = await api.getPreviewData(result.data.sessionId);
|
||
if (preview.success) {
|
||
setData(preview.data.rows);
|
||
setColumns(preview.data.columns.map((col: string) => ({
|
||
id: col,
|
||
name: col,
|
||
type: 'text',
|
||
})));
|
||
}
|
||
|
||
// 添加欢迎消息
|
||
setMessages([{
|
||
id: Date.now(),
|
||
role: 'system',
|
||
content: `✅ 文件上传成功!共${preview.data.totalRows}行 x ${preview.data.totalCols}列`,
|
||
}]);
|
||
}
|
||
} catch (error: any) {
|
||
console.error('上传失败:', error);
|
||
setMessages([{
|
||
id: Date.now(),
|
||
role: 'system',
|
||
content: `❌ 上传失败: ${error.message}`,
|
||
}]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// 发送AI消息(一步到位:生成+执行)
|
||
const handleSendMessage = useCallback(async (message: string) => {
|
||
if (!sessionId) {
|
||
alert('请先上传文件');
|
||
return;
|
||
}
|
||
|
||
// 添加用户消息
|
||
setMessages(prev => [...prev, {
|
||
id: Date.now(),
|
||
role: 'user',
|
||
content: message,
|
||
}]);
|
||
|
||
try {
|
||
setIsLoading(true);
|
||
|
||
// 调用process接口(生成+执行,一步到位)
|
||
const result = await api.processMessage(sessionId, message);
|
||
|
||
if (result.success) {
|
||
// 添加AI消息(带代码和执行结果)
|
||
setMessages(prev => [...prev, {
|
||
id: Date.now() + 1,
|
||
role: 'assistant',
|
||
content: result.data.explanation,
|
||
code: {
|
||
content: result.data.code,
|
||
status: result.data.executeResult.success ? 'success' : 'error',
|
||
},
|
||
}]);
|
||
|
||
// 如果执行成功,更新表格数据
|
||
if (result.data.executeResult.success && result.data.executeResult.newDataPreview) {
|
||
setData(result.data.executeResult.newDataPreview);
|
||
|
||
setMessages(prev => [...prev, {
|
||
id: Date.now() + 2,
|
||
role: 'system',
|
||
content: `✅ 代码执行成功!表格已更新。${result.data.retryCount > 0 ? `(重试${result.data.retryCount}次后成功)` : ''}`,
|
||
}]);
|
||
} else if (!result.data.executeResult.success) {
|
||
setMessages(prev => [...prev, {
|
||
id: Date.now() + 2,
|
||
role: 'system',
|
||
content: `❌ 执行失败: ${result.data.executeResult.error}`,
|
||
}]);
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
console.error('AI处理失败:', error);
|
||
setMessages(prev => [...prev, {
|
||
id: Date.now() + 1,
|
||
role: 'system',
|
||
content: `❌ 处理失败: ${error.response?.data?.error || error.message}`,
|
||
}]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [sessionId]);
|
||
|
||
return {
|
||
sessionId,
|
||
fileName,
|
||
data,
|
||
columns,
|
||
messages,
|
||
isLoading,
|
||
handleFileUpload,
|
||
handleSendMessage,
|
||
};
|
||
};
|
||
```
|
||
|
||
#### 5.3 在主组件中使用Hook
|
||
```typescript
|
||
// index.tsx (更新)
|
||
import { useToolC } from './hooks/useToolC';
|
||
|
||
const ToolC = () => {
|
||
const {
|
||
fileName,
|
||
data,
|
||
columns,
|
||
messages,
|
||
isLoading,
|
||
handleFileUpload,
|
||
handleSendMessage,
|
||
} = useToolC();
|
||
|
||
return (
|
||
<div className="h-screen w-screen flex flex-col bg-slate-50">
|
||
<Header fileName={fileName} />
|
||
<div className="flex-1 flex overflow-hidden">
|
||
<div className="flex-1 flex flex-col">
|
||
<Toolbar />
|
||
<div className="flex-1 overflow-auto p-6">
|
||
<DataGrid data={data} columns={columns} />
|
||
</div>
|
||
</div>
|
||
<Sidebar
|
||
isOpen={true}
|
||
onClose={() => {}}
|
||
messages={messages}
|
||
onSendMessage={handleSendMessage}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
**交付物**:
|
||
- ✅ 6个API方法封装
|
||
- ✅ useToolC核心Hook
|
||
- ✅ 端到端流程可用
|
||
|
||
---
|
||
|
||
### 阶段6:文件上传 + InsightsPanel(1小时)
|
||
|
||
#### 6.1 添加文件上传UI
|
||
```typescript
|
||
// 在ChatPanel中添加上传区域(messages为空时显示)
|
||
{messages.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<div className="mb-6">
|
||
<input
|
||
type="file"
|
||
accept=".csv,.xlsx,.xls"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) handleFileUpload(file);
|
||
}}
|
||
className="hidden"
|
||
id="file-upload"
|
||
/>
|
||
<label
|
||
htmlFor="file-upload"
|
||
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg cursor-pointer hover:bg-emerald-700 transition-all"
|
||
>
|
||
<Upload size={18} />
|
||
上传CSV/Excel文件
|
||
</label>
|
||
</div>
|
||
<p className="text-slate-400 text-sm">
|
||
支持格式:CSV, XLSX, XLS(最大10MB)
|
||
</p>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
#### 6.2 创建InsightsPanel组件
|
||
```typescript
|
||
// components/InsightsPanel.tsx
|
||
import { BarChart3, AlertCircle, Database, Layers } from 'lucide-react';
|
||
|
||
interface InsightsPanelProps {
|
||
dataStats?: {
|
||
totalRows: number;
|
||
totalCols: number;
|
||
missingCount: number;
|
||
missingRate: number;
|
||
};
|
||
}
|
||
|
||
const InsightsPanel: React.FC<InsightsPanelProps> = ({ dataStats }) => {
|
||
if (!dataStats) {
|
||
return (
|
||
<div className="p-6 text-center text-slate-400">
|
||
<p>暂无数据</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="p-4 space-y-4 overflow-y-auto">
|
||
<div className="bg-slate-50 rounded-lg p-4">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Database size={16} className="text-slate-600" />
|
||
<h3 className="font-semibold text-sm text-slate-900">数据规模</h3>
|
||
</div>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-600">总行数</span>
|
||
<span className="font-medium">{dataStats.totalRows}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-600">总列数</span>
|
||
<span className="font-medium">{dataStats.totalCols}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-red-50 rounded-lg p-4">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<AlertCircle size={16} className="text-red-600" />
|
||
<h3 className="font-semibold text-sm text-red-900">缺失值</h3>
|
||
</div>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-red-700">缺失数量</span>
|
||
<span className="font-medium text-red-900">{dataStats.missingCount}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-red-700">缺失率</span>
|
||
<span className="font-medium text-red-900">{dataStats.missingRate.toFixed(1)}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default InsightsPanel;
|
||
```
|
||
|
||
**交付物**:
|
||
- ✅ 文件上传功能
|
||
- ✅ Insights面板
|
||
|
||
---
|
||
|
||
## 🎯 Day 4-5 验收标准
|
||
|
||
### 必达目标(MVP)
|
||
- [x] **页面可访问**:Portal可点击进入Tool C
|
||
- [x] **文件上传**:支持CSV/Excel上传
|
||
- [x] **表格展示**:AG Grid渲染真实数据(cqol-demo.csv)
|
||
- [x] **AI对话**:右侧Chat面板可发送消息
|
||
- [x] **代码生成**:AI生成Python代码并显示
|
||
- [x] **代码执行**:点击执行按钮,表格数据更新
|
||
- [x] **对话历史**:多轮对话正常显示
|
||
|
||
### 可选目标(Day 6优化)
|
||
- [ ] 单元格手动编辑
|
||
- [ ] 工具栏按钮功能
|
||
- [ ] 撤销/重做
|
||
- [ ] 导出Excel
|
||
- [ ] 数据洞察卡片完善
|
||
- [ ] 响应式布局
|
||
|
||
---
|
||
|
||
## 📝 测试场景
|
||
|
||
### 场景1:上传文件
|
||
1. 访问 `/data-cleaning/tool-c`
|
||
2. 点击"上传CSV/Excel文件"
|
||
3. 选择 `cqol-demo.csv`
|
||
4. 验证:表格显示21列x300+行数据
|
||
|
||
### 场景2:AI缺失值处理
|
||
1. 在Chat输入:"把sex列的缺失值填补为众数"
|
||
2. 验证:AI生成代码
|
||
3. 点击"执行代码"
|
||
4. 验证:表格数据更新,缺失值消失
|
||
|
||
### 场景3:AI年龄分组
|
||
1. 在Chat输入:"把age列按18、60分为未成年、成年、老年三组"
|
||
2. 验证:AI生成代码
|
||
3. 点击"执行代码"
|
||
4. 验证:表格新增age_group列
|
||
|
||
### 场景4:对话历史
|
||
1. 发送多条消息
|
||
2. 验证:所有对话保留
|
||
3. 刷新页面
|
||
4. 验证:对话历史加载正常
|
||
|
||
---
|
||
|
||
## 🚨 风险与应对
|
||
|
||
| 风险 | 概率 | 影响 | 应对措施 |
|
||
|------|------|------|---------|
|
||
| AG Grid学习成本高 | 中 | 中 | Day 4上午集中学习官方文档 |
|
||
| API调试时间长 | 高 | 中 | 先用Mock数据,再对接真实API |
|
||
| 代码语法高亮问题 | 低 | 低 | Prism.js配置不复杂,有现成方案 |
|
||
| 性能问题(300+行数据)| 低 | 低 | AG Grid自带虚拟滚动,无需优化 |
|
||
|
||
---
|
||
|
||
## 📦 依赖安装清单
|
||
|
||
```bash
|
||
# AG Grid
|
||
npm install ag-grid-community ag-grid-react
|
||
|
||
# 代码高亮
|
||
npm install prismjs @types/prismjs
|
||
|
||
# Markdown渲染(可选)
|
||
npm install react-markdown
|
||
|
||
# 已有依赖(无需安装)
|
||
# - react
|
||
# - react-router-dom
|
||
# - lucide-react
|
||
# - axios
|
||
# - tailwindcss
|
||
```
|
||
|
||
---
|
||
|
||
## 📚 参考资源
|
||
|
||
- [AG Grid React Documentation](https://www.ag-grid.com/react-data-grid/)
|
||
- [Prism.js Syntax Highlighting](https://prismjs.com/)
|
||
- [Tool B实现代码](../../frontend-v2/src/modules/dc/pages/tool-b/)
|
||
- [原型设计V6](../../03-UI设计/工具C_原型设计V6%20.html)
|
||
|
||
---
|
||
|
||
## ✅ 最终交付物清单
|
||
|
||
### Day 4
|
||
- [x] 文件结构创建(10个文件)
|
||
- [x] Header组件
|
||
- [x] Toolbar组件
|
||
- [x] DataGrid组件(AG Grid)
|
||
- [x] 基础布局完成
|
||
|
||
### Day 5
|
||
- [x] Sidebar组件
|
||
- [x] ChatPanel组件
|
||
- [x] MessageItem组件
|
||
- [x] CodeBlock组件
|
||
- [x] InputArea组件
|
||
- [x] InsightsPanel组件
|
||
- [x] API封装(toolC.ts)
|
||
- [x] useToolC Hook
|
||
- [x] 端到端流程测试通过
|
||
|
||
### 文档
|
||
- [x] 本开发计划
|
||
- [ ] Day 4-5开发完成总结(Day 5结束后)
|
||
- [ ] API对接文档(Day 5结束后)
|
||
|
||
---
|
||
|
||
**制定人**: AI Assistant
|
||
**确认人**: 用户
|
||
**开始时间**: Day 4上午
|
||
**预期完成**: Day 5下午
|
||
|
||
---
|
||
|
||
🚀 **准备好开始Day 4开发了吗?**
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|