Browse Source

✨ feat(customer): add customer management module
- add customer entity, service and API routes
- implement customer list, create, edit and delete functions
- add customer search and pagination features

✨ feat(opportunity): add opportunity management module
- add opportunity entity, service and API routes
- implement opportunity list, create, edit and delete functions
- add opportunity stage management with color indicators

✨ feat(follow-up): add follow-up record management module
- add follow-up entity, service and API routes
- implement follow-up list, create, edit and delete functions
- add follow-up method management with color indicators

🔧 chore(api): add new API clients for customer, opportunity and follow-up
- update api.ts to include new client definitions
- add new API routes in server/api.ts

🔧 chore(entity): add database entities for sales management
- create customer, opportunity and follow-up entities with Zod schemas
- define relationships between entities
- add validation rules for entity fields

yourname 4 months ago
parent
commit
0da35ca071

+ 309 - 0
src/client/admin/pages/Customers.tsx

@@ -0,0 +1,309 @@
+import React, { useState } from 'react';
+import {
+  Button, Table, Space, Form, Input, Modal, Card, Typography, Popconfirm,
+  App
+} from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import dayjs from 'dayjs';
+import { customerClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+
+type CustomerListResponse = InferResponseType<typeof customerClient.$get, 200>;
+type CustomerDetailResponse = InferResponseType<typeof customerClient[':id']['$get'], 200>;
+type CreateCustomerRequest = InferRequestType<typeof customerClient.$post>['json'];
+type UpdateCustomerRequest = InferRequestType<typeof customerClient[':id']['$put']>['json'];
+
+const { Title } = Typography;
+
+// 客户管理页面
+export const CustomersPage = () => {
+  const { message } = App.useApp();
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    keyword: ''
+  });
+  const [modalVisible, setModalVisible] = useState(false);
+  const [modalTitle, setModalTitle] = useState('');
+  const [editingCustomer, setEditingCustomer] = useState<any>(null);
+  const [form] = Form.useForm();
+
+  const { data: customersData, isLoading, refetch } = useQuery({
+    queryKey: ['customers', searchParams],
+    queryFn: async () => {
+      const res = await customerClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.keyword
+        }
+      });
+      if (res.status !== 200) {
+        throw new Error('获取客户列表失败');
+      }
+      return await res.json();
+    }
+  });
+
+  const customers = customersData?.data || [];
+  const pagination = {
+    current: searchParams.page,
+    pageSize: searchParams.limit,
+    total: customersData?.pagination?.total || 0
+  };
+
+  // 处理搜索
+  const handleSearch = (values: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      keyword: values.keyword || '',
+      page: 1
+    }));
+  };
+
+  // 处理分页变化
+  const handleTableChange = (newPagination: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      page: newPagination.current,
+      limit: newPagination.pageSize
+    }));
+  };
+
+  // 打开创建客户模态框
+  const showCreateModal = () => {
+    setModalTitle('创建客户');
+    setEditingCustomer(null);
+    form.resetFields();
+    setModalVisible(true);
+  };
+
+  // 打开编辑客户模态框
+  const showEditModal = (customer: any) => {
+    setModalTitle('编辑客户');
+    setEditingCustomer(customer);
+    form.setFieldsValue(customer);
+    setModalVisible(true);
+  };
+
+  // 处理模态框确认
+  const handleModalOk = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      if (editingCustomer) {
+        // 编辑客户
+        const res = await customerClient[':id']['$put']({
+          param: { id: editingCustomer.id },
+          json: values
+        });
+        if (res.status !== 200) {
+          throw new Error('更新客户失败');
+        }
+        message.success('客户更新成功');
+      } else {
+        // 创建客户
+        const res = await customerClient.$post({
+          json: values
+        });
+        if (res.status !== 201) {
+          throw new Error('创建客户失败');
+        }
+        message.success('客户创建成功');
+      }
+      
+      setModalVisible(false);
+      form.resetFields();
+      refetch(); // 刷新客户列表
+    } catch (error) {
+      console.error('表单提交失败:', error);
+      message.error('操作失败,请重试');
+    }
+  };
+
+  // 处理删除客户
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await customerClient[':id']['$delete']({
+        param: { id }
+      });
+      if (res.status !== 204) {
+        throw new Error('删除客户失败');
+      }
+      message.success('客户删除成功');
+      refetch(); // 刷新客户列表
+    } catch (error) {
+      console.error('删除客户失败:', error);
+      message.error('删除失败,请重试');
+    }
+  };
+  
+  const columns = [
+    {
+      title: '客户姓名',
+      dataIndex: 'name',
+      key: 'name',
+    },
+    {
+      title: '联系电话',
+      dataIndex: 'phone',
+      key: 'phone',
+    },
+    {
+      title: '电子邮箱',
+      dataIndex: 'email',
+      key: 'email',
+    },
+    {
+      title: '公司名称',
+      dataIndex: 'company',
+      key: 'company',
+    },
+    {
+      title: '客户来源',
+      dataIndex: 'source',
+      key: 'source',
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      key: 'createdAt',
+      render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: any) => (
+        <Space size="middle">
+          <Button type="link" onClick={() => showEditModal(record)}>
+            编辑
+          </Button>
+          <Popconfirm
+            title="确定要删除此客户吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger>
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+  
+  return (
+    <div>
+      <div className="mb-6 flex justify-between items-center">
+        <Title level={2}>客户管理</Title>
+      </div>
+      <Card className="shadow-md transition-all duration-300 hover:shadow-lg">
+        <Form layout="inline" onFinish={handleSearch} style={{ marginBottom: 16, padding: '16px 0' }}>
+          <Form.Item name="keyword" label="搜索">
+            <Input placeholder="客户姓名/公司/电话" allowClear />
+          </Form.Item>
+          <Form.Item>
+            <Space>
+              <Button type="primary" htmlType="submit">
+                搜索
+              </Button>
+              <Button type="primary" onClick={showCreateModal}>
+                创建客户
+              </Button>
+            </Space>
+          </Form.Item>
+        </Form>
+
+        <Table
+          columns={columns}
+          dataSource={customers}
+          loading={isLoading}
+          rowKey="id"
+          pagination={{
+            ...pagination,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            showTotal: (total) => `共 ${total} 条记录`
+          }}
+          onChange={handleTableChange}
+          bordered
+          scroll={{ x: 'max-content' }}
+          rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        />
+      </Card>
+
+      {/* 创建/编辑客户模态框 */}
+      <Modal
+        title={modalTitle}
+        open={modalVisible}
+        onOk={handleModalOk}
+        onCancel={() => {
+          setModalVisible(false);
+          form.resetFields();
+        }}
+        width={600}
+        centered
+        destroyOnClose
+        maskClosable={false}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          labelCol={{ span: 5 }}
+          wrapperCol={{ span: 19 }}
+        >
+          <Form.Item
+            name="name"
+            label="客户姓名"
+            required
+            rules={[
+              { required: true, message: '请输入客户姓名' },
+              { min: 2, message: '客户姓名至少2个字符' }
+            ]}
+          >
+            <Input placeholder="请输入客户姓名" />
+          </Form.Item>
+
+          <Form.Item
+            name="phone"
+            label="联系电话"
+            rules={[
+              { required: false, message: '请输入联系电话' },
+              { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' }
+            ]}
+          >
+            <Input placeholder="请输入联系电话" />
+          </Form.Item>
+
+          <Form.Item
+            name="email"
+            label="电子邮箱"
+            rules={[
+              { required: false, message: '请输入电子邮箱' },
+              { type: 'email', message: '请输入有效的邮箱地址' }
+            ]}
+          >
+            <Input placeholder="请输入电子邮箱" />
+          </Form.Item>
+
+          <Form.Item
+            name="company"
+            label="公司名称"
+            rules={[{ required: false, message: '请输入公司名称' }]}
+          >
+            <Input placeholder="请输入公司名称" />
+          </Form.Item>
+
+          <Form.Item
+            name="source"
+            label="客户来源"
+            rules={[{ required: false, message: '请输入客户来源' }]}
+          >
+            <Input placeholder="请输入客户来源(如:网站、推荐、广告等)" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};

+ 381 - 0
src/client/admin/pages/FollowUps.tsx

@@ -0,0 +1,381 @@
+import React, { useState } from 'react';
+import {
+  Button, Table, Space, Form, Input, Select, Modal, Card, Typography, Popconfirm,
+  App, DatePicker,
+  Tag
+} from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import dayjs from 'dayjs';
+import { followUpClient, opportunityClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { FollowUpMethod } from '@/server/modules/follow-ups/follow-up.entity';
+
+type FollowUpListResponse = InferResponseType<typeof followUpClient.$get, 200>;
+type FollowUpDetailResponse = InferResponseType<typeof followUpClient[':id']['$get'], 200>;
+type CreateFollowUpRequest = InferRequestType<typeof followUpClient.$post>['json'];
+type UpdateFollowUpRequest = InferRequestType<typeof followUpClient[':id']['$put']>['json'];
+type OpportunityListResponse = InferResponseType<typeof opportunityClient.$get, 200>;
+
+const { Title } = Typography;
+const { TextArea } = Input;
+
+// 跟进记录管理页面
+export const FollowUpsPage = () => {
+  const { message } = App.useApp();
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    keyword: '',
+    method: ''
+  });
+  const [modalVisible, setModalVisible] = useState(false);
+  const [modalTitle, setModalTitle] = useState('');
+  const [editingFollowUp, setEditingFollowUp] = useState<any>(null);
+  const [form] = Form.useForm();
+  const [opportunities, setOpportunities] = useState<any[]>([]);
+
+  // 获取销售机会列表
+  const { data: opportunitiesData } = useQuery({
+    queryKey: ['allOpportunities'],
+    queryFn: async () => {
+      const res = await opportunityClient.$get({
+        query: {
+          page: 1,
+          pageSize: 1000
+        }
+      });
+      if (res.status !== 200) {
+        throw new Error('获取销售机会列表失败');
+      }
+      const data = await res.json();
+      return data.data || [];
+    }
+  });
+
+  React.useEffect(() => {
+    if (opportunitiesData) {
+      setOpportunities(opportunitiesData);
+    }
+  }, [opportunitiesData]);
+
+  // 获取跟进记录列表
+  const { data: followUpsData, isLoading, refetch } = useQuery({
+    queryKey: ['followUps', searchParams],
+    queryFn: async () => {
+      const res = await followUpClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.keyword
+        }
+      });
+      if (res.status !== 200) {
+        throw new Error('获取跟进记录列表失败');
+      }
+      return await res.json();
+    }
+  });
+
+  const followUps = followUpsData?.data || [];
+  const pagination = {
+    current: searchParams.page,
+    pageSize: searchParams.limit,
+    total: followUpsData?.pagination?.total || 0
+  };
+
+  // 处理搜索
+  const handleSearch = (values: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      keyword: values.keyword || '',
+      method: values.method || '',
+      page: 1
+    }));
+  };
+
+  // 处理分页变化
+  const handleTableChange = (newPagination: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      page: newPagination.current,
+      limit: newPagination.pageSize
+    }));
+  };
+
+  // 打开创建跟进记录模态框
+  const showCreateModal = () => {
+    setModalTitle('创建跟进记录');
+    setEditingFollowUp(null);
+    form.resetFields();
+    setModalVisible(true);
+  };
+
+  // 打开编辑跟进记录模态框
+  const showEditModal = (followUp: any) => {
+    setModalTitle('编辑跟进记录');
+    setEditingFollowUp(followUp);
+    form.setFieldsValue({
+      ...followUp,
+      nextFollowUpDate: followUp.nextFollowUpDate ? dayjs(followUp.nextFollowUpDate) : null
+    });
+    setModalVisible(true);
+  };
+
+  // 处理模态框确认
+  const handleModalOk = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      // 格式化日期
+      if (values.nextFollowUpDate) {
+        values.nextFollowUpDate = dayjs(values.nextFollowUpDate).format('YYYY-MM-DD');
+      }
+      
+      if (editingFollowUp) {
+        // 编辑跟进记录
+        const res = await followUpClient[':id']['$put']({
+          param: { id: editingFollowUp.id },
+          json: values
+        });
+        if (res.status !== 200) {
+          throw new Error('更新跟进记录失败');
+        }
+        message.success('跟进记录更新成功');
+      } else {
+        // 创建跟进记录
+        const res = await followUpClient.$post({
+          json: values
+        });
+        if (res.status !== 201) {
+          throw new Error('创建跟进记录失败');
+        }
+        message.success('跟进记录创建成功');
+      }
+      
+      setModalVisible(false);
+      form.resetFields();
+      refetch(); // 刷新跟进记录列表
+    } catch (error) {
+      console.error('表单提交失败:', error);
+      message.error('操作失败,请重试');
+    }
+  };
+
+  // 处理删除跟进记录
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await followUpClient[':id']['$delete']({
+        param: { id }
+      });
+      if (res.status !== 204) {
+        throw new Error('删除跟进记录失败');
+      }
+      message.success('跟进记录删除成功');
+      refetch(); // 刷新跟进记录列表
+    } catch (error) {
+      console.error('删除跟进记录失败:', error);
+      message.error('删除失败,请重试');
+    }
+  };
+  
+  // 跟进方式中文映射
+  const methodLabelMap: Record<FollowUpMethod, string> = {
+    [FollowUpMethod.PHONE]: '电话',
+    [FollowUpMethod.EMAIL]: '邮件',
+    [FollowUpMethod.MEETING]: '会议',
+    [FollowUpMethod.OTHER]: '其他'
+  };
+  
+  // 跟进方式颜色映射
+  const methodColorMap: Record<FollowUpMethod, string> = {
+    [FollowUpMethod.PHONE]: 'blue',
+    [FollowUpMethod.EMAIL]: 'green',
+    [FollowUpMethod.MEETING]: 'purple',
+    [FollowUpMethod.OTHER]: 'orange'
+  };
+  
+  const columns = [
+    {
+      title: '销售机会',
+      dataIndex: 'opportunityId',
+      key: 'opportunity',
+      render: (opportunityId: number) => {
+        const opportunity = opportunities.find(o => o.id === opportunityId);
+        return opportunity ? opportunity.title : '-';
+      }
+    },
+    {
+      title: '跟进方式',
+      dataIndex: 'method',
+      key: 'method',
+      render: (method: FollowUpMethod) => (
+        <Tag color={methodColorMap[method]}>
+          {methodLabelMap[method]}
+        </Tag>
+      ),
+    },
+    {
+      title: '跟进内容',
+      dataIndex: 'content',
+      key: 'content',
+      ellipsis: true,
+      width: 300
+    },
+    {
+      title: '下次跟进日期',
+      dataIndex: 'nextFollowUpDate',
+      key: 'nextFollowUpDate',
+      render: (date: string) => date ? dayjs(date).format('YYYY-MM-DD') : '-',
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      key: 'createdAt',
+      render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: any) => (
+        <Space size="middle">
+          <Button type="link" onClick={() => showEditModal(record)}>
+            编辑
+          </Button>
+          <Popconfirm
+            title="确定要删除此跟进记录吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger>
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+  
+  return (
+    <div>
+      <div className="mb-6 flex justify-between items-center">
+        <Title level={2}>跟进记录管理</Title>
+      </div>
+      <Card className="shadow-md transition-all duration-300 hover:shadow-lg">
+        <Form layout="inline" onFinish={handleSearch} style={{ marginBottom: 16, padding: '16px 0' }}>
+          <Form.Item name="keyword" label="搜索">
+            <Input placeholder="跟进内容" allowClear />
+          </Form.Item>
+          <Form.Item name="method" label="跟进方式">
+            <Select placeholder="全部方式" allowClear>
+              {Object.entries(FollowUpMethod).map(([value, key]) => (
+                <Select.Option key={key} value={key}>
+                  {methodLabelMap[key as FollowUpMethod]}
+                </Select.Option>
+              ))}
+            </Select>
+          </Form.Item>
+          <Form.Item>
+            <Space>
+              <Button type="primary" htmlType="submit">
+                搜索
+              </Button>
+              <Button type="primary" onClick={showCreateModal}>
+                创建跟进记录
+              </Button>
+            </Space>
+          </Form.Item>
+        </Form>
+
+        <Table
+          columns={columns}
+          dataSource={followUps}
+          loading={isLoading}
+          rowKey="id"
+          pagination={{
+            ...pagination,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            showTotal: (total) => `共 ${total} 条记录`
+          }}
+          onChange={handleTableChange}
+          bordered
+          scroll={{ x: 'max-content' }}
+          rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        />
+      </Card>
+
+      {/* 创建/编辑跟进记录模态框 */}
+      <Modal
+        title={modalTitle}
+        open={modalVisible}
+        onOk={handleModalOk}
+        onCancel={() => {
+          setModalVisible(false);
+          form.resetFields();
+        }}
+        width={600}
+        centered
+        destroyOnClose
+        maskClosable={false}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          labelCol={{ span: 5 }}
+          wrapperCol={{ span: 19 }}
+        >
+          <Form.Item
+            name="opportunityId"
+            label="销售机会"
+            required
+            rules={[{ required: true, message: '请选择销售机会' }]}
+          >
+            <Select placeholder="请选择销售机会">
+              {opportunities.map(opportunity => (
+                <Select.Option key={opportunity.id} value={opportunity.id}>
+                  {opportunity.title}
+                </Select.Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            name="method"
+            label="跟进方式"
+            required
+            rules={[{ required: true, message: '请选择跟进方式' }]}
+          >
+            <Select placeholder="请选择跟进方式">
+              {Object.entries(FollowUpMethod).map(([value, key]) => (
+                <Select.Option key={key} value={key}>
+                  {methodLabelMap[key as FollowUpMethod]}
+                </Select.Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            name="content"
+            label="跟进内容"
+            required
+            rules={[
+              { required: true, message: '请输入跟进内容' },
+              { min: 5, message: '跟进内容至少5个字符' }
+            ]}
+          >
+            <TextArea rows={4} placeholder="请详细描述跟进情况、客户反馈等信息" />
+          </Form.Item>
+
+          <Form.Item
+            name="nextFollowUpDate"
+            label="下次跟进日期"
+            rules={[{ required: false, message: '请选择下次跟进日期' }]}
+          >
+            <DatePicker placeholder="请选择下次跟进日期" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};

+ 413 - 0
src/client/admin/pages/Opportunities.tsx

@@ -0,0 +1,413 @@
+import React, { useState } from 'react';
+import {
+  Button, Table, Space, Form, Input, Select, Modal, Card, Typography, Popconfirm, Tag,
+  App
+} from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import dayjs from 'dayjs';
+import { opportunityClient, customerClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { OpportunityStage } from '@/server/modules/opportunities/opportunity.entity';
+
+type OpportunityListResponse = InferResponseType<typeof opportunityClient.$get, 200>;
+type OpportunityDetailResponse = InferResponseType<typeof opportunityClient[':id']['$get'], 200>;
+type CreateOpportunityRequest = InferRequestType<typeof opportunityClient.$post>['json'];
+type UpdateOpportunityRequest = InferRequestType<typeof opportunityClient[':id']['$put']>['json'];
+type CustomerListResponse = InferResponseType<typeof customerClient.$get, 200>;
+
+const { Title } = Typography;
+
+// 销售机会管理页面
+export const OpportunitiesPage = () => {
+  const { message } = App.useApp();
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    keyword: '',
+    stage: ''
+  });
+  const [modalVisible, setModalVisible] = useState(false);
+  const [modalTitle, setModalTitle] = useState('');
+  const [editingOpportunity, setEditingOpportunity] = useState<any>(null);
+  const [form] = Form.useForm();
+  const [customers, setCustomers] = useState<any[]>([]);
+
+  // 获取客户列表
+  const { data: customersData } = useQuery({
+    queryKey: ['allCustomers'],
+    queryFn: async () => {
+      const res = await customerClient.$get({
+        query: {
+          page: 1,
+          pageSize: 1000
+        }
+      });
+      if (res.status !== 200) {
+        throw new Error('获取客户列表失败');
+      }
+      const data = await res.json();
+      return data.data || [];
+    }
+  });
+
+  React.useEffect(() => {
+    if (customersData) {
+      setCustomers(customersData);
+    }
+  }, [customersData]);
+
+  // 获取销售机会列表
+  const { data: opportunitiesData, isLoading, refetch } = useQuery({
+    queryKey: ['opportunities', searchParams],
+    queryFn: async () => {
+      const res = await opportunityClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.keyword
+        }
+      });
+      if (res.status !== 200) {
+        throw new Error('获取销售机会列表失败');
+      }
+      return await res.json();
+    }
+  });
+
+  const opportunities = opportunitiesData?.data || [];
+  const pagination = {
+    current: searchParams.page,
+    pageSize: searchParams.limit,
+    total: opportunitiesData?.pagination?.total || 0
+  };
+
+  // 处理搜索
+  const handleSearch = (values: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      keyword: values.keyword || '',
+      stage: values.stage || '',
+      page: 1
+    }));
+  };
+
+  // 处理分页变化
+  const handleTableChange = (newPagination: any) => {
+    setSearchParams(prev => ({
+      ...prev,
+      page: newPagination.current,
+      limit: newPagination.pageSize
+    }));
+  };
+
+  // 打开创建销售机会模态框
+  const showCreateModal = () => {
+    setModalTitle('创建销售机会');
+    setEditingOpportunity(null);
+    form.resetFields();
+    setModalVisible(true);
+  };
+
+  // 打开编辑销售机会模态框
+  const showEditModal = (opportunity: any) => {
+    setModalTitle('编辑销售机会');
+    setEditingOpportunity(opportunity);
+    form.setFieldsValue({
+      ...opportunity,
+      expectedCloseDate: opportunity.expectedCloseDate ? dayjs(opportunity.expectedCloseDate) : null
+    });
+    setModalVisible(true);
+  };
+
+  // 处理模态框确认
+  const handleModalOk = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      // 格式化日期
+      if (values.expectedCloseDate) {
+        values.expectedCloseDate = dayjs(values.expectedCloseDate).format('YYYY-MM-DD');
+      }
+      
+      if (editingOpportunity) {
+        // 编辑销售机会
+        const res = await opportunityClient[':id']['$put']({
+          param: { id: editingOpportunity.id },
+          json: values
+        });
+        if (res.status !== 200) {
+          throw new Error('更新销售机会失败');
+        }
+        message.success('销售机会更新成功');
+      } else {
+        // 创建销售机会
+        const res = await opportunityClient.$post({
+          json: values
+        });
+        if (res.status !== 201) {
+          throw new Error('创建销售机会失败');
+        }
+        message.success('销售机会创建成功');
+      }
+      
+      setModalVisible(false);
+      form.resetFields();
+      refetch(); // 刷新销售机会列表
+    } catch (error) {
+      console.error('表单提交失败:', error);
+      message.error('操作失败,请重试');
+    }
+  };
+
+  // 处理删除销售机会
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await opportunityClient[':id']['$delete']({
+        param: { id }
+      });
+      if (res.status !== 204) {
+        throw new Error('删除销售机会失败');
+      }
+      message.success('销售机会删除成功');
+      refetch(); // 刷新销售机会列表
+    } catch (error) {
+      console.error('删除销售机会失败:', error);
+      message.error('删除失败,请重试');
+    }
+  };
+  
+  // 销售阶段中文映射
+  const stageLabelMap: Record<OpportunityStage, string> = {
+    [OpportunityStage.INITIAL_CONTACT]: '初步接触',
+    [OpportunityStage.NEEDS_ANALYSIS]: '需求确认',
+    [OpportunityStage.SOLUTION_PROPOSAL]: '方案制定',
+    [OpportunityStage.NEGOTIATION]: '谈判阶段',
+    [OpportunityStage.CLOSED_WON]: '成交',
+    [OpportunityStage.CLOSED_LOST]: '丢失'
+  };
+  
+  // 销售阶段颜色映射
+  const stageColorMap: Record<OpportunityStage, string> = {
+    [OpportunityStage.INITIAL_CONTACT]: 'blue',
+    [OpportunityStage.NEEDS_ANALYSIS]: 'purple',
+    [OpportunityStage.SOLUTION_PROPOSAL]: 'orange',
+    [OpportunityStage.NEGOTIATION]: 'gold',
+    [OpportunityStage.CLOSED_WON]: 'green',
+    [OpportunityStage.CLOSED_LOST]: 'red'
+  };
+  
+  const columns = [
+    {
+      title: '机会名称',
+      dataIndex: 'title',
+      key: 'title',
+    },
+    {
+      title: '客户',
+      dataIndex: 'customerId',
+      key: 'customer',
+      render: (customerId: number) => {
+        const customer = customers.find(c => c.id === customerId);
+        return customer ? customer.name : '-';
+      }
+    },
+    {
+      title: '预计金额',
+      dataIndex: 'amount',
+      key: 'amount',
+      render: (amount: number) => `¥${amount.toFixed(2)}`
+    },
+    {
+      title: '销售阶段',
+      dataIndex: 'stage',
+      key: 'stage',
+      render: (stage: OpportunityStage) => (
+        <Tag color={stageColorMap[stage]}>
+          {stageLabelMap[stage]}
+        </Tag>
+      ),
+    },
+    {
+      title: '预计成交日期',
+      dataIndex: 'expectedCloseDate',
+      key: 'expectedCloseDate',
+      render: (date: string) => date ? dayjs(date).format('YYYY-MM-DD') : '-',
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createdAt',
+      key: 'createdAt',
+      render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      render: (_: any, record: any) => (
+        <Space size="middle">
+          <Button type="link" onClick={() => showEditModal(record)}>
+            编辑
+          </Button>
+          <Popconfirm
+            title="确定要删除此销售机会吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger>
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+  
+  return (
+    <div>
+      <div className="mb-6 flex justify-between items-center">
+        <Title level={2}>销售机会管理</Title>
+      </div>
+      <Card className="shadow-md transition-all duration-300 hover:shadow-lg">
+        <Form layout="inline" onFinish={handleSearch} style={{ marginBottom: 16, padding: '16px 0' }}>
+          <Form.Item name="keyword" label="搜索">
+            <Input placeholder="机会名称/描述" allowClear />
+          </Form.Item>
+          <Form.Item name="stage" label="销售阶段">
+            <Select placeholder="全部阶段" allowClear>
+              {Object.entries(OpportunityStage).map(([value, key]) => (
+                <Select.Option key={key} value={key}>
+                  {stageLabelMap[key as OpportunityStage]}
+                </Select.Option>
+              ))}
+            </Select>
+          </Form.Item>
+          <Form.Item>
+            <Space>
+              <Button type="primary" htmlType="submit">
+                搜索
+              </Button>
+              <Button type="primary" onClick={showCreateModal}>
+                创建销售机会
+              </Button>
+            </Space>
+          </Form.Item>
+        </Form>
+
+        <Table
+          columns={columns}
+          dataSource={opportunities}
+          loading={isLoading}
+          rowKey="id"
+          pagination={{
+            ...pagination,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            showTotal: (total) => `共 ${total} 条记录`
+          }}
+          onChange={handleTableChange}
+          bordered
+          scroll={{ x: 'max-content' }}
+          rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        />
+      </Card>
+
+      {/* 创建/编辑销售机会模态框 */}
+      <Modal
+        title={modalTitle}
+        open={modalVisible}
+        onOk={handleModalOk}
+        onCancel={() => {
+          setModalVisible(false);
+          form.resetFields();
+        }}
+        width={600}
+        centered
+        destroyOnClose
+        maskClosable={false}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          labelCol={{ span: 5 }}
+          wrapperCol={{ span: 19 }}
+        >
+          <Form.Item
+            name="customerId"
+            label="客户"
+            required
+            rules={[{ required: true, message: '请选择客户' }]}
+          >
+            <Select placeholder="请选择客户">
+              {customers.map(customer => (
+                <Select.Option key={customer.id} value={customer.id}>
+                  {customer.name} {customer.company ? `(${customer.company})` : ''}
+                </Select.Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            name="title"
+            label="机会名称"
+            required
+            rules={[
+              { required: true, message: '请输入机会名称' },
+              { min: 2, message: '机会名称至少2个字符' }
+            ]}
+          >
+            <Input placeholder="请输入销售机会名称" />
+          </Form.Item>
+
+          <Form.Item
+            name="amount"
+            label="预计金额"
+            required
+            rules={[
+              { required: true, message: '请输入预计金额' },
+              { type: 'number', message: '请输入有效的数字' },
+              { min: 0, message: '金额不能为负数' }
+            ]}
+          >
+            <Input 
+              type="number" 
+              placeholder="请输入预计金额" 
+              formatter={value => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
+              parser={value => value!.replace(/\¥\s?|(,*)/g, '')}
+            />
+          </Form.Item>
+
+          <Form.Item
+            name="stage"
+            label="销售阶段"
+            required
+            rules={[{ required: true, message: '请选择销售阶段' }]}
+          >
+            <Select placeholder="请选择销售阶段">
+              {Object.entries(OpportunityStage).map(([value, key]) => (
+                <Select.Option key={key} value={key}>
+                  {stageLabelMap[key as OpportunityStage]}
+                </Select.Option>
+              ))}
+            </Select>
+          </Form.Item>
+
+          <Form.Item
+            name="expectedCloseDate"
+            label="预计成交日期"
+            rules={[{ required: false, message: '请选择预计成交日期' }]}
+          >
+            <Input type="date" />
+          </Form.Item>
+
+          <Form.Item
+            name="description"
+            label="机会描述"
+            rules={[{ required: false, message: '请输入机会描述' }]}
+          >
+            <Input.TextArea rows={4} placeholder="请输入销售机会描述" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};

+ 13 - 1
src/client/api.ts

@@ -1,7 +1,7 @@
 import axios, { isAxiosError } from 'axios';
 import { hc } from 'hono/client'
 import type {
-  AuthRoutes, UserRoutes, RoleRoutes
+  AuthRoutes, UserRoutes, RoleRoutes, CustomerRoutes, OpportunityRoutes, FollowUpRoutes
 } from '@/server/api';
 
 // 创建 axios 适配器
@@ -70,3 +70,15 @@ export const userClient = hc<UserRoutes>('/', {
 export const roleClient = hc<RoleRoutes>('/', {
   fetch: axiosFetch,
 }).api.v1.roles;
+
+export const customerClient = hc<CustomerRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1.customers;
+
+export const opportunityClient = hc<OpportunityRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1.opportunities;
+
+export const followUpClient = hc<FollowUpRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['follow-ups'];

+ 9 - 0
src/server/api.ts

@@ -3,6 +3,9 @@ import { errorHandler } from './utils/errorHandler'
 import usersRouter from './api/users/index'
 import authRoute from './api/auth/index'
 import rolesRoute from './api/roles/index'
+import customerRoutes from './api/customers/index'
+import opportunityRoutes from './api/opportunities/index'
+import followUpRoutes from './api/follow-ups/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 
@@ -53,9 +56,15 @@ if(!import.meta.env.PROD){
 const userRoutes = api.route('/api/v1/users', usersRouter)
 const authRoutes = api.route('/api/v1/auth', authRoute)
 const roleRoutes = api.route('/api/v1/roles', rolesRoute)
+const customerApiRoutes = api.route('/api/v1/customers', customerRoutes)
+const opportunityApiRoutes = api.route('/api/v1/opportunities', opportunityRoutes)
+const followUpApiRoutes = api.route('/api/v1/follow-ups', followUpRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
 export type RoleRoutes = typeof roleRoutes
+export type CustomerRoutes = typeof customerApiRoutes
+export type OpportunityRoutes = typeof opportunityApiRoutes
+export type FollowUpRoutes = typeof followUpApiRoutes
 
 export default api

+ 16 - 0
src/server/api/customers/index.ts

@@ -0,0 +1,16 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { Customer } from '@/server/modules/customers/customer.entity';
+import { CustomerSchema, CreateCustomerDto, UpdateCustomerDto } from '@/server/modules/customers/customer.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const customerRoutes = createCrudRoutes({
+  entity: Customer,
+  createSchema: CreateCustomerDto,
+  updateSchema: UpdateCustomerDto,
+  getSchema: CustomerSchema,
+  listSchema: CustomerSchema,
+  searchFields: ['name', 'company', 'phone', 'email'],
+  middleware: [authMiddleware]
+});
+
+export default customerRoutes;

+ 16 - 0
src/server/api/follow-ups/index.ts

@@ -0,0 +1,16 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { FollowUp } from '@/server/modules/follow-ups/follow-up.entity';
+import { FollowUpSchema, CreateFollowUpDto, UpdateFollowUpDto } from '@/server/modules/follow-ups/follow-up.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const followUpRoutes = createCrudRoutes({
+  entity: FollowUp,
+  createSchema: CreateFollowUpDto,
+  updateSchema: UpdateFollowUpDto,
+  getSchema: FollowUpSchema,
+  listSchema: FollowUpSchema,
+  searchFields: ['content', 'method'],
+  middleware: [authMiddleware]
+});
+
+export default followUpRoutes;

+ 16 - 0
src/server/api/opportunities/index.ts

@@ -0,0 +1,16 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { Opportunity } from '@/server/modules/opportunities/opportunity.entity';
+import { OpportunitySchema, CreateOpportunityDto, UpdateOpportunityDto } from '@/server/modules/opportunities/opportunity.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const opportunityRoutes = createCrudRoutes({
+  entity: Opportunity,
+  createSchema: CreateOpportunityDto,
+  updateSchema: UpdateOpportunityDto,
+  getSchema: OpportunitySchema,
+  listSchema: OpportunitySchema,
+  searchFields: ['title', 'description', 'stage'],
+  middleware: [authMiddleware]
+});
+
+export default opportunityRoutes;

+ 117 - 0
src/server/modules/customers/customer.entity.ts

@@ -0,0 +1,117 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+@Entity('customer')
+export class Customer {
+  @PrimaryGeneratedColumn({ unsigned: true, comment: '客户ID' })
+  id!: number;
+
+  @Column({ name: 'name', type: 'varchar', length: 255, comment: '客户姓名' })
+  name!: string;
+
+  @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true, comment: '联系电话' })
+  phone!: string | null;
+
+  @Column({ name: 'email', type: 'varchar', length: 255, nullable: true, comment: '电子邮箱' })
+  email!: string | null;
+
+  @Column({ name: 'company', type: 'varchar', length: 255, nullable: true, comment: '公司名称' })
+  company!: string | null;
+
+  @Column({ name: 'source', type: 'varchar', length: 50, nullable: true, comment: '客户来源' })
+  source!: string | null;
+
+  @Column({ name: 'is_deleted', type: 'tinyint', default: 0, comment: '是否删除(0:未删除,1:已删除)' })
+  isDeleted!: number;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp', comment: '创建时间' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', comment: '更新时间' })
+  updatedAt!: Date;
+}
+
+export const CustomerSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '客户ID',
+    example: 1
+  }),
+  name: z.string().max(255).openapi({
+    description: '客户姓名',
+    example: '张三'
+  }),
+  phone: z.string().max(20).nullable().openapi({
+    description: '联系电话',
+    example: '13800138000'
+  }),
+  email: z.string().email().nullable().openapi({
+    description: '电子邮箱',
+    example: 'zhangsan@example.com'
+  }),
+  company: z.string().max(255).nullable().openapi({
+    description: '公司名称',
+    example: '示例科技有限公司'
+  }),
+  source: z.string().max(50).nullable().openapi({
+    description: '客户来源',
+    example: '网站'
+  }),
+  isDeleted: z.number().int().min(0).max(1).openapi({
+    description: '是否删除(0:未删除,1:已删除)',
+    example: 0
+  }),
+  createdAt: z.date().openapi({
+    description: '创建时间',
+    example: '2023-01-01T00:00:00Z'
+  }),
+  updatedAt: z.date().openapi({
+    description: '更新时间',
+    example: '2023-01-01T00:00:00Z'
+  })
+});
+
+export const CreateCustomerDto = z.object({
+  name: z.string().max(255).openapi({
+    description: '客户姓名',
+    example: '张三'
+  }),
+  phone: z.string().max(20).nullable().openapi({
+    description: '联系电话',
+    example: '13800138000'
+  }),
+  email: z.string().email().nullable().openapi({
+    description: '电子邮箱',
+    example: 'zhangsan@example.com'
+  }),
+  company: z.string().max(255).nullable().openapi({
+    description: '公司名称',
+    example: '示例科技有限公司'
+  }),
+  source: z.string().max(50).nullable().openapi({
+    description: '客户来源',
+    example: '网站'
+  })
+});
+
+export const UpdateCustomerDto = z.object({
+  name: z.string().max(255).optional().openapi({
+    description: '客户姓名',
+    example: '张三'
+  }),
+  phone: z.string().max(20).nullable().optional().openapi({
+    description: '联系电话',
+    example: '13800138000'
+  }),
+  email: z.string().email().nullable().optional().openapi({
+    description: '电子邮箱',
+    example: 'zhangsan@example.com'
+  }),
+  company: z.string().max(255).nullable().optional().openapi({
+    description: '公司名称',
+    example: '示例科技有限公司'
+  }),
+  source: z.string().max(50).nullable().optional().openapi({
+    description: '客户来源',
+    example: '网站'
+  })
+});

+ 21 - 0
src/server/modules/customers/customer.service.ts

@@ -0,0 +1,21 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { Customer } from './customer.entity';
+
+export class CustomerService extends GenericCrudService<Customer> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, Customer);
+  }
+  
+  // 可以添加客户特有的业务逻辑方法
+  async getCustomerByName(name: string): Promise<Customer | null> {
+    return this.repository.findOneBy({ name });
+  }
+  
+  async getCustomersByCompany(company: string): Promise<Customer[]> {
+    return this.repository.find({
+      where: { company },
+      order: { createdAt: 'DESC' }
+    });
+  }
+}

+ 134 - 0
src/server/modules/follow-ups/follow-up.entity.ts

@@ -0,0 +1,134 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+import { Opportunity } from '../opportunities/opportunity.entity';
+
+export enum FollowUpMethod {
+  PHONE = 'phone',
+  EMAIL = 'email',
+  MEETING = 'meeting',
+  OTHER = 'other'
+}
+
+@Entity('follow_up')
+export class FollowUp {
+  @PrimaryGeneratedColumn({ unsigned: true, comment: '跟进记录ID' })
+  id!: number;
+
+  @Column({ name: 'opportunity_id', type: 'int', unsigned: true, comment: '销售机会ID' })
+  opportunityId!: number;
+
+  @ManyToOne(() => Opportunity)
+  @JoinColumn({ name: 'opportunity_id' })
+  opportunity!: Opportunity;
+
+  @Column({ 
+    name: 'method', 
+    type: 'enum', 
+    enum: FollowUpMethod, 
+    comment: '跟进方式(phone:电话,email:邮件,meeting:会议,other:其他)' 
+  })
+  method!: FollowUpMethod;
+
+  @Column({ name: 'content', type: 'text', comment: '跟进内容' })
+  content!: string;
+
+  @Column({ name: 'next_follow_up_date', type: 'date', nullable: true, comment: '下次跟进日期' })
+  nextFollowUpDate!: Date | null;
+
+  @Column({ name: 'is_deleted', type: 'tinyint', default: 0, comment: '是否删除(0:未删除,1:已删除)' })
+  isDeleted!: number;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp', comment: '创建时间' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', comment: '更新时间' })
+  updatedAt!: Date;
+}
+
+export const FollowUpSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '跟进记录ID',
+    example: 1
+  }),
+  opportunityId: z.number().int().positive().openapi({
+    description: '销售机会ID',
+    example: 1
+  }),
+  method: z.enum([
+    FollowUpMethod.PHONE,
+    FollowUpMethod.EMAIL,
+    FollowUpMethod.MEETING,
+    FollowUpMethod.OTHER
+  ]).openapi({
+    description: '跟进方式',
+    example: FollowUpMethod.PHONE
+  }),
+  content: z.string().openapi({
+    description: '跟进内容',
+    example: '与客户电话沟通,了解到客户对产品功能有进一步需求'
+  }),
+  nextFollowUpDate: z.coerce.date().nullable().openapi({
+    description: '下次跟进日期',
+    example: '2023-06-15'
+  }),
+  isDeleted: z.number().int().min(0).max(1).openapi({
+    description: '是否删除(0:未删除,1:已删除)',
+    example: 0
+  }),
+  createdAt: z.date().openapi({
+    description: '创建时间',
+    example: '2023-06-01T10:30:00Z'
+  }),
+  updatedAt: z.date().openapi({
+    description: '更新时间',
+    example: '2023-06-01T10:30:00Z'
+  })
+});
+
+export const CreateFollowUpDto = z.object({
+  opportunityId: z.coerce.number().int().positive().openapi({
+    description: '销售机会ID',
+    example: 1
+  }),
+  method: z.enum([
+    FollowUpMethod.PHONE,
+    FollowUpMethod.EMAIL,
+    FollowUpMethod.MEETING,
+    FollowUpMethod.OTHER
+  ]).openapi({
+    description: '跟进方式',
+    example: FollowUpMethod.PHONE
+  }),
+  content: z.string().openapi({
+    description: '跟进内容',
+    example: '与客户电话沟通,了解到客户对产品功能有进一步需求'
+  }),
+  nextFollowUpDate: z.coerce.date().nullable().optional().openapi({
+    description: '下次跟进日期',
+    example: '2023-06-15'
+  })
+});
+
+export const UpdateFollowUpDto = z.object({
+  opportunityId: z.coerce.number().int().positive().optional().openapi({
+    description: '销售机会ID',
+    example: 1
+  }),
+  method: z.enum([
+    FollowUpMethod.PHONE,
+    FollowUpMethod.EMAIL,
+    FollowUpMethod.MEETING,
+    FollowUpMethod.OTHER
+  ]).optional().openapi({
+    description: '跟进方式',
+    example: FollowUpMethod.PHONE
+  }),
+  content: z.string().optional().openapi({
+    description: '跟进内容',
+    example: '与客户电话沟通,了解到客户对产品功能有进一步需求'
+  }),
+  nextFollowUpDate: z.coerce.date().nullable().optional().openapi({
+    description: '下次跟进日期',
+    example: '2023-06-15'
+  })
+});

+ 41 - 0
src/server/modules/follow-ups/follow-up.service.ts

@@ -0,0 +1,41 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { FollowUp, FollowUpMethod } from './follow-up.entity';
+
+export class FollowUpService extends GenericCrudService<FollowUp> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, FollowUp);
+  }
+  
+  // 根据销售机会ID获取跟进记录
+  async getByOpportunityId(opportunityId: number): Promise<FollowUp[]> {
+    return this.repository.find({
+      where: { opportunityId, isDeleted: 0 },
+      order: { createdAt: 'DESC' }
+    });
+  }
+  
+  // 根据跟进方式获取跟进记录
+  async getByMethod(method: FollowUpMethod): Promise<FollowUp[]> {
+    return this.repository.find({
+      where: { method, isDeleted: 0 },
+      order: { createdAt: 'DESC' }
+    });
+  }
+  
+  // 获取即将到期的跟进记录
+  async getUpcomingFollowUps(days: number = 7): Promise<FollowUp[]> {
+    const today = new Date();
+    const futureDate = new Date();
+    futureDate.setDate(today.getDate() + days);
+    
+    return this.repository.createQueryBuilder('follow_up')
+      .where('follow_up.nextFollowUpDate BETWEEN :today AND :futureDate', { 
+        today: today.toISOString().split('T')[0], 
+        futureDate: futureDate.toISOString().split('T')[0] 
+      })
+      .andWhere('follow_up.isDeleted = 0')
+      .orderBy('follow_up.nextFollowUpDate', 'ASC')
+      .getMany();
+  }
+}

+ 173 - 0
src/server/modules/opportunities/opportunity.entity.ts

@@ -0,0 +1,173 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+import { Customer } from '../customers/customer.entity';
+
+export enum OpportunityStage {
+  INITIAL_CONTACT = 'initial_contact',
+  NEEDS_ANALYSIS = 'needs_analysis',
+  SOLUTION_PROPOSAL = 'solution_proposal',
+  NEGOTIATION = 'negotiation',
+  CLOSED_WON = 'closed_won',
+  CLOSED_LOST = 'closed_lost'
+}
+
+@Entity('opportunity')
+export class Opportunity {
+  @PrimaryGeneratedColumn({ unsigned: true, comment: '销售机会ID' })
+  id!: number;
+
+  @Column({ name: 'customer_id', type: 'int', unsigned: true, comment: '客户ID' })
+  customerId!: number;
+
+  @ManyToOne(() => Customer)
+  @JoinColumn({ name: 'customer_id' })
+  customer!: Customer;
+
+  @Column({ name: 'title', type: 'varchar', length: 255, comment: '销售机会标题' })
+  title!: string;
+
+  @Column({ name: 'amount', type: 'decimal', precision: 10, scale: 2, comment: '预计金额' })
+  amount!: number;
+
+  @Column({ 
+    name: 'stage', 
+    type: 'enum', 
+    enum: OpportunityStage, 
+    default: OpportunityStage.INITIAL_CONTACT, 
+    comment: '销售阶段(initial_contact:初步接触,needs_analysis:需求确认,solution_proposal:方案制定,negotiation:谈判阶段,closed_won:成交,closed_lost:丢失)' 
+  })
+  stage!: OpportunityStage;
+
+  @Column({ name: 'expected_close_date', type: 'date', nullable: true, comment: '预计成交日期' })
+  expectedCloseDate!: Date | null;
+
+  @Column({ name: 'description', type: 'text', nullable: true, comment: '销售机会描述' })
+  description!: string | null;
+
+  @Column({ name: 'is_deleted', type: 'tinyint', default: 0, comment: '是否删除(0:未删除,1:已删除)' })
+  isDeleted!: number;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp', comment: '创建时间' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', comment: '更新时间' })
+  updatedAt!: Date;
+}
+
+export const OpportunitySchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '销售机会ID',
+    example: 1
+  }),
+  customerId: z.number().int().positive().openapi({
+    description: '客户ID',
+    example: 1
+  }),
+  title: z.string().max(255).openapi({
+    description: '销售机会标题',
+    example: '企业软件升级项目'
+  }),
+  amount: z.coerce.number().multipleOf(0.01).openapi({
+    description: '预计金额',
+    example: 50000.00
+  }),
+  stage: z.enum([
+    OpportunityStage.INITIAL_CONTACT,
+    OpportunityStage.NEEDS_ANALYSIS,
+    OpportunityStage.SOLUTION_PROPOSAL,
+    OpportunityStage.NEGOTIATION,
+    OpportunityStage.CLOSED_WON,
+    OpportunityStage.CLOSED_LOST
+  ]).openapi({
+    description: '销售阶段',
+    example: OpportunityStage.INITIAL_CONTACT
+  }),
+  expectedCloseDate: z.coerce.date().nullable().openapi({
+    description: '预计成交日期',
+    example: '2023-12-31'
+  }),
+  description: z.string().nullable().openapi({
+    description: '销售机会描述',
+    example: '为客户提供企业级软件升级方案'
+  }),
+  isDeleted: z.number().int().min(0).max(1).openapi({
+    description: '是否删除(0:未删除,1:已删除)',
+    example: 0
+  }),
+  createdAt: z.date().openapi({
+    description: '创建时间',
+    example: '2023-01-01T00:00:00Z'
+  }),
+  updatedAt: z.date().openapi({
+    description: '更新时间',
+    example: '2023-01-01T00:00:00Z'
+  })
+});
+
+export const CreateOpportunityDto = z.object({
+  customerId: z.coerce.number().int().positive().openapi({
+    description: '客户ID',
+    example: 1
+  }),
+  title: z.string().max(255).openapi({
+    description: '销售机会标题',
+    example: '企业软件升级项目'
+  }),
+  amount: z.coerce.number().multipleOf(0.01).openapi({
+    description: '预计金额',
+    example: 50000.00
+  }),
+  stage: z.enum([
+    OpportunityStage.INITIAL_CONTACT,
+    OpportunityStage.NEEDS_ANALYSIS,
+    OpportunityStage.SOLUTION_PROPOSAL,
+    OpportunityStage.NEGOTIATION,
+    OpportunityStage.CLOSED_WON,
+    OpportunityStage.CLOSED_LOST
+  ]).optional().default(OpportunityStage.INITIAL_CONTACT).openapi({
+    description: '销售阶段',
+    example: OpportunityStage.INITIAL_CONTACT
+  }),
+  expectedCloseDate: z.coerce.date().nullable().optional().openapi({
+    description: '预计成交日期',
+    example: '2023-12-31'
+  }),
+  description: z.string().nullable().optional().openapi({
+    description: '销售机会描述',
+    example: '为客户提供企业级软件升级方案'
+  })
+});
+
+export const UpdateOpportunityDto = z.object({
+  customerId: z.coerce.number().int().positive().optional().openapi({
+    description: '客户ID',
+    example: 1
+  }),
+  title: z.string().max(255).optional().openapi({
+    description: '销售机会标题',
+    example: '企业软件升级项目'
+  }),
+  amount: z.coerce.number().multipleOf(0.01).optional().openapi({
+    description: '预计金额',
+    example: 50000.00
+  }),
+  stage: z.enum([
+    OpportunityStage.INITIAL_CONTACT,
+    OpportunityStage.NEEDS_ANALYSIS,
+    OpportunityStage.SOLUTION_PROPOSAL,
+    OpportunityStage.NEGOTIATION,
+    OpportunityStage.CLOSED_WON,
+    OpportunityStage.CLOSED_LOST
+  ]).optional().openapi({
+    description: '销售阶段',
+    example: OpportunityStage.INITIAL_CONTACT
+  }),
+  expectedCloseDate: z.coerce.date().nullable().optional().openapi({
+    description: '预计成交日期',
+    example: '2023-12-31'
+  }),
+  description: z.string().nullable().optional().openapi({
+    description: '销售机会描述',
+    example: '为客户提供企业级软件升级方案'
+  })
+});

+ 44 - 0
src/server/modules/opportunities/opportunity.service.ts

@@ -0,0 +1,44 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource, Repository } from 'typeorm';
+import { Opportunity, OpportunityStage } from './opportunity.entity';
+
+export class OpportunityService extends GenericCrudService<Opportunity> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, Opportunity);
+  }
+  
+  // 根据客户ID获取销售机会
+  async getByCustomerId(customerId: number): Promise<Opportunity[]> {
+    return this.repository.find({
+      where: { customerId, isDeleted: 0 },
+      order: { updatedAt: 'DESC' }
+    });
+  }
+  
+  // 根据销售阶段获取销售机会
+  async getByStage(stage: OpportunityStage): Promise<Opportunity[]> {
+    return this.repository.find({
+      where: { stage, isDeleted: 0 },
+      order: { expectedCloseDate: 'ASC' }
+    });
+  }
+  
+  // 获取特定时间段内的销售机会
+  async getByDateRange(startDate: Date, endDate: Date): Promise<Opportunity[]> {
+    return this.repository.createQueryBuilder('opportunity')
+      .where('opportunity.expectedCloseDate BETWEEN :startDate AND :endDate', { startDate, endDate })
+      .andWhere('opportunity.isDeleted = 0')
+      .orderBy('opportunity.expectedCloseDate', 'ASC')
+      .getMany();
+  }
+  
+  // 获取销售机会总额统计
+  async getTotalAmountByStage(): Promise<{ stage: OpportunityStage, total: number }[]> {
+    return this.repository.createQueryBuilder('opportunity')
+      .select('opportunity.stage', 'stage')
+      .addSelect('SUM(opportunity.amount)', 'total')
+      .where('opportunity.isDeleted = 0')
+      .groupBy('opportunity.stage')
+      .getRawMany();
+  }
+}