Browse Source

✨ feat(admin): 添加客户详情模态框功能

- 新建ClientDetailModal组件,实现多标签页展示客户信息
- 客户详情包含客户档案、联系人、合同记录、费用记录、文件记录和操作记录
- 集成React Query实现数据获取和缓存管理
- 在客户列表页添加"详情"按钮,点击打开详情模态框
- 优化UI展示,使用Descriptions和Table组件格式化数据展示
- 添加加载状态和空数据提示,提升用户体验
yourname 8 tháng trước cách đây
mục cha
commit
7f9baec714

+ 379 - 0
src/client/admin/components/ClientDetailModal.tsx

@@ -0,0 +1,379 @@
+import React, { useState } from 'react';
+import { Modal, Tabs, Descriptions, Table, Spin, Empty, Tag } from 'antd';
+import { App } from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import dayjs from 'dayjs';
+import { clientClient, linkmanClient, hetongClient, expenseClient, fileClient } from '@/client/api';
+import type { InferResponseType } from 'hono/client';
+
+// 定义类型 - 直接匹配API响应格式
+type ClientItem = InferResponseType<typeof clientClient[':id']['$get'], 200>;
+type ContactItem = InferResponseType<typeof linkmanClient.$get, 200>;
+type ContractItem = InferResponseType<typeof hetongClient.$get, 200>;
+type ExpenseItem = InferResponseType<typeof expenseClient.$get, 200>;
+type FileItem = InferResponseType<typeof fileClient.$get, 200>;
+
+const { TabPane } = Tabs;
+
+interface ClientDetailModalProps {
+  clientId: number | null;
+  visible: boolean;
+  onClose: () => void;
+}
+
+const ClientDetailModal: React.FC<ClientDetailModalProps> = ({ clientId, visible, onClose }) => {
+  const { message } = App.useApp();
+  const [activeTab, setActiveTab] = useState('info');
+
+  // 获取客户详情
+  const { data: clientDetail, isLoading: loadingClient } = useQuery({
+    queryKey: ['client', clientId],
+    queryFn: async () => {
+      if (!clientId) return null;
+      const res = await clientClient[':id'].$get({ param: { id: clientId } });
+      if (!res.ok) throw new Error('获取客户详情失败');
+      return res.json();
+    },
+    enabled: !!clientId,
+  });
+
+  // 获取相关联系人
+  const { data: contacts, isLoading: loadingContacts } = useQuery({
+    queryKey: ['contacts', clientId],
+    queryFn: async () => {
+      if (!clientId) return [];
+      const res = await linkmanClient.$get({ query: { page: 1, pageSize: 100 } });
+      if (!res.ok) throw new Error('获取联系人失败');
+      return res.json();
+    },
+    enabled: !!clientId && activeTab === 'contacts',
+    select: (data) => {
+      // 过滤属于当前客户的联系人
+      return data.data.filter(contact => contact.clientId === clientId);
+    }
+  });
+
+  // 获取合同记录
+  const { data: contracts, isLoading: loadingContracts } = useQuery({
+    queryKey: ['contracts', clientId],
+    queryFn: async () => {
+      if (!clientId) return [];
+      const res = await hetongClient.$get({ query: { page: 1, pageSize: 100 } });
+      if (!res.ok) throw new Error('获取合同失败');
+      return res.json();
+    },
+    enabled: !!clientId && activeTab === 'contracts',
+    select: (data) => {
+      return data.data.filter(contract => contract.clientId === clientId);
+    }
+  });
+
+  // 获取费用记录
+  const { data: expenses, isLoading: loadingExpenses } = useQuery({
+    queryKey: ['expenses', clientId],
+    queryFn: async () => {
+      if (!clientId) return [];
+      const res = await expenseClient.$get({ query: { page: 1, pageSize: 100 } });
+      if (!res.ok) throw new Error('获取费用失败');
+      return res.json();
+    },
+    enabled: !!clientId && activeTab === 'expenses',
+    select: (data) => {
+      return data.data.filter(expense => expense.clientId === clientId);
+    }
+  });
+
+  // 获取文件记录
+  const { data: files, isLoading: loadingFiles } = useQuery({
+    queryKey: ['files', clientId],
+    queryFn: async () => {
+      if (!clientId) return [];
+      // 暂时获取所有文件,后续可根据需要过滤
+      const res = await fileClient.$get({ query: { page: 1, pageSize: 100 } });
+      if (!res.ok) throw new Error('获取文件失败');
+      return res.json();
+    },
+    enabled: !!clientId && activeTab === 'files',
+    select: (data) => {
+      // 这里可以根据实际业务逻辑过滤
+      return data.data;
+    }
+  });
+
+  // 客户档案标签页
+  const renderClientInfoTab = () => {
+    if (loadingClient) return <Spin size="large" />;
+    if (!clientDetail) return <Empty description="客户信息加载失败" />;
+
+    const data = clientDetail;
+    return (
+      <div className="px-4">
+        <Descriptions title="基本信息" bordered column={2}>
+          <Descriptions.Item label="公司名称">{data.companyName}</Descriptions.Item>
+          <Descriptions.Item label="联系人">{data.contactPerson || '-'}</Descriptions.Item>
+          <Descriptions.Item label="联系电话">{data.mobile || data.telephone || '-'}</Descriptions.Item>
+          <Descriptions.Item label="邮箱">{data.email || '-'}</Descriptions.Item>
+          <Descriptions.Item label="地址">{data.address || '-'}</Descriptions.Item>
+          <Descriptions.Item label="行业">{data.industry || '-'}</Descriptions.Item>
+          <Descriptions.Item label="客户类型">{data.customerType || '-'}</Descriptions.Item>
+          <Descriptions.Item label="来源">{data.source || '-'}</Descriptions.Item>
+          <Descriptions.Item label="合作日期">
+            {data.startDate ? dayjs(data.startDate).format('YYYY-MM-DD') : '-'}
+          </Descriptions.Item>
+          <Descriptions.Item label="状态">
+            <Tag color={data.status === 1 ? 'success' : 'error'}>
+              {data.status === 1 ? '有效' : '无效'}
+            </Tag>
+          </Descriptions.Item>
+          <Descriptions.Item label="审核状态">
+            <Tag color={data.auditStatus === 1 ? 'success' : data.auditStatus === 2 ? 'error' : 'warning'}>
+              {data.auditStatus === 0 ? '待审核' : data.auditStatus === 1 ? '已审核' : '已拒绝'}
+            </Tag>
+          </Descriptions.Item>
+        </Descriptions>
+
+        <Descriptions title="附加信息" bordered column={2} className="mt-4">
+          <Descriptions.Item label="场地面积">{data.square || '-'}</Descriptions.Item>
+          <Descriptions.Item label="邮编">{data.zipCode || '-'}</Descriptions.Item>
+          <Descriptions.Item label="传真">{data.fax || '-'}</Descriptions.Item>
+          <Descriptions.Item label="网址">{data.homepage || '-'}</Descriptions.Item>
+          <Descriptions.Item label="子行业">{data.subIndustry || '-'}</Descriptions.Item>
+          <Descriptions.Item label="下次联系时间">
+            {data.nextContactTime ? dayjs(data.nextContactTime).format('YYYY-MM-DD HH:mm') : '-'}
+          </Descriptions.Item>
+        </Descriptions>
+
+        {data.description && (
+          <Descriptions title="备注信息" bordered column={1} className="mt-4">
+            <Descriptions.Item label="详细信息">{data.description}</Descriptions.Item>
+            <Descriptions.Item label="备注">{data.remarks || '-'}</Descriptions.Item>
+          </Descriptions>
+        )}
+      </div>
+    );
+  };
+
+  // 联系人标签页
+  const renderContactsTab = () => {
+    if (loadingContacts) return <Spin size="large" />;
+    const contactsData = contacts?.data || [];
+    const filteredContacts = contactsData.filter(contact => contact.clientId === clientId);
+    
+    if (filteredContacts.length === 0) return <Empty description="暂无联系人" />;
+
+    const columns = [
+      { title: '姓名', dataIndex: 'name', key: 'name' },
+      { title: '职位', dataIndex: 'position', key: 'position', render: (text: string) => text || '-' },
+      { title: '手机', dataIndex: 'mobile', key: 'mobile', render: (text: string) => text || '-' },
+      { title: '电话', dataIndex: 'telephone', key: 'telephone', render: (text: string) => text || '-' },
+      { title: '邮箱', dataIndex: 'email', key: 'email', render: (text: string) => text || '-' },
+      { title: 'QQ', dataIndex: 'qq', key: 'qq', render: (text: string) => text || '-' },
+    ];
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={filteredContacts}
+        rowKey="id"
+        pagination={false}
+        scroll={{ x: 800 }}
+      />
+    );
+  };
+
+  // 合同标签页
+  const renderContractsTab = () => {
+    if (loadingContracts) return <Spin size="large" />;
+    const contractsData = contracts?.data || [];
+    const filteredContracts = contractsData.filter(contract => contract.clientId === clientId);
+    
+    if (filteredContracts.length === 0) return <Empty description="暂无合同记录" />;
+
+    const columns = [
+      { title: '合同编号', dataIndex: 'contractNumber', key: 'contractNumber' },
+      {
+        title: '合同金额',
+        dataIndex: 'amount',
+        key: 'amount',
+        render: (amount: number) => `¥${amount.toLocaleString()}`,
+      },
+      {
+        title: '开始日期',
+        dataIndex: 'startDate',
+        key: 'startDate',
+        render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
+      },
+      {
+        title: '结束日期',
+        dataIndex: 'endDate',
+        key: 'endDate',
+        render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
+      },
+      {
+        title: '状态',
+        dataIndex: 'status',
+        key: 'status',
+        render: (status: string) => <Tag>{status}</Tag>,
+      },
+      { title: '描述', dataIndex: 'description', key: 'description', render: (text: string) => text || '-' },
+    ];
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={filteredContracts}
+        rowKey="id"
+        pagination={{ pageSize: 10 }}
+        scroll={{ x: 1000 }}
+      />
+    );
+  };
+
+  // 费用标签页
+  const renderExpensesTab = () => {
+    if (loadingExpenses) return <Spin size="large" />;
+    const expensesData = expenses?.data || [];
+    const filteredExpenses = expensesData.filter(expense => expense.clientId === clientId);
+    
+    if (filteredExpenses.length === 0) return <Empty description="暂无费用记录" />;
+
+    const columns = [
+      { title: '费用类型', dataIndex: 'type', key: 'type' },
+      {
+        title: '金额',
+        dataIndex: 'amount',
+        key: 'amount',
+        render: (amount: number) => `¥${amount.toLocaleString()}`,
+      },
+      {
+        title: '发生日期',
+        dataIndex: 'expenseDate',
+        key: 'expenseDate',
+        render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
+      },
+      { title: '描述', dataIndex: 'description', key: 'description', render: (text: string) => text || '-' },
+      {
+        title: '状态',
+        dataIndex: 'status',
+        key: 'status',
+        render: (status: string) => <Tag>{status}</Tag>,
+      },
+      {
+        title: '部门',
+        dataIndex: 'department',
+        key: 'department',
+        render: (text: string) => text || '-',
+      },
+    ];
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={filteredExpenses}
+        rowKey="id"
+        pagination={{ pageSize: 10 }}
+        scroll={{ x: 800 }}
+      />
+    );
+  };
+
+  // 文件标签页
+  const renderFilesTab = () => {
+    if (loadingFiles) return <Spin size="large" />;
+    const filesData = files?.data || [];
+    
+    if (filesData.length === 0) return <Empty description="暂无文件记录" />;
+
+    const columns = [
+      { title: '文件名', dataIndex: 'name', key: 'name' },
+      { title: '类型', dataIndex: 'type', key: 'type', render: (text: string) => text || '-' },
+      {
+        title: '大小',
+        dataIndex: 'size',
+        key: 'size',
+        render: (size: number) => size ? `${(size / 1024).toFixed(2)} KB` : '-',
+      },
+      {
+        title: '上传时间',
+        dataIndex: 'uploadTime',
+        key: 'uploadTime',
+        render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm'),
+      },
+      { title: '描述', dataIndex: 'description', key: 'description', render: (text: string) => text || '-' },
+    ];
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={filesData}
+        rowKey="id"
+        pagination={{ pageSize: 10 }}
+        scroll={{ x: 800 }}
+      />
+    );
+  };
+
+  // 操作记录标签页
+  const renderLogsTab = () => {
+    return (
+      <Empty 
+        description="操作记录功能开发中" 
+        image={Empty.PRESENTED_IMAGE_SIMPLE}
+      />
+    );
+  };
+
+  const clientName = clientDetail?.companyName || '客户详情';
+
+  return (
+    <Modal
+      title={`${clientName} - 详情`}
+      open={visible}
+      onCancel={onClose}
+      footer={null}
+      width={1000}
+      className="client-detail-modal"
+      destroyOnClose
+    >
+      <Tabs
+        activeKey={activeTab}
+        onChange={setActiveTab}
+        tabPosition="top"
+        items={[
+          {
+            key: 'info',
+            label: '客户档案',
+            children: renderClientInfoTab(),
+          },
+          {
+            key: 'contacts',
+            label: '相关联系人',
+            children: renderContactsTab(),
+          },
+          {
+            key: 'contracts',
+            label: '合同记录',
+            children: renderContractsTab(),
+          },
+          {
+            key: 'expenses',
+            label: '费用记录',
+            children: renderExpensesTab(),
+          },
+          {
+            key: 'files',
+            label: '文件记录',
+            children: renderFilesTab(),
+          },
+          {
+            key: 'logs',
+            label: '操作记录',
+            children: renderLogsTab(),
+          },
+        ]}
+      />
+    </Modal>
+  );
+};
+
+export default ClientDetailModal;

+ 31 - 2
src/client/admin/pages/Clients.tsx

@@ -4,7 +4,8 @@ import { App } from 'antd';
 import AreaTreeSelect from '@/client/admin/components/AreaTreeSelect';
 import AreaTreeSelect from '@/client/admin/components/AreaTreeSelect';
 import UserSelect from '@/client/admin/components/UserSelect';
 import UserSelect from '@/client/admin/components/UserSelect';
 import AuditButtons from '@/client/admin/components/AuditButtons';
 import AuditButtons from '@/client/admin/components/AuditButtons';
-import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
+import ClientDetailModal from '@/client/admin/components/ClientDetailModal';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined } from '@ant-design/icons';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { clientClient, areaClient } from '@/client/api';
 import { clientClient, areaClient } from '@/client/api';
 import type { InferResponseType } from 'hono/client';
 import type { InferResponseType } from 'hono/client';
@@ -18,6 +19,8 @@ const Clients: React.FC = () => {
   const { message } = App.useApp();
   const { message } = App.useApp();
   const [form] = Form.useForm();
   const [form] = Form.useForm();
   const [modalVisible, setModalVisible] = useState(false);
   const [modalVisible, setModalVisible] = useState(false);
+  const [detailModalVisible, setDetailModalVisible] = useState(false);
+  const [selectedClientId, setSelectedClientId] = useState<number | null>(null);
   const [editingKey, setEditingKey] = useState<string | null>(null);
   const [editingKey, setEditingKey] = useState<string | null>(null);
   const [searchText, setSearchText] = useState('');
   const [searchText, setSearchText] = useState('');
   const [auditStatusFilter, setAuditStatusFilter] = useState<number | undefined>(undefined);
   const [auditStatusFilter, setAuditStatusFilter] = useState<number | undefined>(undefined);
@@ -158,6 +161,18 @@ const Clients: React.FC = () => {
     setModalVisible(false);
     setModalVisible(false);
     form.resetFields();
     form.resetFields();
   };
   };
+
+  // 查看客户详情
+  const showDetailModal = (record: ClientItem) => {
+    setSelectedClientId(record.id);
+    setDetailModalVisible(true);
+  };
+
+  // 关闭详情弹窗
+  const handleDetailModalClose = () => {
+    setDetailModalVisible(false);
+    setSelectedClientId(null);
+  };
   
   
   // 创建客户
   // 创建客户
   const createClient = useMutation({
   const createClient = useMutation({
@@ -345,10 +360,18 @@ const Clients: React.FC = () => {
     {
     {
       title: '操作',
       title: '操作',
       key: 'action',
       key: 'action',
-      width: 120,
+      width: 180,
       fixed: 'right' as const,
       fixed: 'right' as const,
       render: (_: any, record: ClientItem) => (
       render: (_: any, record: ClientItem) => (
         <Space size="small">
         <Space size="small">
+          <Button
+            size="small"
+            type="link"
+            icon={<EyeOutlined />}
+            onClick={() => showDetailModal(record)}
+          >
+            详情
+          </Button>
           <Button
           <Button
             size="small"
             size="small"
             type="link"
             type="link"
@@ -584,6 +607,12 @@ const Clients: React.FC = () => {
           </Form.Item>
           </Form.Item>
         </Form>
         </Form>
       </Modal>
       </Modal>
+
+      <ClientDetailModal
+        clientId={selectedClientId}
+        visible={detailModalVisible}
+        onClose={handleDetailModalClose}
+      />
     </div>
     </div>
   );
   );
 };
 };