Browse Source

自动生成react + antd可视化界面

ly 4 months ago
parent
commit
82507d60fe

+ 288 - 0
CLIENT_README.md

@@ -0,0 +1,288 @@
+# 客户报备管理系统 - 前端界面
+
+基于 React + Ant Design 5 的现代化管理界面。
+
+## 🎨 功能特性
+
+### ✅ 已实现功能
+
+- **用户认证**
+  - 登录/登出
+  - Token 管理
+  - 权限控制
+
+- **工作台**
+  - 数据统计卡片
+  - 即将到期客户提醒
+  - 个人业绩展示
+
+- **客户管理**
+  - 客户列表(搜索、筛选、分页)
+  - 报备客户(实时查重)
+  - 客户详情
+  - 跟进记录
+  - 状态更新
+
+- **公海池管理**
+  - 公海池客户列表
+  - 领取客户
+  - 多维度筛选
+
+- **审批流程**
+  - 我的申请列表
+  - 创建审批申请
+  - 待审批列表(经理)
+  - 审批处理
+
+- **统计报表**
+  - 团队统计(趋势图表)
+  - 成员业绩排名
+  - 客户来源分析
+  - 可视化图表
+
+## 🚀 快速开始
+
+### 1. 安装依赖
+
+```bash
+cd client
+npm install
+```
+
+### 2. 启动开发服务器
+
+```bash
+npm run dev
+```
+
+前端服务将运行在:http://localhost:3001
+
+### 3. 构建生产版本
+
+```bash
+npm run build
+```
+
+构建文件将输出到 `../public/admin` 目录。
+
+## 📁 项目结构
+
+```
+client/
+├── src/
+│   ├── components/          # 公共组件
+│   │   └── Layout/          # 布局组件
+│   ├── pages/               # 页面组件
+│   │   ├── Login/           # 登录页
+│   │   ├── Dashboard/       # 工作台
+│   │   ├── Customer/        # 客户管理
+│   │   ├── Pool/            # 公海池
+│   │   ├── Approval/        # 审批管理
+│   │   └── Stats/           # 统计报表
+│   ├── utils/               # 工具函数
+│   │   ├── request.js       # axios 封装
+│   │   └── constants.js     # 常量定义
+│   ├── App.jsx              # 根组件
+│   ├── main.jsx             # 入口文件
+│   └── index.css            # 全局样式
+├── index.html               # HTML 模板
+├── vite.config.js           # Vite 配置
+└── package.json             # 依赖配置
+```
+
+## 🎯 页面路由
+
+| 路径 | 页面 | 权限 |
+|------|------|------|
+| `/login` | 登录页 | 公开 |
+| `/` | 工作台 | 全部 |
+| `/customers` | 客户列表 | 全部 |
+| `/customers/create` | 报备客户 | 全部 |
+| `/customers/:id` | 客户详情 | 全部 |
+| `/pool` | 公海池 | 全部 |
+| `/my-approvals` | 我的申请 | 全部 |
+| `/approvals` | 待审批 | 经理+ |
+| `/stats/team` | 团队统计 | 经理+ |
+| `/stats/source` | 来源分析 | 经理+ |
+
+## 🛠️ 技术栈
+
+- **框架**: React 18
+- **UI 库**: Ant Design 5
+- **路由**: React Router 6
+- **HTTP 客户端**: Axios
+- **图表**: Recharts
+- **构建工具**: Vite
+- **日期处理**: Day.js
+
+## 🎨 主要组件
+
+### 1. Layout 布局
+- 顶部导航栏
+- 侧边菜单
+- 内容区域
+- 用户信息展示
+
+### 2. 客户列表
+- 表格展示
+- 搜索和筛选
+- 分页
+- 状态标签
+
+### 3. 客户报备
+- 表单验证
+- 实时查重
+- 冲突提示
+- 自动提交
+
+### 4. 客户详情
+- 基本信息
+- 跟进记录时间线
+- 快速操作
+- 状态更新
+
+### 5. 统计图表
+- 折线图(趋势分析)
+- 饼图(来源分布)
+- 数据表格
+- 日期筛选
+
+## 📝 开发说明
+
+### 添加新页面
+
+1. 在 `src/pages/` 下创建页面组件
+2. 在 `src/App.jsx` 中添加路由
+3. 在 `src/components/Layout/` 中添加菜单项
+
+### API 请求
+
+使用封装好的 `request` 工具:
+
+```javascript
+import request from '@/utils/request'
+
+// GET 请求
+const data = await request.get('/api/customers')
+
+// POST 请求
+await request.post('/api/customers', { name: 'xxx' })
+
+// 带参数
+await request.get('/api/customers', { params: { page: 1 } })
+```
+
+### 常量使用
+
+```javascript
+import { CUSTOMER_STATUS, USER_ROLES } from '@/utils/constants'
+
+// 渲染状态标签
+<Tag color={CUSTOMER_STATUS[status]?.color}>
+  {CUSTOMER_STATUS[status]?.text}
+</Tag>
+```
+
+## 🎯 开发规范
+
+1. **组件命名**: 使用 PascalCase
+2. **文件命名**: 组件用 PascalCase,工具用 camelCase
+3. **样式**: 优先使用 Ant Design 主题,必要时使用 CSS Modules
+4. **状态管理**: 使用 React Hooks (useState, useEffect)
+5. **错误处理**: 统一在 request.js 中处理
+6. **代码格式**: 建议使用 ESLint + Prettier
+
+## 🔌 API 代理配置
+
+开发环境下,Vite 会自动代理 API 请求:
+
+```javascript
+// vite.config.js
+proxy: {
+  '/api': {
+    target: 'http://localhost:3000',
+    changeOrigin: true
+  }
+}
+```
+
+## 🎨 自定义主题
+
+修改 `src/main.jsx` 中的 ConfigProvider:
+
+```javascript
+<ConfigProvider
+  theme={{
+    token: {
+      colorPrimary: '#1890ff',
+      borderRadius: 6,
+    },
+  }}
+>
+  <App />
+</ConfigProvider>
+```
+
+## 📱 响应式设计
+
+所有页面已适配移动端:
+- 使用 Ant Design 的 Grid 系统
+- 表格支持横向滚动
+- 弹窗自适应宽度
+
+## ⚡ 性能优化
+
+- 路由懒加载
+- 图片懒加载
+- 分页加载数据
+- 防抖/节流搜索
+- 组件 memo 优化
+
+## 🐛 常见问题
+
+### Q1: 页面空白
+**A**: 检查后端服务是否启动,查看浏览器控制台错误。
+
+### Q2: API 请求失败
+**A**: 确认后端运行在 `http://localhost:3000`,检查代理配置。
+
+### Q3: 登录后刷新页面返回登录页
+**A**: 检查 localStorage 中的 token 是否存在,可能是 token 过期。
+
+### Q4: 样式不生效
+**A**: 清除缓存重新启动开发服务器:`npm run dev`
+
+## 🚀 部署
+
+### 开发环境
+```bash
+npm run dev
+```
+
+### 生产环境
+```bash
+# 构建
+npm run build
+
+# 构建产物在 ../public/admin 目录
+# 直接访问后端服务器的 /admin 路径即可
+```
+
+### Nginx 配置示例
+```nginx
+location /admin {
+    root /path/to/public;
+    try_files $uri $uri/ /admin/index.html;
+}
+```
+
+## 📞 技术支持
+
+遇到问题请查看:
+1. 浏览器控制台
+2. 网络请求(Network)
+3. 后端日志
+
+---
+
+**祝开发愉快!** 🎉

+ 38 - 8
README.md

@@ -2,7 +2,13 @@
 
 ## 📋 项目简介
 
-基于 Node.js + Express + MySQL 的企业级客户报备管理系统,实现标准化客户报备流程,防止销售撞单抢单,实现客户资源公司化、流程规范化、管理可视化。
+基于 **Node.js + Express + MySQL + React + Ant Design** 的企业级客户报备管理系统,实现标准化客户报备流程,防止销售撞单抢单,实现客户资源公司化、流程规范化、管理可视化。
+
+### 技术栈
+- **后端**: Node.js + Express + MySQL
+- **前端**: React 18 + Ant Design 5 + Vite
+- **认证**: JWT Token
+- **图表**: Recharts
 
 ### 核心功能
 
@@ -22,14 +28,38 @@
 - MySQL >= 5.7
 - npm 或 yarn
 
-### 安装步骤
+### 方式一:完整启动(推荐)
+
+查看详细启动指南:[START_GUIDE.md](START_GUIDE.md)
+
+**快速启动命令:**
 
-1. **克隆项目**
 ```bash
-cd /Users/ly/CodeBuddy/20260114134435
+# 1. 安装后端依赖
+npm install
+
+# 2. 配置数据库(编辑 .env 文件)
+# DB_PASSWORD=你的MySQL密码
+
+# 3. 初始化数据库
+npm run init-db
+
+# 4. 启动后端服务
+npm run dev
+
+# 5. 启动前端界面(新终端窗口)
+cd client
+npm install
+npm run dev
 ```
 
-2. **安装依赖**
+**访问系统:**
+- 🎨 前端界面:http://localhost:3001 (推荐)
+- 📡 后端 API:http://localhost:3000
+
+### 方式二:仅后端服务
+
+1. **安装依赖**
 ```bash
 npm install
 ```
@@ -81,9 +111,9 @@ npm start
 
 6. **访问系统**
 
-打开浏览器访问:http://localhost:3000
-
-API 基础路径:http://localhost:3000/api
+- 简单界面:http://localhost:3000
+- API 基础路径:http://localhost:3000/api
+- 完整管理界面:启动前端服务(见上方"方式一")
 
 ## 📚 API 文档
 

+ 325 - 0
START_GUIDE.md

@@ -0,0 +1,325 @@
+# 🚀 客户报备管理系统 - 完整启动指南
+
+## 📦 项目包含
+
+1. **后端服务** (Node.js + Express + MySQL)
+2. **前端界面** (React + Ant Design 5)
+
+## ⚡ 快速启动(5步)
+
+### 1️⃣ 安装后端依赖
+
+```bash
+# 在项目根目录
+npm install
+```
+
+### 2️⃣ 配置数据库
+
+编辑 `.env` 文件,设置MySQL密码:
+
+```env
+DB_PASSWORD=你的MySQL密码
+```
+
+### 3️⃣ 初始化数据库
+
+```bash
+npm run init-db
+```
+
+### 4️⃣ 启动后端服务
+
+```bash
+npm run dev
+```
+
+后端运行在:http://localhost:3000
+
+### 5️⃣ 启动前端界面
+
+打开**新的终端窗口**:
+
+```bash
+cd client
+npm install
+npm run dev
+```
+
+前端运行在:http://localhost:3001
+
+## 🎯 访问系统
+
+### 方式1:使用前端界面(推荐)
+
+访问:**http://localhost:3001**
+
+默认账号:
+- 管理员:`admin` / `admin123`
+- 销售1:`sales001` / `123456`
+- 销售2:`sales002` / `123456`
+- 经理:`manager001` / `123456`
+
+### 方式2:使用简单界面
+
+访问:http://localhost:3000
+
+### 方式3:直接调用 API
+
+```bash
+# 登录获取 Token
+curl -X POST http://localhost:3000/api/auth/login \
+  -H "Content-Type: application/json" \
+  -d '{"username":"admin","password":"admin123"}'
+```
+
+## 📁 项目结构
+
+```
+customer-crm/
+├── 📁 后端 (根目录)
+│   ├── src/
+│   │   ├── controllers/     # 业务逻辑
+│   │   ├── middleware/      # 中间件
+│   │   ├── routes/          # 路由
+│   │   ├── config/          # 配置
+│   │   └── utils/           # 工具
+│   ├── scripts/             # 数据库脚本
+│   └── public/              # 静态文件
+│
+└── 📁 前端 (client/)
+    ├── src/
+    │   ├── pages/           # 页面组件
+    │   ├── components/      # 公共组件
+    │   └── utils/           # 工具函数
+    └── package.json
+```
+
+## 🎨 功能模块
+
+### ✅ 已实现功能
+
+| 模块 | 功能 |
+|------|------|
+| 🔐 用户认证 | 登录、JWT、权限控制 |
+| 📊 工作台 | 数据统计、到期提醒 |
+| 👥 客户管理 | 列表、报备、查重、详情、跟进 |
+| 🏊 公海池 | 客户领取、释放、筛选 |
+| ✅ 审批流程 | 延期申请、协同跟进、审批处理 |
+| 📈 统计报表 | 团队统计、来源分析、可视化 |
+| ⏰ 自动任务 | 保护期检查、到期提醒 |
+
+## 🔧 常用命令
+
+### 后端命令
+
+```bash
+npm run dev          # 开发模式(热重载)
+npm start            # 生产模式
+npm run init-db      # 初始化数据库
+npm run test-data    # 创建测试数据
+npm run clean-db     # 清空数据库
+```
+
+### 前端命令
+
+```bash
+cd client
+npm run dev          # 开发模式
+npm run build        # 构建生产版本
+npm run preview      # 预览构建结果
+```
+
+## 🎯 测试流程
+
+### 1. 创建测试数据
+
+```bash
+npm run test-data
+```
+
+这将创建:
+- ✅ 3个测试用户
+- ✅ 25个测试客户(含公海池)
+- ✅ 跟进记录
+
+### 2. 测试登录
+
+使用以下账号登录前端界面:
+- 销售:`sales001` / `123456`
+- 经理:`manager001` / `123456`
+
+### 3. 测试核心功能
+
+1. **报备客户**
+   - 进入"客户管理" → "报备客户"
+   - 输入客户信息
+   - 查看实时查重功能
+
+2. **查看客户列表**
+   - 搜索、筛选
+   - 查看客户详情
+   - 添加跟进记录
+
+3. **公海池**
+   - 查看公海池客户
+   - 领取客户
+
+4. **审批流程**
+   - 提交延期申请
+   - 切换经理账号审批
+
+5. **统计报表**(经理账号)
+   - 查看团队统计
+   - 查看来源分析
+
+## 📊 角色权限
+
+| 功能 | 销售 | 经理 | 总监 | 管理员 |
+|------|:----:|:----:|:----:|:------:|
+| 报备客户 | ✅ | ✅ | ✅ | ✅ |
+| 查看自己客户 | ✅ | ✅ | ✅ | ✅ |
+| 查看团队客户 | ❌ | ✅ | ✅ | ✅ |
+| 领取公海客户 | ✅ | ✅ | ✅ | ✅ |
+| 提交审批 | ✅ | ✅ | ✅ | ✅ |
+| 审批处理 | ❌ | ✅ | ✅ | ✅ |
+| 团队统计 | ❌ | ✅ | ✅ | ✅ |
+| 用户管理 | ❌ | ❌ | ❌ | ✅ |
+
+## 🐛 故障排除
+
+### 后端启动失败
+
+**问题1:数据库连接失败**
+```
+解决:检查 .env 中的数据库配置
+     确保 MySQL 服务已启动
+```
+
+**问题2:端口被占用**
+```bash
+# 查找占用端口的进程
+lsof -i :3000
+
+# 杀死进程
+kill -9 PID
+```
+
+### 前端启动失败
+
+**问题1:依赖安装失败**
+```bash
+# 清理缓存重新安装
+cd client
+rm -rf node_modules package-lock.json
+npm install
+```
+
+**问题2:API 请求失败**
+```
+解决:确保后端服务在 http://localhost:3000 运行
+     检查浏览器控制台 Network 标签
+```
+
+### 登录问题
+
+**Token 过期**
+```
+解决:清除浏览器 localStorage
+     或重新登录
+```
+
+## 📱 端口说明
+
+| 服务 | 端口 | 用途 |
+|------|------|------|
+| 后端 API | 3000 | 提供 RESTful API |
+| 前端界面 | 3001 | React 开发服务器 |
+
+## 🔒 安全建议
+
+生产环境部署前,请修改:
+
+1. **JWT Secret**
+```env
+JWT_SECRET=修改为强随机字符串
+```
+
+2. **数据库密码**
+```env
+DB_PASSWORD=使用强密码
+```
+
+3. **管理员密码**
+```bash
+# 首次登录后立即修改
+```
+
+## 🚀 生产部署
+
+### 后端部署
+
+```bash
+# 使用 PM2
+pm2 start src/server.js --name customer-crm
+pm2 save
+pm2 startup
+```
+
+### 前端部署
+
+```bash
+# 构建
+cd client
+npm run build
+
+# 构建产物在 ../public/admin
+# 配置 Nginx 指向该目录
+```
+
+### Nginx 配置
+
+```nginx
+server {
+    listen 80;
+    server_name your-domain.com;
+
+    # 前端静态文件
+    location /admin {
+        root /path/to/public;
+        try_files $uri $uri/ /admin/index.html;
+    }
+
+    # API 代理
+    location /api {
+        proxy_pass http://localhost:3000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection 'upgrade';
+        proxy_set_header Host $host;
+        proxy_cache_bypass $http_upgrade;
+    }
+}
+```
+
+## 📚 相关文档
+
+- [README.md](README.md) - 完整项目文档
+- [QUICKSTART.md](QUICKSTART.md) - 快速启动指南
+- [INSTALL.md](INSTALL.md) - 详细安装说明
+- [API_EXAMPLES.md](API_EXAMPLES.md) - API 调用示例
+- [CLIENT_README.md](CLIENT_README.md) - 前端开发文档
+
+## 🎉 开始使用
+
+现在您可以:
+
+1. ✅ 访问 http://localhost:3001
+2. ✅ 使用 `admin` / `admin123` 登录
+3. ✅ 体验完整的客户报备管理系统!
+
+有问题随时查看文档或提交 Issue。
+
+---
+
+**祝您使用愉快!** 🎊

+ 12 - 0
client/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>客户报备管理系统</title>
+</head>
+<body>
+  <div id="root"></div>
+  <script type="module" src="/src/main.jsx"></script>
+</body>
+</html>

+ 25 - 0
client/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "customer-crm-client",
+  "version": "1.0.0",
+  "private": true,
+  "dependencies": {
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "react-router-dom": "^6.20.0",
+    "antd": "^5.12.0",
+    "@ant-design/icons": "^5.2.6",
+    "axios": "^1.6.2",
+    "dayjs": "^1.11.10",
+    "recharts": "^2.10.3"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-react": "^4.2.1",
+    "vite": "^5.0.8"
+  },
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "proxy": "http://localhost:3000"
+}

+ 67 - 0
client/src/App.jsx

@@ -0,0 +1,67 @@
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
+import { useState, useEffect } from 'react'
+import Login from './pages/Login'
+import Layout from './components/Layout'
+import Dashboard from './pages/Dashboard'
+import CustomerList from './pages/Customer/CustomerList'
+import CustomerDetail from './pages/Customer/CustomerDetail'
+import CustomerCreate from './pages/Customer/CustomerCreate'
+import PoolList from './pages/Pool/PoolList'
+import ApprovalList from './pages/Approval/ApprovalList'
+import MyApprovals from './pages/Approval/MyApprovals'
+import TeamStats from './pages/Stats/TeamStats'
+import SourceAnalysis from './pages/Stats/SourceAnalysis'
+
+function App() {
+  const [user, setUser] = useState(null)
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    const token = localStorage.getItem('token')
+    const userData = localStorage.getItem('user')
+    if (token && userData) {
+      setUser(JSON.parse(userData))
+    }
+    setLoading(false)
+  }, [])
+
+  const handleLogin = (userData) => {
+    setUser(userData)
+  }
+
+  const handleLogout = () => {
+    localStorage.removeItem('token')
+    localStorage.removeItem('user')
+    setUser(null)
+  }
+
+  if (loading) {
+    return null
+  }
+
+  return (
+    <Router>
+      <Routes>
+        <Route path="/login" element={
+          user ? <Navigate to="/" replace /> : <Login onLogin={handleLogin} />
+        } />
+        
+        <Route path="/" element={
+          user ? <Layout user={user} onLogout={handleLogout} /> : <Navigate to="/login" replace />
+        }>
+          <Route index element={<Dashboard />} />
+          <Route path="customers" element={<CustomerList />} />
+          <Route path="customers/create" element={<CustomerCreate />} />
+          <Route path="customers/:id" element={<CustomerDetail />} />
+          <Route path="pool" element={<PoolList />} />
+          <Route path="approvals" element={<ApprovalList />} />
+          <Route path="my-approvals" element={<MyApprovals />} />
+          <Route path="stats/team" element={<TeamStats />} />
+          <Route path="stats/source" element={<SourceAnalysis />} />
+        </Route>
+      </Routes>
+    </Router>
+  )
+}
+
+export default App

+ 59 - 0
client/src/components/Layout/index.css

@@ -0,0 +1,59 @@
+.layout-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: #fff;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  padding: 0 24px;
+}
+
+.logo {
+  display: flex;
+  align-items: center;
+  font-size: 20px;
+  font-weight: 600;
+  color: #1890ff;
+}
+
+.header-right {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+}
+
+.user-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  padding: 8px 12px;
+  border-radius: 4px;
+  transition: background-color 0.3s;
+}
+
+.user-info:hover {
+  background-color: #f0f0f0;
+}
+
+.user-name {
+  font-weight: 500;
+}
+
+.user-role {
+  font-size: 12px;
+  color: #999;
+  padding: 2px 8px;
+  background: #f0f0f0;
+  border-radius: 4px;
+}
+
+.layout-sider {
+  background: #fff;
+  box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
+}
+
+.layout-content {
+  background: #f0f2f5;
+  padding: 24px;
+  min-height: calc(100vh - 64px);
+}

+ 120 - 0
client/src/components/Layout/index.jsx

@@ -0,0 +1,120 @@
+import { Layout as AntLayout, Menu, Dropdown, Avatar, Badge } from 'antd'
+import {
+  DashboardOutlined,
+  TeamOutlined,
+  InboxOutlined,
+  AuditOutlined,
+  BarChartOutlined,
+  UserOutlined,
+  LogoutOutlined,
+  FileTextOutlined
+} from '@ant-design/icons'
+import { Outlet, useNavigate, useLocation } from 'react-router-dom'
+import { USER_ROLES } from '../../utils/constants'
+import './index.css'
+
+const { Header, Sider, Content } = AntLayout
+
+const Layout = ({ user, onLogout }) => {
+  const navigate = useNavigate()
+  const location = useLocation()
+
+  const menuItems = [
+    {
+      key: '/',
+      icon: <DashboardOutlined />,
+      label: '工作台',
+    },
+    {
+      key: '/customers',
+      icon: <TeamOutlined />,
+      label: '客户管理',
+      children: [
+        { key: '/customers', label: '客户列表' },
+        { key: '/customers/create', label: '报备客户' },
+      ]
+    },
+    {
+      key: '/pool',
+      icon: <InboxOutlined />,
+      label: '公海池',
+    },
+    {
+      key: '/approvals-menu',
+      icon: <AuditOutlined />,
+      label: '审批管理',
+      children: [
+        { key: '/my-approvals', label: '我的申请' },
+        ...(user.role !== 'sales' ? [{ key: '/approvals', label: '待审批' }] : [])
+      ]
+    },
+    ...(user.role !== 'sales' ? [{
+      key: '/stats',
+      icon: <BarChartOutlined />,
+      label: '统计报表',
+      children: [
+        { key: '/stats/team', label: '团队统计' },
+        { key: '/stats/source', label: '来源分析' },
+      ]
+    }] : [])
+  ]
+
+  const userMenu = (
+    <Menu
+      items={[
+        {
+          key: 'profile',
+          icon: <UserOutlined />,
+          label: '个人信息',
+        },
+        {
+          type: 'divider',
+        },
+        {
+          key: 'logout',
+          icon: <LogoutOutlined />,
+          label: '退出登录',
+          onClick: onLogout,
+        },
+      ]}
+    />
+  )
+
+  return (
+    <AntLayout style={{ minHeight: '100vh' }}>
+      <Header className="layout-header">
+        <div className="logo">
+          <FileTextOutlined style={{ fontSize: '24px', marginRight: '12px' }} />
+          <span>客户报备管理系统</span>
+        </div>
+        <div className="header-right">
+          <Dropdown overlay={userMenu} placement="bottomRight">
+            <div className="user-info">
+              <Avatar icon={<UserOutlined />} />
+              <span className="user-name">{user.real_name}</span>
+              <span className="user-role">{USER_ROLES[user.role]}</span>
+            </div>
+          </Dropdown>
+        </div>
+      </Header>
+
+      <AntLayout>
+        <Sider width={220} theme="light" className="layout-sider">
+          <Menu
+            mode="inline"
+            selectedKeys={[location.pathname]}
+            defaultOpenKeys={['/customers', '/approvals-menu', '/stats']}
+            items={menuItems}
+            onClick={({ key }) => navigate(key)}
+          />
+        </Sider>
+
+        <Content className="layout-content">
+          <Outlet />
+        </Content>
+      </AntLayout>
+    </AntLayout>
+  )
+}
+
+export default Layout

+ 29 - 0
client/src/index.css

@@ -0,0 +1,29 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
+    'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+  min-height: 100vh;
+}
+
+.page-container {
+  padding: 24px;
+  background: #f0f2f5;
+  min-height: calc(100vh - 64px);
+}
+
+.card-container {
+  background: #fff;
+  border-radius: 8px;
+  padding: 24px;
+  margin-bottom: 24px;
+}

+ 15 - 0
client/src/main.jsx

@@ -0,0 +1,15 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { ConfigProvider } from 'antd'
+import zhCN from 'antd/locale/zh_CN'
+import App from './App'
+import 'dayjs/locale/zh-cn'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+  <React.StrictMode>
+    <ConfigProvider locale={zhCN}>
+      <App />
+    </ConfigProvider>
+  </React.StrictMode>,
+)

+ 168 - 0
client/src/pages/Approval/ApprovalList.jsx

@@ -0,0 +1,168 @@
+import { Card, Table, Button, Space, Tag, Modal, Form, Input, message } from 'antd'
+import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import request from '../../utils/request'
+import { APPROVAL_TYPES, APPROVAL_STATUS } from '../../utils/constants'
+import dayjs from 'dayjs'
+
+const { TextArea } = Input
+
+const ApprovalList = () => {
+  const [data, setData] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [processVisible, setProcessVisible] = useState(false)
+  const [currentRecord, setCurrentRecord] = useState(null)
+  const [processType, setProcessType] = useState(null)
+  const [form] = Form.useForm()
+
+  useEffect(() => {
+    loadData()
+  }, [])
+
+  const loadData = async () => {
+    setLoading(true)
+    try {
+      const res = await request.get('/approvals/pending')
+      setData(res.data.approvals)
+    } catch (error) {
+      console.error('加载数据失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const handleProcess = (record, status) => {
+    setCurrentRecord(record)
+    setProcessType(status)
+    setProcessVisible(true)
+  }
+
+  const onFinish = async (values) => {
+    try {
+      await request.post(`/approvals/${currentRecord.id}/process`, {
+        status: processType,
+        ...values
+      })
+      message.success(processType === 'approved' ? '审批通过' : '审批已拒绝')
+      setProcessVisible(false)
+      form.resetFields()
+      loadData()
+    } catch (error) {
+      console.error('处理失败:', error)
+    }
+  }
+
+  const columns = [
+    {
+      title: '审批类型',
+      dataIndex: 'type',
+      key: 'type',
+      width: 120,
+      render: (type) => <Tag>{APPROVAL_TYPES[type]}</Tag>,
+    },
+    {
+      title: '申请人',
+      dataIndex: 'applicant_name',
+      key: 'applicant_name',
+      width: 100,
+    },
+    {
+      title: '团队',
+      dataIndex: 'applicant_team',
+      key: 'applicant_team',
+      width: 100,
+    },
+    {
+      title: '客户名称',
+      dataIndex: 'customer_name',
+      key: 'customer_name',
+      width: 200,
+    },
+    {
+      title: '申请原因',
+      dataIndex: 'reason',
+      key: 'reason',
+      ellipsis: true,
+    },
+    {
+      title: '延期天数',
+      dataIndex: 'extension_days',
+      key: 'extension_days',
+      width: 100,
+      render: (days) => days ? `${days}天` : '-',
+    },
+    {
+      title: '申请时间',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 160,
+      render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 150,
+      fixed: 'right',
+      render: (_, record) => (
+        <Space>
+          <Button
+            type="primary"
+            size="small"
+            icon={<CheckOutlined />}
+            onClick={() => handleProcess(record, 'approved')}
+          >
+            通过
+          </Button>
+          <Button
+            danger
+            size="small"
+            icon={<CloseOutlined />}
+            onClick={() => handleProcess(record, 'rejected')}
+          >
+            拒绝
+          </Button>
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      <Card title="待审批列表">
+        <Table
+          columns={columns}
+          dataSource={data}
+          rowKey="id"
+          loading={loading}
+          pagination={false}
+          scroll={{ x: 1200 }}
+        />
+      </Card>
+
+      <Modal
+        title={processType === 'approved' ? '审批通过' : '审批拒绝'}
+        open={processVisible}
+        onCancel={() => setProcessVisible(false)}
+        footer={null}
+      >
+        <Form form={form} onFinish={onFinish} layout="vertical">
+          <Form.Item
+            label="审批意见"
+            name="result_comment"
+            rules={[{ required: true, message: '请输入审批意见' }]}
+          >
+            <TextArea rows={4} placeholder="请输入审批意见" />
+          </Form.Item>
+          <Form.Item>
+            <Button type="primary" htmlType="submit">提交</Button>
+            <Button style={{ marginLeft: 8 }} onClick={() => setProcessVisible(false)}>
+              取消
+            </Button>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}
+
+export default ApprovalList

+ 212 - 0
client/src/pages/Approval/MyApprovals.jsx

@@ -0,0 +1,212 @@
+import { Card, Table, Button, Tag, Modal, Form, Input, InputNumber, Select, message } from 'antd'
+import { PlusOutlined } from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import request from '../../utils/request'
+import { APPROVAL_TYPES, APPROVAL_STATUS } from '../../utils/constants'
+import dayjs from 'dayjs'
+
+const { TextArea } = Input
+const { Option } = Select
+
+const MyApprovals = () => {
+  const [data, setData] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [visible, setVisible] = useState(false)
+  const [customers, setCustomers] = useState([])
+  const [form] = Form.useForm()
+
+  useEffect(() => {
+    loadData()
+    loadCustomers()
+  }, [])
+
+  const loadData = async () => {
+    setLoading(true)
+    try {
+      const res = await request.get('/approvals/my')
+      setData(res.data.approvals)
+    } catch (error) {
+      console.error('加载数据失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const loadCustomers = async () => {
+    try {
+      const res = await request.get('/customers', {
+        params: { status: 'following', limit: 100 }
+      })
+      setCustomers(res.data.customers)
+    } catch (error) {
+      console.error('加载客户失败:', error)
+    }
+  }
+
+  const onFinish = async (values) => {
+    try {
+      await request.post('/approvals', values)
+      message.success('审批申请已提交')
+      setVisible(false)
+      form.resetFields()
+      loadData()
+    } catch (error) {
+      console.error('提交失败:', error)
+    }
+  }
+
+  const columns = [
+    {
+      title: '审批类型',
+      dataIndex: 'type',
+      key: 'type',
+      width: 120,
+      render: (type) => <Tag>{APPROVAL_TYPES[type]}</Tag>,
+    },
+    {
+      title: '客户名称',
+      dataIndex: 'customer_name',
+      key: 'customer_name',
+      width: 200,
+    },
+    {
+      title: '申请原因',
+      dataIndex: 'reason',
+      key: 'reason',
+      ellipsis: true,
+    },
+    {
+      title: '延期天数',
+      dataIndex: 'extension_days',
+      key: 'extension_days',
+      width: 100,
+      render: (days) => days ? `${days}天` : '-',
+    },
+    {
+      title: '审批人',
+      dataIndex: 'approver_name',
+      key: 'approver_name',
+      width: 100,
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+      width: 100,
+      render: (status) => (
+        <Tag color={APPROVAL_STATUS[status]?.color}>
+          {APPROVAL_STATUS[status]?.text}
+        </Tag>
+      ),
+    },
+    {
+      title: '审批意见',
+      dataIndex: 'result_comment',
+      key: 'result_comment',
+      ellipsis: true,
+    },
+    {
+      title: '申请时间',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 160,
+      render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
+    },
+  ]
+
+  return (
+    <div>
+      <Card
+        title="我的审批申请"
+        extra={
+          <Button type="primary" icon={<PlusOutlined />} onClick={() => setVisible(true)}>
+            新建申请
+          </Button>
+        }
+      >
+        <Table
+          columns={columns}
+          dataSource={data}
+          rowKey="id"
+          loading={loading}
+          pagination={false}
+          scroll={{ x: 1400 }}
+        />
+      </Card>
+
+      <Modal
+        title="新建审批申请"
+        open={visible}
+        onCancel={() => setVisible(false)}
+        footer={null}
+        width={600}
+      >
+        <Form form={form} onFinish={onFinish} layout="vertical">
+          <Form.Item
+            label="审批类型"
+            name="type"
+            rules={[{ required: true, message: '请选择审批类型' }]}
+          >
+            <Select placeholder="请选择审批类型">
+              <Option value="extension">延期申请</Option>
+              <Option value="collaboration">协同跟进</Option>
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            label="选择客户"
+            name="customer_id"
+            rules={[{ required: true, message: '请选择客户' }]}
+          >
+            <Select
+              showSearch
+              placeholder="请选择客户"
+              optionFilterProp="children"
+              filterOption={(input, option) =>
+                option.children.toLowerCase().includes(input.toLowerCase())
+              }
+            >
+              {customers.map(item => (
+                <Option key={item.id} value={item.id}>{item.customer_name}</Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            noStyle
+            shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
+          >
+            {({ getFieldValue }) =>
+              getFieldValue('type') === 'extension' ? (
+                <Form.Item
+                  label="延期天数"
+                  name="extension_days"
+                  rules={[{ required: true, message: '请输入延期天数' }]}
+                >
+                  <InputNumber min={1} max={30} style={{ width: '100%' }} />
+                </Form.Item>
+              ) : null
+            }
+          </Form.Item>
+
+          <Form.Item
+            label="申请原因"
+            name="reason"
+            rules={[{ required: true, message: '请输入申请原因' }]}
+          >
+            <TextArea rows={4} placeholder="请详细说明申请原因" />
+          </Form.Item>
+
+          <Form.Item>
+            <Button type="primary" htmlType="submit">提交</Button>
+            <Button style={{ marginLeft: 8 }} onClick={() => setVisible(false)}>
+              取消
+            </Button>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}
+
+export default MyApprovals

+ 177 - 0
client/src/pages/Customer/CustomerCreate.jsx

@@ -0,0 +1,177 @@
+import { Card, Form, Input, Select, Button, message, Alert, Spin } from 'antd'
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import request from '../../utils/request'
+import { CUSTOMER_SOURCES, INDUSTRIES, REGIONS } from '../../utils/constants'
+
+const { Option } = Select
+const { TextArea } = Input
+
+const CustomerCreate = () => {
+  const [form] = Form.useForm()
+  const [loading, setLoading] = useState(false)
+  const [checking, setChecking] = useState(false)
+  const [conflict, setConflict] = useState(null)
+  const navigate = useNavigate()
+
+  const checkDuplicate = async (customerName) => {
+    if (!customerName || customerName.length < 2) {
+      setConflict(null)
+      return
+    }
+
+    setChecking(true)
+    try {
+      const res = await request.get('/customers/check-duplicate', {
+        params: { customer_name: customerName }
+      })
+      
+      if (res.has_conflict) {
+        setConflict(res)
+      } else {
+        setConflict(null)
+      }
+    } catch (error) {
+      console.error('查重失败:', error)
+    } finally {
+      setChecking(false)
+    }
+  }
+
+  const onFinish = async (values) => {
+    if (conflict?.has_conflict) {
+      message.error('存在重复客户,请先处理冲突')
+      return
+    }
+
+    setLoading(true)
+    try {
+      await request.post('/customers', values)
+      message.success('客户报备成功')
+      navigate('/customers')
+    } catch (error) {
+      console.error('报备失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div>
+      <Card title="报备客户" extra={
+        <Button onClick={() => navigate('/customers')}>返回</Button>
+      }>
+        <Form
+          form={form}
+          layout="vertical"
+          onFinish={onFinish}
+          autoComplete="off"
+        >
+          <Form.Item
+            label="客户名称"
+            name="customer_name"
+            rules={[{ required: true, message: '请输入客户名称' }]}
+            extra={checking && <Spin size="small" />}
+          >
+            <Input
+              placeholder="请输入客户全称"
+              onChange={(e) => checkDuplicate(e.target.value)}
+            />
+          </Form.Item>
+
+          {conflict?.has_conflict && (
+            <Alert
+              message={`发现${conflict.conflict_type === 'exact' ? '完全' : '相似'}重复的客户`}
+              description={
+                <div>
+                  <p>客户名称: {conflict.data.customer_name}</p>
+                  <p>负责人: {conflict.data.owner_name}</p>
+                  <p>团队: {conflict.data.team}</p>
+                  {conflict.similarity && <p>相似度: {(conflict.similarity * 100).toFixed(1)}%</p>}
+                </div>
+              }
+              type="error"
+              showIcon
+              style={{ marginBottom: 16 }}
+            />
+          )}
+
+          <Form.Item
+            label="行业"
+            name="industry"
+            rules={[{ required: true, message: '请选择行业' }]}
+          >
+            <Select placeholder="请选择行业">
+              {INDUSTRIES.map(item => (
+                <Option key={item} value={item}>{item}</Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            label="地区"
+            name="region"
+            rules={[{ required: true, message: '请选择地区' }]}
+          >
+            <Select placeholder="请选择地区">
+              {REGIONS.map(item => (
+                <Option key={item} value={item}>{item}</Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            label="联系人"
+            name="contact_person"
+          >
+            <Input placeholder="请输入联系人姓名" />
+          </Form.Item>
+
+          <Form.Item
+            label="联系电话"
+            name="contact_phone"
+            rules={[
+              { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }
+            ]}
+          >
+            <Input placeholder="请输入联系电话" />
+          </Form.Item>
+
+          <Form.Item
+            label="需求概况"
+            name="demand_description"
+            rules={[{ required: true, message: '请输入需求概况' }]}
+          >
+            <TextArea
+              rows={4}
+              placeholder="请描述客户的需求情况"
+            />
+          </Form.Item>
+
+          <Form.Item
+            label="来源渠道"
+            name="source"
+            rules={[{ required: true, message: '请选择来源渠道' }]}
+          >
+            <Select placeholder="请选择来源渠道">
+              {CUSTOMER_SOURCES.map(item => (
+                <Option key={item} value={item}>{item}</Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading} disabled={conflict?.has_conflict}>
+              提交报备
+            </Button>
+            <Button style={{ marginLeft: 8 }} onClick={() => navigate('/customers')}>
+              取消
+            </Button>
+          </Form.Item>
+        </Form>
+      </Card>
+    </div>
+  )
+}
+
+export default CustomerCreate

+ 218 - 0
client/src/pages/Customer/CustomerDetail.jsx

@@ -0,0 +1,218 @@
+import { Card, Descriptions, Button, Space, Tag, Timeline, Form, Input, Select, Modal, message, Divider } from 'antd'
+import { ArrowLeftOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import request from '../../utils/request'
+import { CUSTOMER_STATUS, FOLLOWUP_TYPES } from '../../utils/constants'
+import dayjs from 'dayjs'
+
+const { TextArea } = Input
+const { Option } = Select
+
+const CustomerDetail = () => {
+  const { id } = useParams()
+  const navigate = useNavigate()
+  const [data, setData] = useState(null)
+  const [loading, setLoading] = useState(true)
+  const [followupVisible, setFollowupVisible] = useState(false)
+  const [statusVisible, setStatusVisible] = useState(false)
+  const [form] = Form.useForm()
+
+  useEffect(() => {
+    loadData()
+  }, [id])
+
+  const loadData = async () => {
+    try {
+      const res = await request.get(`/customers/${id}`)
+      setData(res.data)
+    } catch (error) {
+      console.error('加载失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const handleAddFollowup = async (values) => {
+    try {
+      await request.post('/customers/followup', {
+        customer_id: id,
+        ...values
+      })
+      message.success('跟进记录添加成功')
+      setFollowupVisible(false)
+      form.resetFields()
+      loadData()
+    } catch (error) {
+      console.error('添加失败:', error)
+    }
+  }
+
+  const handleUpdateStatus = async (values) => {
+    try {
+      await request.patch(`/customers/${id}/status`, values)
+      message.success('状态更新成功')
+      setStatusVisible(false)
+      loadData()
+    } catch (error) {
+      console.error('更新失败:', error)
+    }
+  }
+
+  if (loading || !data) return null
+
+  const { customer, followups, attachments } = data
+
+  return (
+    <div>
+      <Card
+        title={
+          <Space>
+            <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} />
+            <span>客户详情</span>
+          </Space>
+        }
+        extra={
+          <Space>
+            <Button icon={<PlusOutlined />} onClick={() => setFollowupVisible(true)}>
+              添加跟进
+            </Button>
+            <Button icon={<EditOutlined />} onClick={() => setStatusVisible(true)}>
+              更新状态
+            </Button>
+          </Space>
+        }
+      >
+        <Descriptions bordered column={2}>
+          <Descriptions.Item label="客户名称">{customer.customer_name}</Descriptions.Item>
+          <Descriptions.Item label="状态">
+            <Tag color={CUSTOMER_STATUS[customer.status]?.color}>
+              {CUSTOMER_STATUS[customer.status]?.text}
+            </Tag>
+          </Descriptions.Item>
+          <Descriptions.Item label="行业">{customer.industry}</Descriptions.Item>
+          <Descriptions.Item label="地区">{customer.region}</Descriptions.Item>
+          <Descriptions.Item label="联系人">{customer.contact_person}</Descriptions.Item>
+          <Descriptions.Item label="联系电话">{customer.contact_phone}</Descriptions.Item>
+          <Descriptions.Item label="负责人">{customer.owner_name}</Descriptions.Item>
+          <Descriptions.Item label="来源渠道">{customer.source}</Descriptions.Item>
+          <Descriptions.Item label="报备时间">
+            {dayjs(customer.report_time).format('YYYY-MM-DD HH:mm')}
+          </Descriptions.Item>
+          <Descriptions.Item label="保护期到期">
+            <span style={{ color: customer.is_expired ? '#ff4d4f' : undefined }}>
+              {dayjs(customer.protected_end_date).format('YYYY-MM-DD HH:mm')}
+            </span>
+          </Descriptions.Item>
+          {customer.last_followup && (
+            <Descriptions.Item label="最后跟进">
+              {dayjs(customer.last_followup).format('YYYY-MM-DD HH:mm')}
+            </Descriptions.Item>
+          )}
+          <Descriptions.Item label="需求概况" span={2}>
+            {customer.demand_description}
+          </Descriptions.Item>
+        </Descriptions>
+      </Card>
+
+      <Card title="跟进记录" style={{ marginTop: 16 }}>
+        {followups.length === 0 ? (
+          <div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
+            暂无跟进记录
+          </div>
+        ) : (
+          <Timeline>
+            {followups.map(item => (
+              <Timeline.Item key={item.id}>
+                <div>
+                  <div style={{ marginBottom: 8 }}>
+                    <Tag>{FOLLOWUP_TYPES[item.followup_type]}</Tag>
+                    <span style={{ marginLeft: 8, color: '#999' }}>
+                      {item.user_name} · {dayjs(item.created_at).format('YYYY-MM-DD HH:mm')}
+                    </span>
+                  </div>
+                  <div style={{ marginBottom: 4 }}>
+                    <strong>跟进内容:</strong>{item.content}
+                  </div>
+                  {item.next_plan && (
+                    <div style={{ color: '#666' }}>
+                      <strong>下一步计划:</strong>{item.next_plan}
+                    </div>
+                  )}
+                </div>
+              </Timeline.Item>
+            ))}
+          </Timeline>
+        )}
+      </Card>
+
+      {/* 添加跟进记录弹窗 */}
+      <Modal
+        title="添加跟进记录"
+        open={followupVisible}
+        onCancel={() => setFollowupVisible(false)}
+        footer={null}
+      >
+        <Form form={form} onFinish={handleAddFollowup} layout="vertical">
+          <Form.Item
+            label="跟进方式"
+            name="followup_type"
+            rules={[{ required: true, message: '请选择跟进方式' }]}
+          >
+            <Select>
+              {Object.entries(FOLLOWUP_TYPES).map(([key, value]) => (
+                <Option key={key} value={key}>{value}</Option>
+              ))}
+            </Select>
+          </Form.Item>
+          <Form.Item
+            label="跟进内容"
+            name="content"
+            rules={[{ required: true, message: '请输入跟进内容' }]}
+          >
+            <TextArea rows={4} />
+          </Form.Item>
+          <Form.Item label="下一步计划" name="next_plan">
+            <TextArea rows={2} />
+          </Form.Item>
+          <Form.Item>
+            <Button type="primary" htmlType="submit">提交</Button>
+            <Button style={{ marginLeft: 8 }} onClick={() => setFollowupVisible(false)}>
+              取消
+            </Button>
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      {/* 更新状态弹窗 */}
+      <Modal
+        title="更新客户状态"
+        open={statusVisible}
+        onCancel={() => setStatusVisible(false)}
+        footer={null}
+      >
+        <Form onFinish={handleUpdateStatus} layout="vertical">
+          <Form.Item
+            label="状态"
+            name="status"
+            rules={[{ required: true, message: '请选择状态' }]}
+          >
+            <Select>
+              <Option value="following">跟进中</Option>
+              <Option value="won">已成交</Option>
+              <Option value="lost">已丢单</Option>
+            </Select>
+          </Form.Item>
+          <Form.Item>
+            <Button type="primary" htmlType="submit">提交</Button>
+            <Button style={{ marginLeft: 8 }} onClick={() => setStatusVisible(false)}>
+              取消
+            </Button>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}
+
+export default CustomerDetail

+ 192 - 0
client/src/pages/Customer/CustomerList.jsx

@@ -0,0 +1,192 @@
+import { Card, Table, Button, Space, Tag, Input, Select, message, Modal } from 'antd'
+import { PlusOutlined, EyeOutlined, EditOutlined, SearchOutlined } from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import request from '../../utils/request'
+import { CUSTOMER_STATUS } from '../../utils/constants'
+import dayjs from 'dayjs'
+
+const { Search } = Input
+const { Option } = Select
+
+const CustomerList = () => {
+  const [data, setData] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 })
+  const [filters, setFilters] = useState({ status: '', keyword: '' })
+  const navigate = useNavigate()
+
+  useEffect(() => {
+    loadData()
+  }, [pagination.current, pagination.pageSize, filters])
+
+  const loadData = async () => {
+    setLoading(true)
+    try {
+      const res = await request.get('/customers', {
+        params: {
+          page: pagination.current,
+          limit: pagination.pageSize,
+          ...filters
+        }
+      })
+      setData(res.data.customers)
+      setPagination(prev => ({ ...prev, total: res.data.pagination.total }))
+    } catch (error) {
+      console.error('加载数据失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const handleSearch = (value) => {
+    setFilters(prev => ({ ...prev, keyword: value }))
+    setPagination(prev => ({ ...prev, current: 1 }))
+  }
+
+  const handleStatusChange = (value) => {
+    setFilters(prev => ({ ...prev, status: value }))
+    setPagination(prev => ({ ...prev, current: 1 }))
+  }
+
+  const handleTableChange = (newPagination) => {
+    setPagination(newPagination)
+  }
+
+  const columns = [
+    {
+      title: '客户名称',
+      dataIndex: 'customer_name',
+      key: 'customer_name',
+      width: 200,
+      fixed: 'left',
+    },
+    {
+      title: '行业',
+      dataIndex: 'industry',
+      key: 'industry',
+      width: 120,
+    },
+    {
+      title: '地区',
+      dataIndex: 'region',
+      key: 'region',
+      width: 100,
+    },
+    {
+      title: '联系人',
+      dataIndex: 'contact_person',
+      key: 'contact_person',
+      width: 100,
+    },
+    {
+      title: '联系电话',
+      dataIndex: 'contact_phone',
+      key: 'contact_phone',
+      width: 120,
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+      width: 100,
+      render: (status) => (
+        <Tag color={CUSTOMER_STATUS[status]?.color}>
+          {CUSTOMER_STATUS[status]?.text}
+        </Tag>
+      ),
+    },
+    {
+      title: '负责人',
+      dataIndex: 'owner_name',
+      key: 'owner_name',
+      width: 100,
+    },
+    {
+      title: '保护期到期',
+      dataIndex: 'protected_end_date',
+      key: 'protected_end_date',
+      width: 160,
+      render: (text, record) => {
+        const isExpired = record.is_expired === 1
+        return (
+          <span style={{ color: isExpired ? '#ff4d4f' : undefined }}>
+            {dayjs(text).format('YYYY-MM-DD HH:mm')}
+          </span>
+        )
+      },
+    },
+    {
+      title: '报备时间',
+      dataIndex: 'report_time',
+      key: 'report_time',
+      width: 160,
+      render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      fixed: 'right',
+      width: 100,
+      render: (_, record) => (
+        <Space>
+          <Button
+            type="link"
+            size="small"
+            icon={<EyeOutlined />}
+            onClick={() => navigate(`/customers/${record.id}`)}
+          >
+            查看
+          </Button>
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      <Card>
+        <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
+          <Space>
+            <Search
+              placeholder="搜索客户名称、联系人、电话"
+              allowClear
+              onSearch={handleSearch}
+              style={{ width: 300 }}
+            />
+            <Select
+              placeholder="客户状态"
+              allowClear
+              style={{ width: 120 }}
+              onChange={handleStatusChange}
+            >
+              <Option value="following">跟进中</Option>
+              <Option value="won">已成交</Option>
+              <Option value="lost">已丢单</Option>
+              <Option value="released">已释放</Option>
+            </Select>
+          </Space>
+          <Button
+            type="primary"
+            icon={<PlusOutlined />}
+            onClick={() => navigate('/customers/create')}
+          >
+            报备客户
+          </Button>
+        </Space>
+
+        <Table
+          columns={columns}
+          dataSource={data}
+          rowKey="id"
+          loading={loading}
+          pagination={pagination}
+          onChange={handleTableChange}
+          scroll={{ x: 1400 }}
+        />
+      </Card>
+    </div>
+  )
+}
+
+export default CustomerList

+ 3 - 0
client/src/pages/Dashboard/index.css

@@ -0,0 +1,3 @@
+.dashboard {
+  /* Dashboard specific styles */
+}

+ 151 - 0
client/src/pages/Dashboard/index.jsx

@@ -0,0 +1,151 @@
+import { Card, Row, Col, Statistic, Table, Tag, Alert } from 'antd'
+import {
+  TeamOutlined,
+  CheckCircleOutlined,
+  CloseCircleOutlined,
+  SyncOutlined,
+  TrophyOutlined
+} from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import request from '../../utils/request'
+import dayjs from 'dayjs'
+import './index.css'
+
+const Dashboard = () => {
+  const [stats, setStats] = useState(null)
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    loadData()
+  }, [])
+
+  const loadData = async () => {
+    try {
+      const res = await request.get('/stats/dashboard')
+      setStats(res.data)
+    } catch (error) {
+      console.error('加载数据失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  if (loading) return null
+
+  const columns = [
+    {
+      title: '客户名称',
+      dataIndex: 'customer_name',
+      key: 'customer_name',
+    },
+    {
+      title: '行业',
+      dataIndex: 'industry',
+      key: 'industry',
+    },
+    {
+      title: '地区',
+      dataIndex: 'region',
+      key: 'region',
+    },
+    {
+      title: '保护期到期',
+      dataIndex: 'protected_end_date',
+      key: 'protected_end_date',
+      render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
+    },
+  ]
+
+  return (
+    <div className="dashboard">
+      <h2 style={{ marginBottom: 24 }}>工作台</h2>
+
+      <Row gutter={[16, 16]}>
+        <Col xs={24} sm={12} lg={6}>
+          <Card>
+            <Statistic
+              title="我的报备"
+              value={stats.summary.total}
+              prefix={<TeamOutlined />}
+              valueStyle={{ color: '#1890ff' }}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} lg={6}>
+          <Card>
+            <Statistic
+              title="跟进中"
+              value={stats.summary.following}
+              prefix={<SyncOutlined />}
+              valueStyle={{ color: '#faad14' }}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} lg={6}>
+          <Card>
+            <Statistic
+              title="已成交"
+              value={stats.summary.won}
+              prefix={<CheckCircleOutlined />}
+              valueStyle={{ color: '#52c41a' }}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} lg={6}>
+          <Card>
+            <Statistic
+              title="转化率"
+              value={stats.summary.conversion_rate}
+              prefix={<TrophyOutlined />}
+              valueStyle={{ color: '#eb2f96' }}
+            />
+          </Card>
+        </Col>
+      </Row>
+
+      <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
+        <Col xs={24} sm={12}>
+          <Card>
+            <Statistic
+              title="本月报备"
+              value={stats.summary.month_reports}
+              suffix="个"
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12}>
+          <Card>
+            <Statistic
+              title="本周跟进"
+              value={stats.summary.week_followups}
+              suffix="次"
+            />
+          </Card>
+        </Col>
+      </Row>
+
+      {stats.expiring_customers && stats.expiring_customers.length > 0 && (
+        <Card 
+          title="即将到期的客户" 
+          style={{ marginTop: 24 }}
+          extra={<Tag color="warning">提醒</Tag>}
+        >
+          <Alert
+            message="以下客户保护期即将到期,请及时跟进或申请延期"
+            type="warning"
+            showIcon
+            style={{ marginBottom: 16 }}
+          />
+          <Table
+            dataSource={stats.expiring_customers}
+            columns={columns}
+            pagination={false}
+            rowKey="id"
+          />
+        </Card>
+      )}
+    </div>
+  )
+}
+
+export default Dashboard

+ 39 - 0
client/src/pages/Login/index.css

@@ -0,0 +1,39 @@
+.login-container {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.login-card {
+  width: 400px;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+}
+
+.login-header {
+  text-align: center;
+  margin-bottom: 32px;
+}
+
+.login-header h1 {
+  font-size: 28px;
+  color: #1890ff;
+  margin-bottom: 8px;
+}
+
+.login-header p {
+  color: #666;
+  font-size: 14px;
+}
+
+.login-tip {
+  text-align: center;
+  color: #999;
+  font-size: 12px;
+  margin-top: 16px;
+}
+
+.login-tip p {
+  margin: 4px 0;
+}

+ 78 - 0
client/src/pages/Login/index.jsx

@@ -0,0 +1,78 @@
+import { Form, Input, Button, Card, message } from 'antd'
+import { UserOutlined, LockOutlined } from '@ant-design/icons'
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import request from '../../utils/request'
+import './index.css'
+
+const Login = ({ onLogin }) => {
+  const [loading, setLoading] = useState(false)
+  const navigate = useNavigate()
+
+  const onFinish = async (values) => {
+    setLoading(true)
+    try {
+      const res = await request.post('/auth/login', values)
+      localStorage.setItem('token', res.data.token)
+      localStorage.setItem('user', JSON.stringify(res.data.user))
+      message.success('登录成功')
+      onLogin(res.data.user)
+      navigate('/')
+    } catch (error) {
+      console.error('登录失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div className="login-container">
+      <Card className="login-card">
+        <div className="login-header">
+          <h1>客户报备管理系统</h1>
+          <p>Customer Registration CRM</p>
+        </div>
+        
+        <Form
+          name="login"
+          initialValues={{ username: 'admin', password: 'admin123' }}
+          onFinish={onFinish}
+          size="large"
+        >
+          <Form.Item
+            name="username"
+            rules={[{ required: true, message: '请输入用户名' }]}
+          >
+            <Input 
+              prefix={<UserOutlined />} 
+              placeholder="用户名" 
+            />
+          </Form.Item>
+
+          <Form.Item
+            name="password"
+            rules={[{ required: true, message: '请输入密码' }]}
+          >
+            <Input.Password
+              prefix={<LockOutlined />}
+              placeholder="密码"
+            />
+          </Form.Item>
+
+          <Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading} block>
+              登录
+            </Button>
+          </Form.Item>
+        </Form>
+
+        <div className="login-tip">
+          <p>默认账号:admin / admin123</p>
+          <p>测试账号:sales001 / 123456</p>
+        </div>
+      </Card>
+    </div>
+  )
+}
+
+export default Login

+ 173 - 0
client/src/pages/Pool/PoolList.jsx

@@ -0,0 +1,173 @@
+import { Card, Table, Button, Space, Input, Select, message, Modal } from 'antd'
+import { InboxOutlined, SearchOutlined } from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import request from '../../utils/request'
+import { INDUSTRIES, REGIONS } from '../../utils/constants'
+import dayjs from 'dayjs'
+
+const { Search } = Input
+const { Option } = Select
+
+const PoolList = () => {
+  const [data, setData] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 })
+  const [filters, setFilters] = useState({ industry: '', region: '', keyword: '' })
+
+  useEffect(() => {
+    loadData()
+  }, [pagination.current, pagination.pageSize, filters])
+
+  const loadData = async () => {
+    setLoading(true)
+    try {
+      const res = await request.get('/pool/customers', {
+        params: {
+          page: pagination.current,
+          limit: pagination.pageSize,
+          ...filters
+        }
+      })
+      setData(res.data.customers)
+      setPagination(prev => ({ ...prev, total: res.data.pagination.total }))
+    } catch (error) {
+      console.error('加载数据失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const handleClaim = (customerId, customerName) => {
+    Modal.confirm({
+      title: '确认领取客户',
+      content: `确定要领取客户 "${customerName}" 吗?领取后将获得30天保护期。`,
+      onOk: async () => {
+        try {
+          await request.post('/pool/claim', { customer_id: customerId })
+          message.success('客户领取成功')
+          loadData()
+        } catch (error) {
+          console.error('领取失败:', error)
+        }
+      }
+    })
+  }
+
+  const columns = [
+    {
+      title: '客户名称',
+      dataIndex: 'customer_name',
+      key: 'customer_name',
+      width: 200,
+      fixed: 'left',
+    },
+    {
+      title: '行业',
+      dataIndex: 'industry',
+      key: 'industry',
+      width: 120,
+    },
+    {
+      title: '地区',
+      dataIndex: 'region',
+      key: 'region',
+      width: 100,
+    },
+    {
+      title: '原负责人',
+      dataIndex: 'last_owner_name',
+      key: 'last_owner_name',
+      width: 100,
+    },
+    {
+      title: '释放原因',
+      dataIndex: 'release_reason',
+      key: 'release_reason',
+      width: 150,
+    },
+    {
+      title: '被领取次数',
+      dataIndex: 'claim_count',
+      key: 'claim_count',
+      width: 100,
+    },
+    {
+      title: '更新时间',
+      dataIndex: 'updated_at',
+      key: 'updated_at',
+      width: 160,
+      render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      fixed: 'right',
+      width: 100,
+      render: (_, record) => (
+        <Button
+          type="primary"
+          size="small"
+          onClick={() => handleClaim(record.id, record.customer_name)}
+        >
+          领取
+        </Button>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      <Card>
+        <Space style={{ marginBottom: 16 }} wrap>
+          <Search
+            placeholder="搜索客户名称"
+            allowClear
+            onSearch={(value) => {
+              setFilters(prev => ({ ...prev, keyword: value }))
+              setPagination(prev => ({ ...prev, current: 1 }))
+            }}
+            style={{ width: 300 }}
+          />
+          <Select
+            placeholder="选择行业"
+            allowClear
+            style={{ width: 150 }}
+            onChange={(value) => {
+              setFilters(prev => ({ ...prev, industry: value || '' }))
+              setPagination(prev => ({ ...prev, current: 1 }))
+            }}
+          >
+            {INDUSTRIES.map(item => (
+              <Option key={item} value={item}>{item}</Option>
+            ))}
+          </Select>
+          <Select
+            placeholder="选择地区"
+            allowClear
+            style={{ width: 120 }}
+            onChange={(value) => {
+              setFilters(prev => ({ ...prev, region: value || '' }))
+              setPagination(prev => ({ ...prev, current: 1 }))
+            }}
+          >
+            {REGIONS.map(item => (
+              <Option key={item} value={item}>{item}</Option>
+            ))}
+          </Select>
+        </Space>
+
+        <Table
+          columns={columns}
+          dataSource={data}
+          rowKey="id"
+          loading={loading}
+          pagination={pagination}
+          onChange={setPagination}
+          scroll={{ x: 1200 }}
+        />
+      </Card>
+    </div>
+  )
+}
+
+export default PoolList

+ 216 - 0
client/src/pages/Stats/SourceAnalysis.jsx

@@ -0,0 +1,216 @@
+import React, { useState, useEffect } from 'react';
+import { Card, Table, Spin, message, DatePicker, Row, Col } from 'antd';
+import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts';
+import request from '../../utils/request';
+import dayjs from 'dayjs';
+
+const { RangePicker } = DatePicker;
+
+const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D', '#FFC658', '#FF6B6B'];
+
+const SourceAnalysis = () => {
+  const [loading, setLoading] = useState(false);
+  const [data, setData] = useState([]);
+  const [dateRange, setDateRange] = useState([
+    dayjs().subtract(30, 'day'),
+    dayjs()
+  ]);
+
+  useEffect(() => {
+    fetchData();
+  }, []);
+
+  const fetchData = async () => {
+    setLoading(true);
+    try {
+      const params = {
+        start_date: dateRange[0].format('YYYY-MM-DD'),
+        end_date: dateRange[1].format('YYYY-MM-DD')
+      };
+      const response = await request.get('/stats/source-analysis', { params });
+      if (response.success) {
+        setData(response.data || []);
+      }
+    } catch (error) {
+      message.error('获取来源分析数据失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleDateChange = (dates) => {
+    if (dates) {
+      setDateRange(dates);
+    }
+  };
+
+  const handleSearch = () => {
+    fetchData();
+  };
+
+  const columns = [
+    {
+      title: '排名',
+      key: 'rank',
+      width: 80,
+      render: (text, record, index) => index + 1,
+    },
+    {
+      title: '来源渠道',
+      dataIndex: 'source',
+      key: 'source',
+    },
+    {
+      title: '客户数量',
+      dataIndex: 'count',
+      key: 'count',
+      sorter: (a, b) => a.count - b.count,
+      render: (count) => <strong>{count}</strong>,
+    },
+    {
+      title: '占比',
+      dataIndex: 'percentage',
+      key: 'percentage',
+      render: (percentage) => `${percentage}%`,
+    },
+    {
+      title: '成交数',
+      dataIndex: 'closed_count',
+      key: 'closed_count',
+      render: (count) => count || 0,
+    },
+    {
+      title: '成交率',
+      key: 'close_rate',
+      render: (record) => {
+        if (!record.count) return '0%';
+        const rate = ((record.closed_count || 0) / record.count * 100).toFixed(1);
+        return `${rate}%`;
+      },
+    },
+  ];
+
+  // 准备饼图数据
+  const chartData = data.map(item => ({
+    name: item.source,
+    value: item.count
+  }));
+
+  return (
+    <div>
+      <Card 
+        title="客户来源分析" 
+        extra={
+          <Row gutter={16}>
+            <Col>
+              <RangePicker
+                value={dateRange}
+                onChange={handleDateChange}
+                format="YYYY-MM-DD"
+              />
+            </Col>
+            <Col>
+              <a onClick={handleSearch}>查询</a>
+            </Col>
+          </Row>
+        }
+      >
+        <Spin spinning={loading}>
+          <Row gutter={16}>
+            {/* 饼图 */}
+            <Col span={12}>
+              <div style={{ height: 400 }}>
+                <h3 style={{ textAlign: 'center', marginBottom: 20 }}>客户来源分布</h3>
+                {chartData.length > 0 ? (
+                  <ResponsiveContainer width="100%" height="100%">
+                    <PieChart>
+                      <Pie
+                        data={chartData}
+                        cx="50%"
+                        cy="50%"
+                        labelLine={true}
+                        label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(1)}%`}
+                        outerRadius={120}
+                        fill="#8884d8"
+                        dataKey="value"
+                      >
+                        {chartData.map((entry, index) => (
+                          <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
+                        ))}
+                      </Pie>
+                      <Tooltip />
+                      <Legend />
+                    </PieChart>
+                  </ResponsiveContainer>
+                ) : (
+                  <div style={{ textAlign: 'center', padding: '100px 0', color: '#999' }}>
+                    暂无数据
+                  </div>
+                )}
+              </div>
+            </Col>
+
+            {/* 统计卡片 */}
+            <Col span={12}>
+              <Row gutter={[16, 16]}>
+                <Col span={24}>
+                  <Card>
+                    <div style={{ textAlign: 'center' }}>
+                      <div style={{ fontSize: 14, color: '#999', marginBottom: 8 }}>
+                        总客户数
+                      </div>
+                      <div style={{ fontSize: 28, fontWeight: 'bold', color: '#1890ff' }}>
+                        {data.reduce((sum, item) => sum + item.count, 0)}
+                      </div>
+                    </div>
+                  </Card>
+                </Col>
+                <Col span={24}>
+                  <Card>
+                    <div style={{ textAlign: 'center' }}>
+                      <div style={{ fontSize: 14, color: '#999', marginBottom: 8 }}>
+                        总成交数
+                      </div>
+                      <div style={{ fontSize: 28, fontWeight: 'bold', color: '#52c41a' }}>
+                        {data.reduce((sum, item) => sum + (item.closed_count || 0), 0)}
+                      </div>
+                    </div>
+                  </Card>
+                </Col>
+                <Col span={24}>
+                  <Card>
+                    <div style={{ textAlign: 'center' }}>
+                      <div style={{ fontSize: 14, color: '#999', marginBottom: 8 }}>
+                        平均成交率
+                      </div>
+                      <div style={{ fontSize: 28, fontWeight: 'bold', color: '#faad14' }}>
+                        {(() => {
+                          const totalCount = data.reduce((sum, item) => sum + item.count, 0);
+                          const totalClosed = data.reduce((sum, item) => sum + (item.closed_count || 0), 0);
+                          return totalCount > 0 ? `${(totalClosed / totalCount * 100).toFixed(1)}%` : '0%';
+                        })()}
+                      </div>
+                    </div>
+                  </Card>
+                </Col>
+              </Row>
+            </Col>
+          </Row>
+
+          {/* 数据表格 */}
+          <div style={{ marginTop: 24 }}>
+            <h3>详细数据</h3>
+            <Table
+              columns={columns}
+              dataSource={data}
+              rowKey="source"
+              pagination={false}
+            />
+          </div>
+        </Spin>
+      </Card>
+    </div>
+  );
+};
+
+export default SourceAnalysis;

+ 159 - 0
client/src/pages/Stats/TeamStats.jsx

@@ -0,0 +1,159 @@
+import { Card, Row, Col, Statistic, Table, DatePicker, Radio, Space } from 'antd'
+import { TeamOutlined, TrophyOutlined } from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
+import request from '../../utils/request'
+import dayjs from 'dayjs'
+
+const { RangePicker } = DatePicker
+
+const TeamStats = () => {
+  const [data, setData] = useState(null)
+  const [loading, setLoading] = useState(true)
+  const [dateRange, setDateRange] = useState([
+    dayjs().subtract(30, 'days'),
+    dayjs()
+  ])
+  const [period, setPeriod] = useState('day')
+
+  useEffect(() => {
+    loadData()
+  }, [dateRange, period])
+
+  const loadData = async () => {
+    setLoading(true)
+    try {
+      const res = await request.get('/stats/team', {
+        params: {
+          start_date: dateRange[0].format('YYYY-MM-DD'),
+          end_date: dateRange[1].format('YYYY-MM-DD'),
+          period
+        }
+      })
+      setData(res.data)
+    } catch (error) {
+      console.error('加载数据失败:', error)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  if (loading || !data) return null
+
+  const memberColumns = [
+    {
+      title: '姓名',
+      dataIndex: 'real_name',
+      key: 'real_name',
+    },
+    {
+      title: '报备总数',
+      dataIndex: 'total_customers',
+      key: 'total_customers',
+      sorter: (a, b) => a.total_customers - b.total_customers,
+    },
+    {
+      title: '跟进中',
+      dataIndex: 'following',
+      key: 'following',
+    },
+    {
+      title: '已成交',
+      dataIndex: 'won',
+      key: 'won',
+      sorter: (a, b) => a.won - b.won,
+    },
+    {
+      title: '已丢单',
+      dataIndex: 'lost',
+      key: 'lost',
+    },
+    {
+      title: '转化率',
+      dataIndex: 'conversion_rate',
+      key: 'conversion_rate',
+      sorter: (a, b) => parseFloat(a.conversion_rate) - parseFloat(b.conversion_rate),
+    },
+    {
+      title: '活跃天数',
+      dataIndex: 'active_days',
+      key: 'active_days',
+      sorter: (a, b) => a.active_days - b.active_days,
+    },
+  ]
+
+  return (
+    <div>
+      <Card>
+        <Space style={{ marginBottom: 16 }}>
+          <RangePicker
+            value={dateRange}
+            onChange={setDateRange}
+            format="YYYY-MM-DD"
+          />
+          <Radio.Group value={period} onChange={(e) => setPeriod(e.target.value)}>
+            <Radio.Button value="day">按天</Radio.Button>
+            <Radio.Button value="week">按周</Radio.Button>
+            <Radio.Button value="month">按月</Radio.Button>
+          </Radio.Group>
+        </Space>
+
+        <Row gutter={16} style={{ marginBottom: 24 }}>
+          <Col span={8}>
+            <Card>
+              <Statistic
+                title="团队报备总数"
+                value={data.team_summary.total}
+                prefix={<TeamOutlined />}
+              />
+            </Card>
+          </Col>
+          <Col span={8}>
+            <Card>
+              <Statistic
+                title="团队成交数"
+                value={data.team_summary.won}
+                prefix={<TrophyOutlined />}
+                valueStyle={{ color: '#52c41a' }}
+              />
+            </Card>
+          </Col>
+          <Col span={8}>
+            <Card>
+              <Statistic
+                title="跟进中"
+                value={data.team_summary.following}
+                valueStyle={{ color: '#faad14' }}
+              />
+            </Card>
+          </Col>
+        </Row>
+      </Card>
+
+      <Card title="报备趋势" style={{ marginTop: 16 }}>
+        <ResponsiveContainer width="100%" height={300}>
+          <LineChart data={data.timeline}>
+            <CartesianGrid strokeDasharray="3 3" />
+            <XAxis dataKey="period" />
+            <YAxis />
+            <Tooltip />
+            <Legend />
+            <Line type="monotone" dataKey="count" stroke="#1890ff" name="报备数" />
+            <Line type="monotone" dataKey="won_count" stroke="#52c41a" name="成交数" />
+          </LineChart>
+        </ResponsiveContainer>
+      </Card>
+
+      <Card title="成员业绩排名" style={{ marginTop: 16 }}>
+        <Table
+          columns={memberColumns}
+          dataSource={data.member_stats}
+          rowKey="id"
+          pagination={false}
+        />
+      </Card>
+    </div>
+  )
+}
+
+export default TeamStats

+ 76 - 0
client/src/utils/constants.js

@@ -0,0 +1,76 @@
+// 客户状态
+export const CUSTOMER_STATUS = {
+  following: { text: '跟进中', color: 'processing' },
+  won: { text: '已成交', color: 'success' },
+  lost: { text: '已丢单', color: 'error' },
+  released: { text: '已释放', color: 'default' }
+}
+
+// 跟进类型
+export const FOLLOWUP_TYPES = {
+  call: '电话',
+  visit: '拜访',
+  email: '邮件',
+  wechat: '微信',
+  other: '其他'
+}
+
+// 审批类型
+export const APPROVAL_TYPES = {
+  extension: '延期申请',
+  force_release: '强制释放',
+  collaboration: '协同跟进'
+}
+
+// 审批状态
+export const APPROVAL_STATUS = {
+  pending: { text: '待审批', color: 'warning' },
+  approved: { text: '已通过', color: 'success' },
+  rejected: { text: '已拒绝', color: 'error' }
+}
+
+// 用户角色
+export const USER_ROLES = {
+  sales: '销售',
+  sales_manager: '销售经理',
+  sales_director: '销售总监',
+  admin: '系统管理员'
+}
+
+// 客户来源
+export const CUSTOMER_SOURCES = [
+  '转介绍',
+  '展会',
+  '自主开发',
+  '电话营销',
+  '网络推广',
+  '其他'
+]
+
+// 行业列表
+export const INDUSTRIES = [
+  '互联网/软件',
+  '制造业',
+  '金融',
+  '教育',
+  '医疗',
+  '零售',
+  '房地产',
+  '物流',
+  '其他'
+]
+
+// 地区列表
+export const REGIONS = [
+  '北京',
+  '上海',
+  '广州',
+  '深圳',
+  '杭州',
+  '成都',
+  '重庆',
+  '武汉',
+  '西安',
+  '南京',
+  '其他'
+]

+ 52 - 0
client/src/utils/request.js

@@ -0,0 +1,52 @@
+import axios from 'axios'
+import { message } from 'antd'
+
+const request = axios.create({
+  baseURL: '/api',
+  timeout: 10000
+})
+
+// 请求拦截器
+request.interceptors.request.use(
+  config => {
+    const token = localStorage.getItem('token')
+    if (token) {
+      config.headers.Authorization = `Bearer ${token}`
+    }
+    return config
+  },
+  error => {
+    return Promise.reject(error)
+  }
+)
+
+// 响应拦截器
+request.interceptors.response.use(
+  response => {
+    const res = response.data
+    if (res.success === false) {
+      message.error(res.message || '请求失败')
+      return Promise.reject(new Error(res.message || '请求失败'))
+    }
+    return res
+  },
+  error => {
+    if (error.response) {
+      if (error.response.status === 401) {
+        message.error('登录已过期,请重新登录')
+        localStorage.removeItem('token')
+        localStorage.removeItem('user')
+        window.location.href = '/login'
+      } else if (error.response.status === 403) {
+        message.error('权限不足')
+      } else {
+        message.error(error.response.data?.message || '请求失败')
+      }
+    } else {
+      message.error('网络错误,请稍后重试')
+    }
+    return Promise.reject(error)
+  }
+)
+
+export default request

+ 19 - 0
client/vite.config.js

@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    port: 3001,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:3000',
+        changeOrigin: true
+      }
+    }
+  },
+  build: {
+    outDir: '../public/admin',
+    emptyOutDir: true
+  }
+})

+ 8 - 4
src/controllers/approvalController.js

@@ -95,6 +95,8 @@ exports.getPendingApprovals = async (req, res) => {
     `;
     const countParams = type ? [userId, type] : [userId];
     const [countResult] = await pool.query(countQuery, countParams);
+    
+    const total = countResult && countResult[0] ? countResult[0].total : 0;
 
     res.json({
       success: true,
@@ -103,8 +105,8 @@ exports.getPendingApprovals = async (req, res) => {
         pagination: {
           page: parseInt(page),
           limit: parseInt(limit),
-          total: countResult[0].total,
-          total_pages: Math.ceil(countResult[0].total / limit)
+          total,
+          total_pages: Math.ceil(total / limit)
         }
       }
     });
@@ -150,6 +152,8 @@ exports.getMyApprovals = async (req, res) => {
     `;
     const countParams = status ? [userId, status] : [userId];
     const [countResult] = await pool.query(countQuery, countParams);
+    
+    const total = countResult && countResult[0] ? countResult[0].total : 0;
 
     res.json({
       success: true,
@@ -158,8 +162,8 @@ exports.getMyApprovals = async (req, res) => {
         pagination: {
           page: parseInt(page),
           limit: parseInt(limit),
-          total: countResult[0].total,
-          total_pages: Math.ceil(countResult[0].total / limit)
+          total,
+          total_pages: Math.ceil(total / limit)
         }
       }
     });

+ 25 - 17
src/controllers/customerController.js

@@ -151,48 +151,56 @@ exports.getCustomers = async (req, res) => {
     const userId = req.user.id;
     const userRole = req.user.role;
 
-    let query = `
-      SELECT c.*, u.real_name as owner_name, u.team,
-        CASE 
-          WHEN c.protected_end_date < NOW() THEN 1
-          ELSE 0
-        END as is_expired
-      FROM customers c
-      LEFT JOIN users u ON c.sales_owner = u.id
-      WHERE 1=1
-    `;
+    // 构建 WHERE 条件
+    let whereClause = 'WHERE 1=1';
     const params = [];
 
     // 根据角色过滤数据
     if (userRole === 'sales') {
-      query += ' AND c.sales_owner = ?';
+      whereClause += ' AND c.sales_owner = ?';
       params.push(userId);
     } else if (userRole === 'sales_manager') {
-      query += ' AND u.team = ?';
+      whereClause += ' AND u.team = ?';
       params.push(req.user.team);
     }
     // admin 和 sales_director 可以查看所有客户
 
     // 状态过滤
     if (status) {
-      query += ' AND c.status = ?';
+      whereClause += ' AND c.status = ?';
       params.push(status);
     }
 
     // 关键词搜索
     if (keyword) {
-      query += ' AND (c.customer_name LIKE ? OR c.contact_person LIKE ? OR c.contact_phone LIKE ?)';
+      whereClause += ' AND (c.customer_name LIKE ? OR c.contact_person LIKE ? OR c.contact_phone LIKE ?)';
       const searchPattern = `%${keyword}%`;
       params.push(searchPattern, searchPattern, searchPattern);
     }
 
     // 获取总数
-    const countQuery = query.replace(/SELECT.*FROM/, 'SELECT COUNT(*) as total FROM');
+    const countQuery = `
+      SELECT COUNT(*) as total 
+      FROM customers c
+      LEFT JOIN users u ON c.sales_owner = u.id
+      ${whereClause}
+    `;
     const [countResult] = await pool.query(countQuery, params);
-    const total = countResult[0].total;
+    const total = countResult && countResult[0] ? countResult[0].total : 0;
 
     // 获取数据
-    query += ' ORDER BY c.created_at DESC LIMIT ? OFFSET ?';
+    const query = `
+      SELECT c.*, u.real_name as owner_name, u.team,
+        CASE 
+          WHEN c.protected_end_date < NOW() THEN 1
+          ELSE 0
+        END as is_expired
+      FROM customers c
+      LEFT JOIN users u ON c.sales_owner = u.id
+      ${whereClause}
+      ORDER BY c.created_at DESC 
+      LIMIT ? OFFSET ?
+    `;
     params.push(parseInt(limit), parseInt(offset));
     
     const [customers] = await pool.query(query, params);

+ 24 - 15
src/controllers/poolController.js

@@ -9,41 +9,48 @@ exports.getPoolCustomers = async (req, res) => {
     const { page = 1, limit = 20, industry, region, keyword } = req.query;
     const offset = (page - 1) * limit;
 
-    let query = `
-      SELECT c.*, u.real_name as last_owner_name,
-        (SELECT COUNT(*) FROM pool_claim_records WHERE customer_id = c.id) as claim_count
-      FROM customers c
-      LEFT JOIN users u ON c.sales_owner = u.id
-      WHERE c.is_in_pool = true AND c.status = 'released'
-    `;
+    // 构建 WHERE 条件
+    let whereClause = "WHERE c.is_in_pool = true AND c.status = 'released'";
     const params = [];
 
     // 行业过滤
     if (industry) {
-      query += ' AND c.industry = ?';
+      whereClause += ' AND c.industry = ?';
       params.push(industry);
     }
 
     // 地区过滤
     if (region) {
-      query += ' AND c.region = ?';
+      whereClause += ' AND c.region = ?';
       params.push(region);
     }
 
     // 关键词搜索
     if (keyword) {
-      query += ' AND (c.customer_name LIKE ? OR c.demand_description LIKE ?)';
+      whereClause += ' AND (c.customer_name LIKE ? OR c.demand_description LIKE ?)';
       const searchPattern = `%${keyword}%`;
       params.push(searchPattern, searchPattern);
     }
 
     // 获取总数
-    const countQuery = query.replace(/SELECT.*FROM/, 'SELECT COUNT(*) as total FROM');
+    const countQuery = `
+      SELECT COUNT(*) as total 
+      FROM customers c
+      ${whereClause}
+    `;
     const [countResult] = await pool.query(countQuery, params);
-    const total = countResult[0].total;
+    const total = countResult && countResult[0] ? countResult[0].total : 0;
 
     // 获取数据
-    query += ' ORDER BY c.updated_at DESC LIMIT ? OFFSET ?';
+    const query = `
+      SELECT c.*, u.real_name as last_owner_name,
+        (SELECT COUNT(*) FROM pool_claim_records WHERE customer_id = c.id) as claim_count
+      FROM customers c
+      LEFT JOIN users u ON c.sales_owner = u.id
+      ${whereClause}
+      ORDER BY c.updated_at DESC 
+      LIMIT ? OFFSET ?
+    `;
     params.push(parseInt(limit), parseInt(offset));
     
     const [customers] = await pool.query(query, params);
@@ -214,6 +221,8 @@ exports.getMyClaimRecords = async (req, res) => {
       'SELECT COUNT(*) as total FROM pool_claim_records WHERE user_id = ?',
       [userId]
     );
+    
+    const total = countResult && countResult[0] ? countResult[0].total : 0;
 
     res.json({
       success: true,
@@ -222,8 +231,8 @@ exports.getMyClaimRecords = async (req, res) => {
         pagination: {
           page: parseInt(page),
           limit: parseInt(limit),
-          total: countResult[0].total,
-          total_pages: Math.ceil(countResult[0].total / limit)
+          total,
+          total_pages: Math.ceil(total / limit)
         }
       }
     });